diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 000000000..85de1a5e8 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,17 @@ +version = 1 + +[[analyzers]] +name = "shell" + +[[analyzers]] +name = "javascript" + + [analyzers.meta] + plugins = ["react"] + environment = ["nodejs"] + +[[analyzers]] +name = "python" + + [analyzers.meta] + runtime_version = "3.x.x" \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml new file mode 100644 index 000000000..58c404e37 --- /dev/null +++ b/.github/workflows/build-branch.yml @@ -0,0 +1,213 @@ + +name: Branch Build + +on: + pull_request: + types: + - closed + branches: + - master + - release + - qa + - develop + +env: + TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + +jobs: + branch_build_and_push: + if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }} + name: Build-Push Web/Space/API/Proxy Docker Image + runs-on: ubuntu-20.04 + + steps: + - name: Check out the repo + uses: actions/checkout@v3.3.0 + + # - name: Set Target Branch Name on PR close + # if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }} + # run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV + + # - name: Set Target Branch Name on other than PR close + # if: ${{ github.event_name == 'push' }} + # run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + + - uses: ASzc/change-string-case-action@v2 + id: gh_branch_upper_lower + with: + string: ${{env.TARGET_BRANCH}} + + - uses: mad9000/actions-find-and-replace-string@2 + id: gh_branch_replace_slash + with: + source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }} + find: '/' + replace: '-' + + - uses: mad9000/actions-find-and-replace-string@2 + id: gh_branch_replace_dot + with: + source: ${{ steps.gh_branch_replace_slash.outputs.value }} + find: '.' + replace: '' + + - uses: mad9000/actions-find-and-replace-string@2 + id: gh_branch_clean + with: + source: ${{ steps.gh_branch_replace_dot.outputs.value }} + find: '_' + replace: '' + - name: Uploading Proxy Source + uses: actions/upload-artifact@v3 + with: + name: proxy-src-code + path: ./nginx + - name: Uploading Backend Source + uses: actions/upload-artifact@v3 + with: + name: backend-src-code + path: ./apiserver + - name: Uploading Web Source + uses: actions/upload-artifact@v3 + with: + name: web-src-code + path: | + ./ + !./apiserver + !./nginx + !./deploy + !./space + + - name: Uploading Space Source + uses: actions/upload-artifact@v3 + with: + name: space-src-code + path: | + ./ + !./apiserver + !./nginx + !./deploy + !./web + outputs: + gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }} + + branch_build_push_frontend: + runs-on: ubuntu-20.04 + needs: [ branch_build_and_push ] + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.5.0 + + - name: Login to Docker Hub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Downloading Web Source Code + uses: actions/download-artifact@v3 + with: + name: web-src-code + + - name: Build and Push Frontend to Docker Container Registry + uses: docker/build-push-action@v4.0.0 + with: + context: . + file: ./web/Dockerfile.web + platforms: linux/amd64 + tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_space: + runs-on: ubuntu-20.04 + needs: [ branch_build_and_push ] + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.5.0 + + - name: Login to Docker Hub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Downloading Space Source Code + uses: actions/download-artifact@v3 + with: + name: space-src-code + + - name: Build and Push Space to Docker Hub + uses: docker/build-push-action@v4.0.0 + with: + context: . + file: ./space/Dockerfile.space + platforms: linux/amd64 + tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_backend: + runs-on: ubuntu-20.04 + needs: [ branch_build_and_push ] + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.5.0 + + - name: Login to Docker Hub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Downloading Backend Source Code + uses: actions/download-artifact@v3 + with: + name: backend-src-code + + - name: Build and Push Backend to Docker Hub + uses: docker/build-push-action@v4.0.0 + with: + context: . + file: ./Dockerfile.api + platforms: linux/amd64 + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_proxy: + runs-on: ubuntu-20.04 + needs: [ branch_build_and_push ] + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2.5.0 + + - name: Login to Docker Hub + uses: docker/login-action@v2.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Downloading Proxy Source Code + uses: actions/download-artifact@v3 + with: + name: proxy-src-code + + - name: Build and Push Plane-Proxy to Docker Hub + uses: docker/build-push-action@v4.0.0 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index f48505350..357a48229 100644 --- a/.gitignore +++ b/.gitignore @@ -76,7 +76,7 @@ pnpm-lock.yaml pnpm-workspace.yaml .npmrc +.secrets tmp/ - ## packages dist diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index cd74b6121..9fa847b6e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -hello@plane.so. +squawk@plane.so. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b25a791d0..73d69fb2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like: -- 3rd-party libraries being used and their versions -- a use-case that fails +- 3rd-party libraries being used and their versions +- a use-case that fails Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved. @@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla ### Requirements -- Node.js version v16.18.0 -- Python version 3.8+ -- Postgres version v14 -- Redis version v6.2.7 +- Node.js version v16.18.0 +- Python version 3.8+ +- Postgres version v14 +- Redis version v6.2.7 ### Setup the project @@ -81,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt To ensure consistency throughout the source code, please keep these rules in mind as you are working: -- All features or bug fixes must be tested by one or more specs (unit-tests). -- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. +- All features or bug fixes must be tested by one or more specs (unit-tests). +- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. ## Need help? Questions and suggestions @@ -90,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in ## Ways to contribute -- Try Plane Cloud and the self hosting platform and give feedback -- Add new integrations -- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) -- Share your thoughts and suggestions with us -- Help create tutorials and blog posts -- Request a feature by submitting a proposal -- Report a bug -- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. +- Try Plane Cloud and the self hosting platform and give feedback +- Add new integrations +- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) +- Share your thoughts and suggestions with us +- Help create tutorials and blog posts +- Request a feature by submitting a proposal +- Report a bug +- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. diff --git a/ENV_SETUP.md b/ENV_SETUP.md index 6796c3db6..23faf83f7 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -1,8 +1,10 @@ # Environment Variables + ​ -Environment variables are distributed in various files. Please refer them carefully. +Environment variables are distributed in various files. Please refer them carefully. ## {PROJECT_FOLDER}/.env + File is available in the project root folder​ ``` @@ -41,25 +43,37 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 ``` + ​ + ## {PROJECT_FOLDER}/web/.env.example + ​ + ``` # Enable/Disable OAUTH - default 0 for selfhosted instance NEXT_PUBLIC_ENABLE_OAUTH=0 # Public boards deploy URL NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" ``` + ​ + ## {PROJECT_FOLDER}/spaces/.env.example + ​ + ``` # Flag to toggle OAuth NEXT_PUBLIC_ENABLE_OAUTH=0 ``` + ​ + ## {PROJECT_FOLDER}/apiserver/.env + ​ + ``` # Backend # Debug value for api server use it as 0 for production use @@ -126,7 +140,9 @@ ENABLE_SIGNUP="1" # Email Redirection URL WEB_URL="http://localhost" ``` + ## Updates​ + - The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects. - The naming convention for containers and images has been updated. - The plane-worker image will no longer be maintained, as it has been merged with plane-backend. diff --git a/README.md b/README.md index c00aee995..53679943b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Plane

-

Open-source, self-hosted project planning tool

+

Flexible, extensible open-source project management

diff --git a/apiserver/.env.example b/apiserver/.env.example index 404ddde69..ed6c20499 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -69,3 +69,6 @@ ENABLE_MAGIC_LINK_LOGIN="0" # Email redirections and minio domain settings WEB_URL="http://localhost" + +# Gunicorn Workers +GUNICORN_WORKERS=2 diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index dc25a14e2..9b09f244e 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -6,4 +6,4 @@ python manage.py migrate # Create a Default User python bin/user_script.py -exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - +exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/bin/user_script.py b/apiserver/bin/user_script.py index e115b20b8..a356f2ec9 100644 --- a/apiserver/bin/user_script.py +++ b/apiserver/bin/user_script.py @@ -1,4 +1,4 @@ -import os, sys, random, string +import os, sys import uuid sys.path.append("/code") diff --git a/apiserver/gunicorn.config.py b/apiserver/gunicorn.config.py index 67205b5ec..51c2a5488 100644 --- a/apiserver/gunicorn.config.py +++ b/apiserver/gunicorn.config.py @@ -3,4 +3,4 @@ from psycogreen.gevent import patch_psycopg def post_fork(server, worker): patch_psycopg() - worker.log.info("Made Psycopg2 Green") \ No newline at end of file + worker.log.info("Made Psycopg2 Green") diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py index e4e3e0f9b..4f907dbd6 100644 --- a/apiserver/plane/api/permissions/project.py +++ b/apiserver/plane/api/permissions/project.py @@ -101,4 +101,4 @@ class ProjectLitePermission(BasePermission): workspace__slug=view.workspace_slug, member=request.user, project_id=view.project_id, - ).exists() \ No newline at end of file + ).exists() diff --git a/apiserver/plane/api/serializers/analytic.py b/apiserver/plane/api/serializers/analytic.py index 5f35e1117..9f3ee6d0a 100644 --- a/apiserver/plane/api/serializers/analytic.py +++ b/apiserver/plane/api/serializers/analytic.py @@ -17,7 +17,7 @@ class AnalyticViewSerializer(BaseSerializer): if bool(query_params): validated_data["query"] = issue_filters(query_params, "POST") else: - validated_data["query"] = dict() + validated_data["query"] = {} return AnalyticView.objects.create(**validated_data) def update(self, instance, validated_data): @@ -25,6 +25,6 @@ class AnalyticViewSerializer(BaseSerializer): if bool(query_params): validated_data["query"] = issue_filters(query_params, "POST") else: - validated_data["query"] = dict() + validated_data["query"] = {} validated_data["query"] = issue_filters(query_params, "PATCH") return super().update(instance, validated_data) diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 54aa4fd0c..104a3dd06 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -1,6 +1,3 @@ -# Django imports -from django.db.models.functions import TruncDate - # Third party imports from rest_framework import serializers diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index ae17b749b..f52a90660 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -6,7 +6,6 @@ from .base import BaseSerializer from .issue import IssueFlatSerializer, LabelLiteSerializer from .project import ProjectLiteSerializer from .state import StateLiteSerializer -from .project import ProjectLiteSerializer from .user import UserLiteSerializer from plane.db.models import Inbox, InboxIssue, Issue diff --git a/apiserver/plane/api/serializers/integration/__init__.py b/apiserver/plane/api/serializers/integration/__init__.py index 963fc295e..112ff02d1 100644 --- a/apiserver/plane/api/serializers/integration/__init__.py +++ b/apiserver/plane/api/serializers/integration/__init__.py @@ -5,4 +5,4 @@ from .github import ( GithubIssueSyncSerializer, GithubCommentSyncSerializer, ) -from .slack import SlackProjectSyncSerializer \ No newline at end of file +from .slack import SlackProjectSyncSerializer diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 2b64e22ef..ae033969f 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -5,11 +5,10 @@ from django.utils import timezone from rest_framework import serializers # Module imports -from .base import BaseSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer from .state import StateSerializer, StateLiteSerializer -from .user import UserLiteSerializer -from .project import ProjectSerializer, ProjectLiteSerializer +from .project import ProjectLiteSerializer from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, @@ -232,25 +231,6 @@ class IssueActivitySerializer(BaseSerializer): fields = "__all__" -class IssueCommentSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - issue_detail = IssueFlatSerializer(read_only=True, source="issue") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - - class Meta: - model = IssueComment - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - class IssuePropertySerializer(BaseSerializer): class Meta: @@ -287,7 +267,6 @@ class LabelLiteSerializer(BaseSerializer): class IssueLabelSerializer(BaseSerializer): - # label_details = LabelSerializer(read_only=True, source="label") class Meta: model = IssueLabel @@ -569,7 +548,7 @@ class IssueSerializer(BaseSerializer): ] -class IssueLiteSerializer(BaseSerializer): +class IssueLiteSerializer(DynamicBaseSerializer): workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index aaabd4ae0..48f773b0f 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -4,9 +4,8 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer from .user import UserLiteSerializer -from .project import ProjectSerializer, ProjectLiteSerializer +from .project import ProjectLiteSerializer from .workspace import WorkspaceLiteSerializer -from .issue import IssueStateSerializer from plane.db.models import ( User, @@ -19,7 +18,7 @@ from plane.db.models import ( class ModuleWriteSerializer(BaseSerializer): - members_list = serializers.ListField( + members = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, @@ -39,6 +38,11 @@ class ModuleWriteSerializer(BaseSerializer): "created_at", "updated_at", ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data['members'] = [str(member.id) for member in instance.members.all()] + return data def validate(self, data): if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): @@ -46,7 +50,7 @@ class ModuleWriteSerializer(BaseSerializer): return data def create(self, validated_data): - members = validated_data.pop("members_list", None) + members = validated_data.pop("members", None) project = self.context["project"] @@ -72,7 +76,7 @@ class ModuleWriteSerializer(BaseSerializer): return module def update(self, instance, validated_data): - members = validated_data.pop("members_list", None) + members = validated_data.pop("members", None) if members is not None: ModuleMember.objects.filter(module=instance).delete() diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/api/serializers/page.py index 94f7836de..abdf958cb 100644 --- a/apiserver/plane/api/serializers/page.py +++ b/apiserver/plane/api/serializers/page.py @@ -33,7 +33,7 @@ class PageBlockLiteSerializer(BaseSerializer): class PageSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - labels_list = serializers.ListField( + labels = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, @@ -50,9 +50,13 @@ class PageSerializer(BaseSerializer): "project", "owned_by", ] + def to_representation(self, instance): + data = super().to_representation(instance) + data['labels'] = [str(label.id) for label in instance.labels.all()] + return data def create(self, validated_data): - labels = validated_data.pop("labels_list", None) + labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] page = Page.objects.create( @@ -77,7 +81,7 @@ class PageSerializer(BaseSerializer): return page def update(self, instance, validated_data): - labels = validated_data.pop("labels_list", None) + labels = validated_data.pop("labels", None) if labels is not None: PageLabel.objects.filter(page=instance).delete() PageLabel.objects.bulk_create( diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index 097bc4c93..ad416c340 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -7,8 +7,6 @@ from plane.db.models import State class StateSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = State diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index ab28d0174..b8f9dedd4 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -79,14 +79,14 @@ class UserMeSettingsSerializer(BaseSerializer): email=obj.email ).count() if obj.last_workspace_id is not None: - workspace = Workspace.objects.get( + workspace = Workspace.objects.filter( pk=obj.last_workspace_id, workspace_member__member=obj.id - ) + ).first() return { "last_workspace_id": obj.last_workspace_id, - "last_workspace_slug": workspace.slug, + "last_workspace_slug": workspace.slug if workspace is not None else "", "fallback_workspace_id": obj.last_workspace_id, - "fallback_workspace_slug": workspace.slug, + "fallback_workspace_slug": workspace.slug if workspace is not None else "", "invites": workspace_invites, } else: diff --git a/apiserver/plane/api/serializers/view.py b/apiserver/plane/api/serializers/view.py index a3b6f48be..e7502609a 100644 --- a/apiserver/plane/api/serializers/view.py +++ b/apiserver/plane/api/serializers/view.py @@ -57,7 +57,7 @@ class IssueViewSerializer(BaseSerializer): if bool(query_params): validated_data["query"] = issue_filters(query_params, "POST") else: - validated_data["query"] = dict() + validated_data["query"] = {} return IssueView.objects.create(**validated_data) def update(self, instance, validated_data): @@ -65,7 +65,7 @@ class IssueViewSerializer(BaseSerializer): if bool(query_params): validated_data["query"] = issue_filters(query_params, "POST") else: - validated_data["query"] = dict() + validated_data["query"] = {} validated_data["query"] = issue_filters(query_params, "PATCH") return super().update(instance, validated_data) diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index 8c718a18e..0a80ce8b7 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -110,9 +110,8 @@ class TeamSerializer(BaseSerializer): ] TeamMember.objects.bulk_create(team_members, batch_size=10) return team - else: - team = Team.objects.create(**validated_data) - return team + team = Team.objects.create(**validated_data) + return team def update(self, instance, validated_data): if "members" in validated_data: @@ -124,8 +123,7 @@ class TeamSerializer(BaseSerializer): ] TeamMember.objects.bulk_create(team_members, batch_size=10) return super().update(instance, validated_data) - else: - return super().update(instance, validated_data) + return super().update(instance, validated_data) class WorkspaceThemeSerializer(BaseSerializer): diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index 49c2b772e..957dac24e 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -1,10 +1,10 @@ from .analytic import urlpatterns as analytic_urls from .asset import urlpatterns as asset_urls from .authentication import urlpatterns as authentication_urls -from .configuration import urlpatterns as configuration_urls +from .config import urlpatterns as configuration_urls from .cycle import urlpatterns as cycle_urls from .estimate import urlpatterns as estimate_urls -from .gpt import urlpatterns as gpt_urls +from .external import urlpatterns as external_urls from .importer import urlpatterns as importer_urls from .inbox import urlpatterns as inbox_urls from .integration import urlpatterns as integration_urls @@ -14,10 +14,8 @@ from .notification import urlpatterns as notification_urls from .page import urlpatterns as page_urls from .project import urlpatterns as project_urls from .public_board import urlpatterns as public_board_urls -from .release_note import urlpatterns as release_note_urls from .search import urlpatterns as search_urls from .state import urlpatterns as state_urls -from .unsplash import urlpatterns as unsplash_urls from .user import urlpatterns as user_urls from .views import urlpatterns as view_urls from .workspace import urlpatterns as workspace_urls @@ -30,7 +28,7 @@ urlpatterns = [ *configuration_urls, *cycle_urls, *estimate_urls, - *gpt_urls, + *external_urls, *importer_urls, *inbox_urls, *integration_urls, @@ -40,10 +38,8 @@ urlpatterns = [ *page_urls, *project_urls, *public_board_urls, - *release_note_urls, *search_urls, *state_urls, - *unsplash_urls, *user_urls, *view_urls, *workspace_urls, diff --git a/apiserver/plane/api/urls/configuration.py b/apiserver/plane/api/urls/config.py similarity index 100% rename from apiserver/plane/api/urls/configuration.py rename to apiserver/plane/api/urls/config.py diff --git a/apiserver/plane/api/urls/external.py b/apiserver/plane/api/urls/external.py new file mode 100644 index 000000000..c22289035 --- /dev/null +++ b/apiserver/plane/api/urls/external.py @@ -0,0 +1,25 @@ +from django.urls import path + + +from plane.api.views import UnsplashEndpoint +from plane.api.views import ReleaseNotesEndpoint +from plane.api.views import GPTIntegrationEndpoint + + +urlpatterns = [ + path( + "unsplash/", + UnsplashEndpoint.as_view(), + name="unsplash", + ), + path( + "release-notes/", + ReleaseNotesEndpoint.as_view(), + name="release-notes", + ), + path( + "workspaces//projects//ai-assistant/", + GPTIntegrationEndpoint.as_view(), + name="importer", + ), +] diff --git a/apiserver/plane/api/urls/gpt.py b/apiserver/plane/api/urls/gpt.py deleted file mode 100644 index f2b0362c7..000000000 --- a/apiserver/plane/api/urls/gpt.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - - -from plane.api.views import GPTIntegrationEndpoint - - -urlpatterns = [ - path( - "workspaces//projects//ai-assistant/", - GPTIntegrationEndpoint.as_view(), - name="importer", - ), -] diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index f1ef7c176..23a8e4fa6 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -3,6 +3,8 @@ from django.urls import path from plane.api.views import ( IssueViewSet, + IssueListEndpoint, + IssueListGroupedEndpoint, LabelViewSet, BulkCreateIssueLabelsEndpoint, BulkDeleteIssuesEndpoint, @@ -35,6 +37,16 @@ urlpatterns = [ ), name="project-issue", ), + path( + "v2/workspaces//projects//issues/", + IssueListEndpoint.as_view(), + name="project-issue", + ), + path( + "v3/workspaces//projects//issues/", + IssueListGroupedEndpoint.as_view(), + name="project-issue", + ), path( "workspaces//projects//issues//", IssueViewSet.as_view( diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index b2a3fbd60..2d9e513df 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -4,17 +4,15 @@ from plane.api.views import ( ProjectViewSet, InviteProjectEndpoint, ProjectMemberViewSet, - ProjectMemberEndpoint, ProjectMemberInvitationsViewset, ProjectMemberUserEndpoint, - AddMemberToProjectEndpoint, ProjectJoinEndpoint, AddTeamToProjectEndpoint, ProjectUserViewsEndpoint, ProjectIdentifierEndpoint, ProjectFavoritesViewSet, LeaveProjectEndpoint, - ProjectPublicCoverImagesEndpoint + ProjectPublicCoverImagesEndpoint, ) @@ -53,7 +51,7 @@ urlpatterns = [ ), path( "workspaces//projects//members/", - ProjectMemberViewSet.as_view({"get": "list"}), + ProjectMemberViewSet.as_view({"get": "list", "post": "create"}), name="project-member", ), path( @@ -67,16 +65,6 @@ urlpatterns = [ ), name="project-member", ), - path( - "workspaces//projects//project-members/", - ProjectMemberEndpoint.as_view(), - name="project-member", - ), - path( - "workspaces//projects//members/add/", - AddMemberToProjectEndpoint.as_view(), - name="project", - ), path( "workspaces//projects/join/", ProjectJoinEndpoint.as_view(), diff --git a/apiserver/plane/api/urls/release_note.py b/apiserver/plane/api/urls/release_note.py deleted file mode 100644 index dfbd1ec66..000000000 --- a/apiserver/plane/api/urls/release_note.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - - -from plane.api.views import ReleaseNotesEndpoint - - -urlpatterns = [ - path( - "release-notes/", - ReleaseNotesEndpoint.as_view(), - name="release-notes", - ), -] diff --git a/apiserver/plane/api/urls/state.py b/apiserver/plane/api/urls/state.py index bcfd80cd7..94aa55f24 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -20,11 +20,19 @@ urlpatterns = [ StateViewSet.as_view( { "get": "retrieve", - "put": "update", "patch": "partial_update", "delete": "destroy", } ), name="project-state", ), + path( + "workspaces//projects//states//mark-default/", + StateViewSet.as_view( + { + "post": "mark_as_default", + } + ), + name="project-state", + ), ] diff --git a/apiserver/plane/api/urls/unsplash.py b/apiserver/plane/api/urls/unsplash.py deleted file mode 100644 index 25fab4694..000000000 --- a/apiserver/plane/api/urls/unsplash.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.urls import path - - -from plane.api.views import UnsplashEndpoint - - -urlpatterns = [ - path( - "unsplash/", - UnsplashEndpoint.as_view(), - name="unsplash", - ), -] diff --git a/apiserver/plane/api/urls/workspace.py b/apiserver/plane/api/urls/workspace.py index 7cfc8f27a..f26730833 100644 --- a/apiserver/plane/api/urls/workspace.py +++ b/apiserver/plane/api/urls/workspace.py @@ -5,7 +5,6 @@ from plane.api.views import ( WorkSpaceViewSet, InviteWorkspaceEndpoint, WorkSpaceMemberViewSet, - WorkspaceMembersEndpoint, WorkspaceInvitationsViewset, WorkspaceMemberUserEndpoint, WorkspaceMemberUserViewsEndpoint, @@ -86,11 +85,6 @@ urlpatterns = [ ), name="workspace-member", ), - path( - "workspaces//workspace-members/", - WorkspaceMembersEndpoint.as_view(), - name="workspace-members", - ), path( "workspaces//teams/", TeamMemberViewSet.as_view( diff --git a/apiserver/plane/api/urls_deprecated.py b/apiserver/plane/api/urls_deprecated.py index c108257b3..67cc62e46 100644 --- a/apiserver/plane/api/urls_deprecated.py +++ b/apiserver/plane/api/urls_deprecated.py @@ -28,7 +28,6 @@ from plane.api.views import ( ## End User # Workspaces WorkSpaceViewSet, - UserWorkspaceInvitationsEndpoint, UserWorkSpacesEndpoint, InviteWorkspaceEndpoint, JoinWorkspaceEndpoint, diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index e17550050..ca66ce48e 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -7,14 +7,12 @@ from .project import ( ProjectMemberInvitationsViewset, ProjectMemberInviteDetailViewSet, ProjectIdentifierEndpoint, - AddMemberToProjectEndpoint, ProjectJoinEndpoint, ProjectUserViewsEndpoint, ProjectMemberUserEndpoint, ProjectFavoritesViewSet, ProjectDeployBoardViewSet, ProjectDeployBoardPublicSettingsEndpoint, - ProjectMemberEndpoint, WorkspaceProjectDeployBoardEndpoint, LeaveProjectEndpoint, ProjectPublicCoverImagesEndpoint, @@ -53,11 +51,15 @@ from .workspace import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, - WorkspaceMembersEndpoint, LeaveWorkspaceEndpoint, ) from .state import StateViewSet -from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet +from .view import ( + GlobalViewViewSet, + GlobalViewIssuesViewSet, + IssueViewViewSet, + IssueViewFavoriteViewSet, +) from .cycle import ( CycleViewSet, CycleIssueViewSet, @@ -68,6 +70,8 @@ from .cycle import ( from .asset import FileAssetEndpoint, UserAssetsEndpoint from .issue import ( IssueViewSet, + IssueListEndpoint, + IssueListGroupedEndpoint, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, @@ -165,8 +169,12 @@ from .analytic import ( DefaultAnalyticsEndpoint, ) -from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet +from .notification import ( + NotificationViewSet, + UnreadNotificationEndpoint, + MarkAllReadNotificationViewSet, +) from .exporter import ExportIssuesEndpoint -from .config import ConfigurationEndpoint \ No newline at end of file +from .config import ConfigurationEndpoint diff --git a/apiserver/plane/api/views/auth_extended.py b/apiserver/plane/api/views/auth_extended.py index c7107ecfa..fbffacff8 100644 --- a/apiserver/plane/api/views/auth_extended.py +++ b/apiserver/plane/api/views/auth_extended.py @@ -55,11 +55,11 @@ class VerifyEmailEndpoint(BaseAPIView): return Response( {"email": "Successfully activated"}, status=status.HTTP_200_OK ) - except jwt.ExpiredSignatureError as indentifier: + except jwt.ExpiredSignatureError as _indentifier: return Response( {"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST ) - except jwt.exceptions.DecodeError as indentifier: + except jwt.exceptions.DecodeError as _indentifier: return Response( {"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST ) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 2f01abb0c..eadfeef61 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -249,11 +249,11 @@ class MagicSignInGenerateEndpoint(BaseAPIView): ## Generate a random token token = ( - "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + "".join(random.choices(string.ascii_lowercase, k=4)) + "-" - + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + + "".join(random.choices(string.ascii_lowercase, k=4)) + "-" - + "".join(random.choices(string.ascii_lowercase + string.digits, k=4)) + + "".join(random.choices(string.ascii_lowercase, k=4)) ) ri = redis_instance() diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py index f59ca04a0..d035c4740 100644 --- a/apiserver/plane/api/views/config.py +++ b/apiserver/plane/api/views/config.py @@ -21,8 +21,8 @@ class ConfigurationEndpoint(BaseAPIView): def get(self, request): data = {} - data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None) - data["github"] = os.environ.get("GITHUB_CLIENT_ID", None) + data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None) + data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None) data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) data["magic_login"] = ( bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) @@ -30,4 +30,8 @@ class ConfigurationEndpoint(BaseAPIView): data["email_password_login"] = ( os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" ) + data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None) + data["posthog_api_key"] = os.environ.get("POSTHOG_API_KEY", None) + data["posthog_host"] = os.environ.get("POSTHOG_HOST", None) + data["has_unsplash_configured"] = bool(settings.UNSPLASH_ACCESS_KEY) return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index b18c42d86..21defcc13 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -3,7 +3,6 @@ import json # Django imports from django.db.models import ( - OuterRef, Func, F, Q, @@ -177,9 +176,8 @@ class CycleViewSet(BaseViewSet): def list(self, request, slug, project_id): queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - order_by = request.GET.get("order_by", "sort_order") - queryset = queryset.order_by(order_by) + queryset = queryset.order_by("-is_favorite","-created_at") # Current Cycle if cycle_view == "current": @@ -480,13 +478,13 @@ class CycleViewSet(BaseViewSet): ) ) cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - # Delete the cycle - cycle.delete() + issue_activity.delay( type="cycle.activity.deleted", requested_data=json.dumps( { "cycle_id": str(pk), + "cycle_name": str(cycle.name), "issues": [str(issue_id) for issue_id in cycle_issues], } ), @@ -496,6 +494,8 @@ class CycleViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), ) + # Delete the cycle + cycle.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -512,12 +512,6 @@ class CycleIssueViewSet(BaseViewSet): "issue__assignees__id", ] - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - cycle_id=self.kwargs.get("cycle_id"), - ) - def get_queryset(self): return self.filter_queryset( super() @@ -670,7 +664,7 @@ class CycleIssueViewSet(BaseViewSet): type="cycle.activity.created", requested_data=json.dumps({"cycles_list": issues}), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=json.dumps( { diff --git a/apiserver/plane/api/views/external.py b/apiserver/plane/api/views/external.py index 755879dc6..a04495569 100644 --- a/apiserver/plane/api/views/external.py +++ b/apiserver/plane/api/views/external.py @@ -89,4 +89,4 @@ class UnsplashEndpoint(BaseAPIView): } resp = requests.get(url=url, headers=headers) - return Response(resp.json(), status=status.HTTP_200_OK) + return Response(resp.json(), status=resp.status_code) diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index 373324d5d..4060b2bd5 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -39,6 +39,7 @@ from plane.utils.integrations.github import get_github_repo_details from plane.utils.importers.jira import jira_project_issue_summary from plane.bgtasks.importer_task import service_importer from plane.utils.html_processor import strip_tags +from plane.api.permissions import WorkSpaceAdminPermission class ServiceIssueImportSummaryEndpoint(BaseAPIView): @@ -119,6 +120,9 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView): class ImportServiceEndpoint(BaseAPIView): + permission_classes = [ + WorkSpaceAdminPermission, + ] def post(self, request, slug, service): project_id = request.data.get("project_id", False) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 632da0d95..517e9b6de 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -360,8 +360,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ) .select_related("issue", "workspace", "project") ) - else: - return InboxIssue.objects.none() + return InboxIssue.objects.none() def list(self, request, slug, project_id, inbox_id): project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id) diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/api/views/integration/base.py index 65b94d0a1..cc911b537 100644 --- a/apiserver/plane/api/views/integration/base.py +++ b/apiserver/plane/api/views/integration/base.py @@ -1,6 +1,6 @@ # Python improts import uuid - +import requests # Django imports from django.contrib.auth.hashers import make_password @@ -25,7 +25,7 @@ from plane.utils.integrations.github import ( delete_github_installation, ) from plane.api.permissions import WorkSpaceAdminPermission - +from plane.utils.integrations.slack import slack_oauth class IntegrationViewSet(BaseViewSet): serializer_class = IntegrationSerializer @@ -98,12 +98,19 @@ class WorkspaceIntegrationViewSet(BaseViewSet): config = {"installation_id": installation_id} if provider == "slack": - metadata = request.data.get("metadata", {}) + code = request.data.get("code", False) + + if not code: + return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST) + + slack_response = slack_oauth(code=code) + + metadata = slack_response access_token = metadata.get("access_token", False) team_id = metadata.get("team", {}).get("id", False) if not metadata or not access_token or not team_id: return Response( - {"error": "Access token and team id is required"}, + {"error": "Slack could not be installed. Please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) config = {"team_id": team_id, "access_token": access_token} diff --git a/apiserver/plane/api/views/integration/slack.py b/apiserver/plane/api/views/integration/slack.py index 83aa951ba..6b1b47d37 100644 --- a/apiserver/plane/api/views/integration/slack.py +++ b/apiserver/plane/api/views/integration/slack.py @@ -11,6 +11,7 @@ from plane.api.views import BaseViewSet, BaseAPIView from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember from plane.api.serializers import SlackProjectSyncSerializer from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission +from plane.utils.integrations.slack import slack_oauth class SlackProjectSyncViewSet(BaseViewSet): @@ -32,25 +33,47 @@ class SlackProjectSyncViewSet(BaseViewSet): ) def create(self, request, slug, project_id, workspace_integration_id): - serializer = SlackProjectSyncSerializer(data=request.data) + try: + code = request.data.get("code", False) - workspace_integration = WorkspaceIntegration.objects.get( - workspace__slug=slug, pk=workspace_integration_id - ) + if not code: + return Response( + {"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST + ) - if serializer.is_valid(): - serializer.save( - project_id=project_id, - workspace_integration_id=workspace_integration_id, + slack_response = slack_oauth(code=code) + + workspace_integration = WorkspaceIntegration.objects.get( + workspace__slug=slug, pk=workspace_integration_id ) workspace_integration = WorkspaceIntegration.objects.get( pk=workspace_integration_id, workspace__slug=slug ) - + slack_project_sync = SlackProjectSync.objects.create( + access_token=slack_response.get("access_token"), + scopes=slack_response.get("scope"), + bot_user_id=slack_response.get("bot_user_id"), + webhook_url=slack_response.get("incoming_webhook", {}).get("url"), + data=slack_response, + team_id=slack_response.get("team", {}).get("id"), + team_name=slack_response.get("team", {}).get("name"), + workspace_integration=workspace_integration, + project_id=project_id, + ) _ = ProjectMember.objects.get_or_create( member=workspace_integration.actor, role=20, project_id=project_id ) - + serializer = SlackProjectSyncSerializer(slack_project_sync) return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "Slack is already installed for the project"}, + status=status.HTTP_410_GONE, + ) + capture_exception(e) + return Response( + {"error": "Slack could not be installed. Please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 99f2de2c2..d1cd93e73 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -39,7 +39,6 @@ from plane.api.serializers import ( IssueActivitySerializer, IssueCommentSerializer, IssuePropertySerializer, - LabelSerializer, IssueSerializer, LabelSerializer, IssueFlatSerializer, @@ -235,10 +234,7 @@ class IssueViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - return Response( - issues, status=status.HTTP_200_OK - ) - + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -316,6 +312,104 @@ class IssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +class IssueListEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + filters = issue_filters(request.query_params, "GET") + + issue_queryset = ( + Issue.objects.filter(workspace__slug=slug, project_id=project_id) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .distinct() + ) + + serializer = IssueLiteSerializer( + issue_queryset, many=True, fields=fields if fields else None + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueListGroupedEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + fields = [field for field in request.GET.get("fields", "").split(",") if field] + + issue_queryset = ( + Issue.objects.filter(workspace__slug=slug, project_id=project_id) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(module_id=F("issue_module__module_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .distinct() + ) + + issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data + issue_dict = {str(issue["id"]): issue for issue in issues} + return Response( + issue_dict, + status=status.HTTP_200_OK, + ) + + class UserWorkSpaceIssues(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug): @@ -443,9 +537,7 @@ class UserWorkSpaceIssues(BaseAPIView): status=status.HTTP_200_OK, ) - return Response( - issues, status=status.HTTP_200_OK - ) + return Response(issues, status=status.HTTP_200_OK) class WorkSpaceIssuesEndpoint(BaseAPIView): @@ -623,13 +715,12 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView): serializer = IssuePropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) - def get(self, request, slug, project_id): - issue_property, _ = IssueProperty.objects.get_or_create( - user=request.user, project_id=project_id - ) - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_200_OK) + issue_property, _ = IssueProperty.objects.get_or_create( + user=request.user, project_id=project_id + ) + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) class LabelViewSet(BaseViewSet): @@ -780,6 +871,20 @@ class SubIssuesEndpoint(BaseAPIView): updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) + # Track the issue + _ = [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"parent": str(issue_id)}), + actor_id=str(request.user.id), + issue_id=str(sub_issue_id), + project_id=str(project_id), + current_instance=json.dumps({"parent": str(sub_issue_id)}), + epoch=int(timezone.now().timestamp()), + ) + for sub_issue_id in sub_issue_ids + ] + return Response( IssueFlatSerializer(updated_sub_issues, many=True).data, status=status.HTTP_200_OK, @@ -1092,17 +1197,19 @@ class IssueArchiveViewSet(BaseViewSet): archived_at__isnull=False, pk=pk, ) - issue.archived_at = None - issue.save() issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps({"archived_at": None}), actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_id), - current_instance=None, + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), epoch=int(timezone.now().timestamp()), ) + issue.archived_at = None + issue.save() return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -1396,8 +1503,7 @@ class IssueCommentPublicViewSet(BaseViewSet): ) .distinct() ).order_by("created_at") - else: - return IssueComment.objects.none() + return IssueComment.objects.none() except ProjectDeployBoard.DoesNotExist: return IssueComment.objects.none() @@ -1522,8 +1628,7 @@ class IssueReactionPublicViewSet(BaseViewSet): .order_by("-created_at") .distinct() ) - else: - return IssueReaction.objects.none() + return IssueReaction.objects.none() except ProjectDeployBoard.DoesNotExist: return IssueReaction.objects.none() @@ -1618,8 +1723,7 @@ class CommentReactionPublicViewSet(BaseViewSet): .order_by("-created_at") .distinct() ) - else: - return CommentReaction.objects.none() + return CommentReaction.objects.none() except ProjectDeployBoard.DoesNotExist: return CommentReaction.objects.none() @@ -1713,8 +1817,7 @@ class IssueVotePublicViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) ) - else: - return IssueVote.objects.none() + return IssueVote.objects.none() except ProjectDeployBoard.DoesNotExist: return IssueVote.objects.none() @@ -2160,9 +2263,7 @@ class IssueDraftViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - return Response( - issues, status=status.HTTP_200_OK - ) + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -2227,7 +2328,7 @@ class IssueDraftViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) current_instance = json.dumps( - IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + IssueSerializer(issue).data, cls=DjangoJSONEncoder ) issue.delete() issue_activity.delay( diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 48f892764..6c2088922 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -55,7 +55,6 @@ class ModuleViewSet(BaseViewSet): ) def get_queryset(self): - order_by = self.request.GET.get("order_by", "sort_order") subquery = ModuleFavorite.objects.filter( user=self.request.user, @@ -138,7 +137,7 @@ class ModuleViewSet(BaseViewSet): ), ) ) - .order_by(order_by, "name") + .order_by("-is_favorite","-created_at") ) def create(self, request, slug, project_id): @@ -266,12 +265,12 @@ class ModuleViewSet(BaseViewSet): module_issues = list( ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) ) - module.delete() issue_activity.delay( type="module.activity.deleted", requested_data=json.dumps( { "module_id": str(pk), + "module_name": str(module.name), "issues": [str(issue_id) for issue_id in module_issues], } ), @@ -281,6 +280,7 @@ class ModuleViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), ) + module.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -297,12 +297,6 @@ class ModuleIssueViewSet(BaseViewSet): ProjectEntityPermission, ] - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - module_id=self.kwargs.get("module_id"), - ) - def get_queryset(self): return self.filter_queryset( super() @@ -446,7 +440,7 @@ class ModuleIssueViewSet(BaseViewSet): type="module.activity.created", requested_data=json.dumps({"modules_list": issues}), actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), + issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=json.dumps( { diff --git a/apiserver/plane/api/views/oauth.py b/apiserver/plane/api/views/oauth.py index a7740b49a..4eb5d35f7 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/api/views/oauth.py @@ -12,7 +12,6 @@ from django.conf import settings from rest_framework.response import Response from rest_framework import exceptions from rest_framework.permissions import AllowAny -from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken from rest_framework import status from sentry_sdk import capture_exception @@ -116,7 +115,7 @@ def get_user_data(access_token: str) -> dict: url="https://api.github.com/user/emails", headers=headers ).json() - [ + _ = [ user_data.update({"email": item.get("email")}) for item in response if item.get("primary") is True @@ -150,7 +149,7 @@ class OauthEndpoint(BaseAPIView): data = get_user_data(access_token) email = data.get("email", None) - if email == None: + if email is None: return Response( { "error": "Something went wrong. Please try again later or contact the support team." @@ -161,7 +160,6 @@ class OauthEndpoint(BaseAPIView): if "@" in email: user = User.objects.get(email=email) email = data["email"] - channel = "email" mobile_number = uuid.uuid4().hex email_verified = True else: @@ -185,7 +183,7 @@ class OauthEndpoint(BaseAPIView): user.last_active = timezone.now() user.last_login_time = timezone.now() user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_medium = f"oauth" + user.last_login_medium = "oauth" user.last_login_uagent = request.META.get("HTTP_USER_AGENT") user.is_email_verified = email_verified user.save() @@ -236,7 +234,6 @@ class OauthEndpoint(BaseAPIView): if "@" in email: email = data["email"] mobile_number = uuid.uuid4().hex - channel = "email" email_verified = True else: return Response( diff --git a/apiserver/plane/api/views/page.py b/apiserver/plane/api/views/page.py index fd31cdf14..ca0927a51 100644 --- a/apiserver/plane/api/views/page.py +++ b/apiserver/plane/api/views/page.py @@ -1,5 +1,5 @@ # Python imports -from datetime import timedelta, datetime, date +from datetime import timedelta, date # Django imports from django.db.models import Exists, OuterRef, Q, Prefetch diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 632a5bf53..37e491e83 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -11,7 +11,6 @@ from django.db.models import ( Q, Exists, OuterRef, - Func, F, Func, Subquery, @@ -35,7 +34,6 @@ from plane.api.serializers import ( ProjectDetailSerializer, ProjectMemberInviteSerializer, ProjectFavoriteSerializer, - IssueLiteSerializer, ProjectDeployBoardSerializer, ProjectMemberAdminSerializer, ) @@ -84,7 +82,7 @@ class ProjectViewSet(BaseViewSet): ] def get_serializer_class(self, *args, **kwargs): - if self.action == "update" or self.action == "partial_update": + if self.action in ["update", "partial_update"]: return ProjectSerializer return ProjectDetailSerializer @@ -336,7 +334,7 @@ class ProjectViewSet(BaseViewSet): {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) - except Project.DoesNotExist or Workspace.DoesNotExist as e: + except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND ) @@ -482,6 +480,83 @@ class ProjectMemberViewSet(BaseViewSet): .select_related("workspace", "workspace__owner") ) + def create(self, request, slug, project_id): + members = request.data.get("members", []) + + # get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + if not len(members): + return Response( + {"error": "Atleast one member is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_project_members = [] + bulk_issue_props = [] + + project_members = ( + ProjectMember.objects.filter( + workspace__slug=slug, + member_id__in=[member.get("member_id") for member in members], + ) + .values("member_id", "sort_order") + .order_by("sort_order") + ) + + for member in members: + sort_order = [ + project_member.get("sort_order") + for project_member in project_members + if str(project_member.get("member_id")) == str(member.get("member_id")) + ] + bulk_project_members.append( + ProjectMember( + member_id=member.get("member_id"), + role=member.get("role", 10), + project_id=project_id, + workspace_id=project.workspace_id, + sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, + ) + ) + bulk_issue_props.append( + IssueProperty( + user_id=member.get("member_id"), + project_id=project_id, + workspace_id=project.workspace_id, + ) + ) + + project_members = ProjectMember.objects.bulk_create( + bulk_project_members, + batch_size=10, + ignore_conflicts=True, + ) + + _ = IssueProperty.objects.bulk_create( + bulk_issue_props, batch_size=10, ignore_conflicts=True + ) + + serializer = ProjectMemberSerializer(project_members, many=True) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def list(self, request, slug, project_id): + project_member = ProjectMember.objects.get( + member=request.user, workspace__slug=slug, project_id=project_id + ) + + project_members = ProjectMember.objects.filter( + project_id=project_id, + workspace__slug=slug, + member__is_bot=False, + ).select_related("project", "member", "workspace") + + if project_member.role > 10: + serializer = ProjectMemberAdminSerializer(project_members, many=True) + else: + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( pk=pk, workspace__slug=slug, project_id=project_id @@ -567,73 +642,6 @@ class ProjectMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class AddMemberToProjectEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - - def post(self, request, slug, project_id): - members = request.data.get("members", []) - - # get the project - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - if not len(members): - return Response( - {"error": "Atleast one member is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - bulk_project_members = [] - bulk_issue_props = [] - - project_members = ( - ProjectMember.objects.filter( - workspace__slug=slug, - member_id__in=[member.get("member_id") for member in members], - ) - .values("member_id", "sort_order") - .order_by("sort_order") - ) - - for member in members: - sort_order = [ - project_member.get("sort_order") - for project_member in project_members - if str(project_member.get("member_id")) - == str(member.get("member_id")) - ] - bulk_project_members.append( - ProjectMember( - member_id=member.get("member_id"), - role=member.get("role", 10), - project_id=project_id, - workspace_id=project.workspace_id, - sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, - ) - ) - bulk_issue_props.append( - IssueProperty( - user_id=member.get("member_id"), - project_id=project_id, - workspace_id=project.workspace_id, - ) - ) - - project_members = ProjectMember.objects.bulk_create( - bulk_project_members, - batch_size=10, - ignore_conflicts=True, - ) - - _ = IssueProperty.objects.bulk_create( - bulk_issue_props, batch_size=10, ignore_conflicts=True - ) - - serializer = ProjectMemberSerializer(project_members, many=True) - - return Response(serializer.data, status=status.HTTP_201_CREATED) - - class AddTeamToProjectEndpoint(BaseAPIView): permission_classes = [ ProjectBasePermission, @@ -933,21 +941,6 @@ class ProjectDeployBoardViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) -class ProjectMemberEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - - def get(self, request, slug, project_id): - project_members = ProjectMember.objects.filter( - project_id=project_id, - workspace__slug=slug, - member__is_bot=False, - ).select_related("project", "member", "workspace") - serializer = ProjectMemberSerializer(project_members, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): permission_classes = [ AllowAny, diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 063abf0e3..dbb6e1d71 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -47,36 +47,45 @@ class StateViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request, slug, project_id): - state_dict = dict() states = StateSerializer(self.get_queryset(), many=True).data + grouped = request.GET.get("grouped", False) + if grouped == "true": + state_dict = {} + for key, value in groupby( + sorted(states, key=lambda state: state["group"]), + lambda state: state.get("group"), + ): + state_dict[str(key)] = list(value) + return Response(state_dict, status=status.HTTP_200_OK) + return Response(states, status=status.HTTP_200_OK) - for key, value in groupby( - sorted(states, key=lambda state: state["group"]), - lambda state: state.get("group"), - ): - state_dict[str(key)] = list(value) - - return Response(state_dict, status=status.HTTP_200_OK) + def mark_as_default(self, request, slug, project_id, pk): + # Select all the states which are marked as default + _ = State.objects.filter( + workspace__slug=slug, project_id=project_id, default=True + ).update(default=False) + _ = State.objects.filter( + workspace__slug=slug, project_id=project_id, pk=pk + ).update(default=True) + return Response(status=status.HTTP_204_NO_CONTENT) def destroy(self, request, slug, project_id, pk): state = State.objects.get( ~Q(name="Triage"), - pk=pk, project_id=project_id, workspace__slug=slug, + pk=pk, + project_id=project_id, + workspace__slug=slug, ) if state.default: - return Response( - {"error": "Default state cannot be deleted"}, status=False - ) + return Response({"error": "Default state cannot be deleted"}, status=False) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=pk).exists() if issue_exist: return Response( - { - "error": "The state is not empty, only empty states can be deleted" - }, + {"error": "The state is not empty, only empty states can be deleted"}, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/user.py b/apiserver/plane/api/views/user.py index f17b176ba..2e40565b4 100644 --- a/apiserver/plane/api/views/user.py +++ b/apiserver/plane/api/views/user.py @@ -19,7 +19,6 @@ from plane.db.models import ( WorkspaceMemberInvite, Issue, IssueActivity, - WorkspaceMember, ) from plane.utils.paginator import BasePaginator diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 165a96179..c53fbf126 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -6,12 +6,10 @@ from uuid import uuid4 # Django imports from django.db import IntegrityError -from django.db.models import Prefetch from django.conf import settings from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email -from django.contrib.sites.shortcuts import get_current_site from django.db.models import ( Prefetch, OuterRef, @@ -55,7 +53,6 @@ from . import BaseViewSet from plane.db.models import ( User, Workspace, - WorkspaceMember, WorkspaceMemberInvite, Team, ProjectMember, @@ -472,7 +469,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): model = WorkspaceMember permission_classes = [ - WorkSpaceAdminPermission, + WorkspaceEntityPermission, ] search_fields = [ @@ -489,6 +486,25 @@ class WorkSpaceMemberViewSet(BaseViewSet): .select_related("member") ) + def list(self, request, slug): + workspace_member = WorkspaceMember.objects.get( + member=request.user, workspace__slug=slug + ) + + workspace_members = WorkspaceMember.objects.filter( + workspace__slug=slug, + member__is_bot=False, + ).select_related("workspace", "member") + + if workspace_member.role > 10: + serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True) + else: + serializer = WorkSpaceMemberSerializer( + workspace_members, + many=True, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + def partial_update(self, request, slug, pk): workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug) if request.user.id == workspace_member.member_id: @@ -1252,20 +1268,6 @@ class WorkspaceLabelsEndpoint(BaseAPIView): return Response(labels, status=status.HTTP_200_OK) -class WorkspaceMembersEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] - - def get(self, request, slug): - workspace_members = WorkspaceMember.objects.filter( - workspace__slug=slug, - member__is_bot=False, - ).select_related("workspace", "member") - serialzier = WorkSpaceMemberSerializer(workspace_members, many=True) - return Response(serialzier.data, status=status.HTTP_200_OK) - - class LeaveWorkspaceEndpoint(BaseAPIView): permission_classes = [ WorkspaceEntityPermission, diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index bf507a810..220819e28 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -409,7 +409,6 @@ def analytic_export_task(email, data, slug): distribution, x_axis, y_axis, - segment, key, assignee_details, label_details, diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index 76c2441b3..6075c247f 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -26,7 +26,7 @@ def email_verification(first_name, email, token, current_site): from_email_string = settings.EMAIL_FROM - subject = f"Verify your Email!" + subject = "Verify your Email!" context = { "first_name": first_name, diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index c1883f343..cc1d6cc0b 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -11,8 +11,6 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception -# Module imports -from plane.db.models import User @shared_task @@ -24,7 +22,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): from_email_string = settings.EMAIL_FROM - subject = f"Reset Your Password - Plane" + subject = "Reset Your Password - Plane" context = { "first_name": first_name, diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index fe6a08a51..15b1432da 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -6,6 +6,7 @@ import jwt import logging from datetime import datetime + # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder @@ -28,7 +29,6 @@ from plane.db.models import ( User, IssueProperty, ) -from .workspace_invitation_task import workspace_invitation from plane.bgtasks.user_welcome_task import send_welcome_slack @@ -59,7 +59,7 @@ def service_importer(service, importer_id): ignore_conflicts=True, ) - [ + _ = [ send_welcome_slack.delay( str(user.id), True, @@ -158,7 +158,7 @@ def service_importer(service, importer_id): ) # Create repo sync - repo_sync = GithubRepositorySync.objects.create( + _ = GithubRepositorySync.objects.create( repository=repo, workspace_integration=workspace_integration, actor=workspace_integration.actor, @@ -180,7 +180,7 @@ def service_importer(service, importer_id): ImporterSerializer(importer).data, cls=DjangoJSONEncoder, ) - res = requests.post( + _ = requests.post( f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/", json=import_data_json, headers=headers, diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 9a6d95b49..3146e6c16 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -83,7 +83,7 @@ def track_description( if ( last_activity is not None and last_activity.field == "description" - and actor_id == last_activity.actor_id + and actor_id == str(last_activity.actor_id) ): last_activity.created_at = timezone.now() last_activity.save(update_fields=["created_at"]) @@ -132,7 +132,7 @@ def track_parent( else "", field="parent", project_id=project_id, - workspace=workspace_id, + workspace_id=workspace_id, comment=f"updated the parent issue to", old_identifier=old_parent.id if old_parent is not None else None, new_identifier=new_parent.id if new_parent is not None else None, @@ -277,7 +277,7 @@ def track_labels( issue_activities, epoch, ): - requested_labels = set([str(lab) for lab in requested_data.get("labels_list", [])]) + requested_labels = set([str(lab) for lab in requested_data.get("labels", [])]) current_labels = set([str(lab) for lab in current_instance.get("labels", [])]) added_labels = requested_labels - current_labels @@ -335,9 +335,7 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set( - [str(asg) for asg in requested_data.get("assignees_list", [])] - ) + requested_assignees = set([str(asg) for asg in requested_data.get("assignees", [])]) current_assignees = set([str(asg) for asg in current_instance.get("assignees", [])]) added_assignees = requested_assignees - current_assignees @@ -364,17 +362,19 @@ def track_assignees( for dropped_assignee in dropped_assginees: assignee = User.objects.get(pk=dropped_assignee) issue_activities.append( - issue_id=issue_id, - actor_id=actor_id, - verb="updated", - old_value=assignee.display_name, - new_value="", - field="assignees", - project_id=project_id, - workspace_id=workspace_id, - comment=f"removed assignee ", - old_identifier=assignee.id, - epoch=epoch, + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=assignee.display_name, + new_value="", + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed assignee ", + old_identifier=assignee.id, + epoch=epoch, + ) ) @@ -419,36 +419,37 @@ def track_archive_at( issue_activities, epoch, ): - if requested_data.get("archived_at") is None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - project_id=project_id, - workspace_id=workspace_id, - comment=f"has restored the issue", - verb="updated", - actor_id=actor_id, - field="archived_at", - old_value="archive", - new_value="restore", - epoch=epoch, + if current_instance.get("archived_at") != requested_data.get("archived_at"): + if requested_data.get("archived_at") is None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="has restored the issue", + verb="updated", + actor_id=actor_id, + field="archived_at", + old_value="archive", + new_value="restore", + epoch=epoch, + ) ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - project_id=project_id, - workspace_id=workspace_id, - comment=f"Plane has archived the issue", - verb="updated", - actor_id=actor_id, - field="archived_at", - old_value=None, - new_value="archive", - epoch=epoch, + else: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="Plane has archived the issue", + verb="updated", + actor_id=actor_id, + field="archived_at", + old_value=None, + new_value="archive", + epoch=epoch, + ) ) - ) def track_closed_to( @@ -524,8 +525,8 @@ def update_issue_activity( "description_html": track_description, "target_date": track_target_date, "start_date": track_start_date, - "labels_list": track_labels, - "assignees_list": track_assignees, + "labels": track_labels, + "assignees": track_assignees, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, @@ -537,7 +538,7 @@ def update_issue_activity( ) for key in requested_data: - func = ISSUE_ACTIVITY_MAPPER.get(key, None) + func = ISSUE_ACTIVITY_MAPPER.get(key) if func is not None: func( requested_data=requested_data, @@ -691,6 +692,10 @@ def create_cycle_issue_activity( new_cycle = Cycle.objects.filter( pk=updated_record.get("new_cycle_id", None) ).first() + issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) issue_activities.append( IssueActivity( @@ -713,6 +718,10 @@ def create_cycle_issue_activity( cycle = Cycle.objects.filter( pk=created_record.get("fields").get("cycle") ).first() + issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) issue_activities.append( IssueActivity( @@ -747,22 +756,27 @@ def delete_cycle_issue_activity( ) cycle_id = requested_data.get("cycle_id", "") + cycle_name = requested_data.get("cycle_name", "") cycle = Cycle.objects.filter(pk=cycle_id).first() issues = requested_data.get("issues") for issue in issues: + current_issue = Issue.objects.filter(pk=issue).first() + if issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) issue_activities.append( IssueActivity( issue_id=issue, actor_id=actor_id, verb="deleted", - old_value=cycle.name if cycle is not None else "", + old_value=cycle.name if cycle is not None else cycle_name, new_value="", field="cycles", project_id=project_id, workspace_id=workspace_id, - comment=f"removed this issue from {cycle.name if cycle is not None else None}", - old_identifier=cycle.id if cycle is not None else None, + comment=f"removed this issue from {cycle.name if cycle is not None else cycle_name}", + old_identifier=cycle_id if cycle_id is not None else None, epoch=epoch, ) ) @@ -794,6 +808,10 @@ def create_module_issue_activity( new_module = Module.objects.filter( pk=updated_record.get("new_module_id", None) ).first() + issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) issue_activities.append( IssueActivity( @@ -816,6 +834,10 @@ def create_module_issue_activity( module = Module.objects.filter( pk=created_record.get("fields").get("module") ).first() + issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) issue_activities.append( IssueActivity( issue_id=created_record.get("fields").get("issue"), @@ -849,22 +871,27 @@ def delete_module_issue_activity( ) module_id = requested_data.get("module_id", "") + module_name = requested_data.get("module_name", "") module = Module.objects.filter(pk=module_id).first() issues = requested_data.get("issues") for issue in issues: + current_issue = Issue.objects.filter(pk=issue).first() + if issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) issue_activities.append( IssueActivity( issue_id=issue, actor_id=actor_id, verb="deleted", - old_value=module.name if module is not None else "", + old_value=module.name if module is not None else module_name, new_value="", field="modules", project_id=project_id, workspace_id=workspace_id, - comment=f"removed this issue from ", - old_identifier=module.id if module is not None else None, + comment=f"removed this issue from {module.name if module is not None else module_name}", + old_identifier=module_id if module_id is not None else None, epoch=epoch, ) ) @@ -1452,15 +1479,16 @@ def issue_activity( issue_activities = [] project = Project.objects.get(pk=project_id) - issue = Issue.objects.filter(pk=issue_id).first() workspace_id = project.workspace_id - if issue is not None: - try: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - except Exception as e: - pass + if issue_id is not None: + issue = Issue.objects.filter(pk=issue_id).first() + if issue: + try: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + except Exception as e: + pass ACTIVITY_MAPPER = { "issue.activity.created": create_issue_activity, @@ -1535,6 +1563,8 @@ def issue_activity( IssueActivitySerializer(issue_activities_created, many=True).data, cls=DjangoJSONEncoder, ), + requested_data=requested_data, + current_instance=current_instance, ) return diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index f0e8c19e7..821ae9db6 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -60,7 +60,7 @@ def archive_old_issues(): # Check if Issues if issues: # Set the archive time to current time - archive_at = timezone.now() + archive_at = timezone.now().date() issues_to_update = [] for issue in issues: @@ -72,14 +72,14 @@ def archive_old_issues(): Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) - [ + _ = [ issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps({"archived_at": str(archive_at)}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, - current_instance=None, + current_instance=json.dumps({"archived_at": None}), subscriber=False, epoch=int(timezone.now().timestamp()) ) diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 6bdfca2d4..f5e6eea8a 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -20,7 +20,7 @@ def magic_link(email, key, token, current_site): from_email_string = settings.EMAIL_FROM - subject = f"Login for Plane" + subject = "Login for Plane" context = {"magic_url": abs_url, "code": token} diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index f290a38c0..0c2199e44 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -1,20 +1,193 @@ # Python imports import json - -# Django imports -from django.utils import timezone +import uuid # Module imports -from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification +from plane.db.models import ( + IssueMention, + IssueSubscriber, + Project, + User, + IssueAssignee, + Issue, + Notification, + IssueComment, + IssueActivity +) # Third Party imports from celery import shared_task +from bs4 import BeautifulSoup + + + +# =========== Issue Description Html Parsing and Notification Functions ====================== + +def update_mentions_for_issue(issue, project, new_mentions, removed_mention): + aggregated_issue_mentions = [] + + for mention_id in new_mentions: + aggregated_issue_mentions.append( + IssueMention( + mention_id=mention_id, + issue=issue, + project=project, + workspace_id=project.workspace_id + ) + ) + + IssueMention.objects.bulk_create( + aggregated_issue_mentions, batch_size=100) + IssueMention.objects.filter( + issue=issue, mention__in=removed_mention).delete() + + +def get_new_mentions(requested_instance, current_instance): + # requested_data is the newer instance of the current issue + # current_instance is the older instance of the current issue, saved in the database + + # extract mentions from both the instance of data + mentions_older = extract_mentions(current_instance) + + mentions_newer = extract_mentions(requested_instance) + + # Getting Set Difference from mentions_newer + new_mentions = [ + mention for mention in mentions_newer if mention not in mentions_older] + + return new_mentions + +# Get Removed Mention + + +def get_removed_mentions(requested_instance, current_instance): + # requested_data is the newer instance of the current issue + # current_instance is the older instance of the current issue, saved in the database + + # extract mentions from both the instance of data + mentions_older = extract_mentions(current_instance) + mentions_newer = extract_mentions(requested_instance) + + # Getting Set Difference from mentions_newer + removed_mentions = [ + mention for mention in mentions_older if mention not in mentions_newer] + + return removed_mentions + +# Adds mentions as subscribers + + +def extract_mentions_as_subscribers(project_id, issue_id, mentions): + # mentions is an array of User IDs representing the FILTERED set of mentioned users + + bulk_mention_subscribers = [] + + for mention_id in mentions: + # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification + if not IssueSubscriber.objects.filter( + issue_id=issue_id, + subscriber_id=mention_id, + project_id=project_id, + ).exists() and not IssueAssignee.objects.filter( + project_id=project_id, issue_id=issue_id, + assignee_id=mention_id + ).exists() and not Issue.objects.filter( + project_id=project_id, pk=issue_id, created_by_id=mention_id + ).exists(): + + project = Project.objects.get(pk=project_id) + + bulk_mention_subscribers.append(IssueSubscriber( + workspace_id=project.workspace_id, + project_id=project_id, + issue_id=issue_id, + subscriber_id=mention_id, + )) + return bulk_mention_subscribers + +# Parse Issue Description & extracts mentions +def extract_mentions(issue_instance): + try: + # issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. + mentions = [] + # Convert string to dictionary + data = json.loads(issue_instance) + html = data.get("description_html") + soup = BeautifulSoup(html, 'html.parser') + mention_tags = soup.find_all( + 'mention-component', attrs={'target': 'users'}) + + mentions = [mention_tag['id'] for mention_tag in mention_tags] + + return list(set(mentions)) + except Exception as e: + return [] + + +# =========== Comment Parsing and Notification Functions ====================== +def extract_comment_mentions(comment_value): + try: + mentions = [] + soup = BeautifulSoup(comment_value, 'html.parser') + mentions_tags = soup.find_all( + 'mention-component', attrs={'target': 'users'} + ) + for mention_tag in mentions_tags: + mentions.append(mention_tag['id']) + return list(set(mentions)) + except Exception as e: + return [] + +def get_new_comment_mentions(new_value, old_value): + + mentions_newer = extract_comment_mentions(new_value) + if old_value is None: + return mentions_newer + + mentions_older = extract_comment_mentions(old_value) + # Getting Set Difference from mentions_newer + new_mentions = [ + mention for mention in mentions_newer if mention not in mentions_older] + + return new_mentions + + +def createMentionNotification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity): + return Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mentioned", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=notification_comment, + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(activity.get("id")), + "verb": str(activity.get("verb")), + "field": str(activity.get("field")), + "actor": str(activity.get("actor_id")), + "new_value": str(activity.get("new_value")), + "old_value": str(activity.get("old_value")), + } + }, + ) @shared_task -def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created): +def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance): issue_activities_created = ( - json.loads(issue_activities_created) if issue_activities_created is not None else None + json.loads( + issue_activities_created) if issue_activities_created is not None else None ) if type not in [ "cycle.activity.created", @@ -33,39 +206,99 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi ]: # Create Notifications bulk_notifications = [] + + """ + Mention Tasks + 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent + 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers + """ + + # Get new mentions from the newer instance + new_mentions = get_new_mentions( + requested_instance=requested_data, current_instance=current_instance) + removed_mention = get_removed_mentions( + requested_instance=requested_data, current_instance=current_instance) + + comment_mentions = [] + all_comment_mentions = [] + + # Get New Subscribers from the mentions of the newer instance + requested_mentions = extract_mentions( + issue_instance=requested_data) + mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, issue_id=issue_id, mentions=requested_mentions) + + for issue_activity in issue_activities_created: + issue_comment = issue_activity.get("issue_comment") + issue_comment_new_value = issue_activity.get("new_value") + issue_comment_old_value = issue_activity.get("old_value") + if issue_comment is not None: + # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. + + all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value) + + new_comment_mentions = get_new_comment_mentions(old_value=issue_comment_old_value, new_value=issue_comment_new_value) + comment_mentions = comment_mentions + new_comment_mentions + + comment_mention_subscribers = extract_mentions_as_subscribers( project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions) + """ + We will not send subscription activity notification to the below mentioned user sets + - Those who have been newly mentioned in the issue description, we will send mention notification to them. + - When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification + - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification + """ + + issue_assignees = list( + IssueAssignee.objects.filter( + project_id=project_id, issue_id=issue_id) + .exclude(assignee_id__in=list(new_mentions + comment_mentions)) + .values_list("assignee", flat=True) + ) + issue_subscribers = list( - IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id) - .exclude(subscriber_id=actor_id) + IssueSubscriber.objects.filter( + project_id=project_id, issue_id=issue_id) + .exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])) .values_list("subscriber", flat=True) ) - issue_assignees = list( - IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id) - .exclude(assignee_id=actor_id) - .values_list("assignee", flat=True) - ) - - issue_subscribers = issue_subscribers + issue_assignees - issue = Issue.objects.filter(pk=issue_id).first() + if (issue.created_by_id is not None and str(issue.created_by_id) != str(actor_id)): + issue_subscribers = issue_subscribers + [issue.created_by_id] + if subscriber: # add the user to issue subscriber try: - _ = IssueSubscriber.objects.get_or_create( - issue_id=issue_id, subscriber_id=actor_id - ) + if str(issue.created_by_id) != str(actor_id) and uuid.UUID(actor_id) not in issue_assignees: + _ = IssueSubscriber.objects.get_or_create( + project_id=project_id, issue_id=issue_id, subscriber_id=actor_id + ) except Exception as e: pass project = Project.objects.get(pk=project_id) - for subscriber in list(set(issue_subscribers)): + issue_subscribers = list(set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}) + + for subscriber in issue_subscribers: + if subscriber in issue_subscribers: + sender = "in_app:issue_activities:subscribed" + if issue.created_by_id is not None and subscriber == issue.created_by_id: + sender = "in_app:issue_activities:created" + if subscriber in issue_assignees: + sender = "in_app:issue_activities:assigned" + for issue_activity in issue_activities_created: + issue_comment = issue_activity.get("issue_comment") + if issue_comment is not None: + issue_comment = IssueComment.objects.get( + id=issue_comment, issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id) + bulk_notifications.append( Notification( workspace=project.workspace, - sender="in_app:issue_activities", + sender=sender, triggered_by_id=actor_id, receiver_id=subscriber, entity_identifier=issue_id, @@ -89,7 +322,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi "new_value": str(issue_activity.get("new_value")), "old_value": str(issue_activity.get("old_value")), "issue_comment": str( - issue_activity.get("issue_comment").comment_stripped + issue_comment.comment_stripped if issue_activity.get("issue_comment") is not None else "" ), @@ -98,5 +331,88 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi ) ) + # Add Mentioned as Issue Subscribers + IssueSubscriber.objects.bulk_create( + mention_subscribers + comment_mention_subscribers, batch_size=100) + + last_activity = ( + IssueActivity.objects.filter(issue_id=issue_id) + .order_by("-created_at") + .first() + ) + + actor = User.objects.get(pk=actor_id) + + for mention_id in comment_mentions: + if (mention_id != actor_id): + for issue_activity in issue_activities_created: + notification = createMentionNotification( + project=project, + issue=issue, + notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", + actor_id=actor_id, + mention_id=mention_id, + issue_id=issue_id, + activity=issue_activity + ) + bulk_notifications.append(notification) + + + for mention_id in new_mentions: + if (mention_id != actor_id): + if ( + last_activity is not None + and last_activity.field == "description" + and actor_id == str(last_activity.actor_id) + ): + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mentioned", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=f"You have been mentioned in the issue {issue.name}", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(last_activity.id), + "verb": str(last_activity.verb), + "field": str(last_activity.field), + "actor": str(last_activity.actor_id), + "new_value": str(last_activity.new_value), + "old_value": str(last_activity.old_value), + }, + }, + ) + ) + else: + for issue_activity in issue_activities_created: + notification = createMentionNotification( + project=project, + issue=issue, + notification_comment=f"You have been mentioned in the issue {issue.name}", + actor_id=actor_id, + mention_id=mention_id, + issue_id=issue_id, + activity=issue_activity + ) + bulk_notifications.append(notification) + + # save new mentions for the particular issue and remove the mentions that has been deleted from the description + update_mentions_for_issue(issue=issue, project=project, new_mentions=new_mentions, + removed_mention=removed_mention) + # Bulk create notifications Notification.objects.bulk_create(bulk_notifications, batch_size=100) + + diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index d4fa4da82..e5971ade8 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -14,7 +14,7 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError # Module imports -from plane.db.models import Workspace, User, WorkspaceMemberInvite +from plane.db.models import Workspace, WorkspaceMemberInvite @shared_task diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 15fe8af52..dfb094339 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -29,4 +29,4 @@ app.conf.beat_schedule = { # Load task modules from all registered Django app configs. app.autodiscover_tasks() -app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler' \ No newline at end of file +app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler' diff --git a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py index 4890ec9d5..ae5753e07 100644 --- a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py @@ -4,7 +4,7 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion import plane.db.models.issue - +import uuid class Migration(migrations.Migration): @@ -13,9 +13,29 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name="issue_mentions", + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4,editable=False, primary_key=True, serialize=False, unique=True)), + ('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuemention', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuemention_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuemention', to='db.workspace')), + ], + options={ + 'verbose_name': 'IssueMention', + 'verbose_name_plural': 'IssueMentions', + 'db_table': 'issue_mentions', + 'ordering': ('-created_at',), + }, + ), migrations.AlterField( model_name='issueproperty', name='properties', field=models.JSONField(default=plane.db.models.issue.get_default_properties), ), - ] + ] \ No newline at end of file diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 9496b5906..d8286f8f8 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -27,12 +27,12 @@ from .issue import ( IssueActivity, IssueProperty, IssueComment, - IssueBlocker, IssueLabel, IssueAssignee, Label, IssueBlocker, IssueRelation, + IssueMention, IssueLink, IssueSequence, IssueAttachment, @@ -78,4 +78,4 @@ from .analytic import AnalyticView from .notification import Notification -from .exporter import ExporterHistory \ No newline at end of file +from .exporter import ExporterHistory diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py index fce31c8e7..0383807b7 100644 --- a/apiserver/plane/db/models/exporter.py +++ b/apiserver/plane/db/models/exporter.py @@ -53,4 +53,4 @@ class ExporterHistory(BaseModel): def __str__(self): """Return name of the service""" - return f"{self.provider} <{self.workspace.name}>" \ No newline at end of file + return f"{self.provider} <{self.workspace.name}>" diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py index 3f2be93b8..3bef68708 100644 --- a/apiserver/plane/db/models/integration/__init__.py +++ b/apiserver/plane/db/models/integration/__init__.py @@ -1,3 +1,3 @@ from .base import Integration, WorkspaceIntegration from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync -from .slack import SlackProjectSync \ No newline at end of file +from .slack import SlackProjectSync diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py index 130925c21..f4d152bb1 100644 --- a/apiserver/plane/db/models/integration/github.py +++ b/apiserver/plane/db/models/integration/github.py @@ -6,7 +6,6 @@ from django.db import models # Module imports from plane.db.models import ProjectBaseModel -from plane.db.mixins import AuditModel class GithubRepository(ProjectBaseModel): diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 9ba73fd43..0c227a158 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" + return f"{self.issue.name} {self.related_issue.name}" + +class IssueMention(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_mention" + ) + mention = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_mention", + ) + class Meta: + unique_together = ["issue", "mention"] + verbose_name = "Issue Mention" + verbose_name_plural = "Issue Mentions" + db_table = "issue_mentions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.mention.email}" class IssueAssignee(ProjectBaseModel): diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 4cd2134ac..f4ace65e5 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -4,9 +4,6 @@ from uuid import uuid4 # Django imports from django.db import models from django.conf import settings -from django.template.defaultfilters import slugify -from django.db.models.signals import post_save -from django.dispatch import receiver from django.core.validators import MinValueValidator, MaxValueValidator # Modeule imports diff --git a/apiserver/plane/middleware/user_middleware.py b/apiserver/plane/middleware/user_middleware.py deleted file mode 100644 index 60dee9b73..000000000 --- a/apiserver/plane/middleware/user_middleware.py +++ /dev/null @@ -1,33 +0,0 @@ -import jwt -import pytz -from django.conf import settings -from django.utils import timezone -from plane.db.models import User - - -class UserMiddleware(object): - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - - try: - if request.headers.get("Authorization"): - authorization_header = request.headers.get("Authorization") - access_token = authorization_header.split(" ")[1] - decoded = jwt.decode( - access_token, settings.SECRET_KEY, algorithms=["HS256"] - ) - id = decoded['user_id'] - user = User.objects.get(id=id) - user.last_active = timezone.now() - user.token_updated_at = None - user.save() - timezone.activate(pytz.timezone(user.user_timezone)) - except Exception as e: - print(e) - - response = self.get_response(request) - - return response diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index f776afd91..fe4732343 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -4,7 +4,6 @@ import ssl import certifi import dj_database_url -from urllib.parse import urlparse import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py index f77d5060c..0a0e47b0b 100644 --- a/apiserver/plane/tests/__init__.py +++ b/apiserver/plane/tests/__init__.py @@ -1 +1 @@ -from .api import * \ No newline at end of file +from .api import * diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index 2b83ef8cf..90643749c 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -2,16 +2,13 @@ """ -# from django.contrib import admin from django.urls import path, include, re_path from django.views.generic import TemplateView from django.conf import settings -# from django.conf.urls.static import static urlpatterns = [ - # path("admin/", admin.site.urls), path("", TemplateView.as_view(template_name="index.html")), path("api/", include("plane.api.urls")), path("", include("plane.web.urls")), diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 074eaae30..be52bcce4 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -12,19 +12,19 @@ from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Conc from plane.db.models import Issue -def annotate_with_monthly_dimension(queryset, field_name): +def annotate_with_monthly_dimension(queryset, field_name, attribute): # Get the year and the months year = ExtractYear(field_name) month = ExtractMonth(field_name) # Concat the year and month dimension = Concat(year, Value("-"), month, output_field=CharField()) # Annotate the dimension - return queryset.annotate(dimension=dimension) + return queryset.annotate(**{attribute: dimension}) def extract_axis(queryset, x_axis): # Format the dimension when the axis is in date if x_axis in ["created_at", "start_date", "target_date", "completed_at"]: - queryset = annotate_with_monthly_dimension(queryset, x_axis) + queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension") return queryset, "dimension" else: return queryset.annotate(dimension=F(x_axis)), "dimension" @@ -47,7 +47,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): # if segment in ["created_at", "start_date", "target_date", "completed_at"]: - queryset = annotate_with_monthly_dimension(queryset, segment) + queryset = annotate_with_monthly_dimension(queryset, segment, "segmented") segment = "segmented" queryset = queryset.values(x_axis) @@ -81,7 +81,6 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): # Total Issues in Cycle or Module total_issues = queryset.total_issues - if cycle_id: # Get all dates between the two dates date_range = [ @@ -103,7 +102,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): .values("date", "total_completed") .order_by("date") ) - + if module_id: # Get all dates between the two dates date_range = [ @@ -126,18 +125,15 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): .order_by("date") ) - for date in date_range: cumulative_pending_issues = total_issues total_completed = 0 total_completed = sum( - [ - item["total_completed"] - for item in completed_issues_distribution - if item["date"] is not None and item["date"] <= date - ] + item["total_completed"] + for item in completed_issues_distribution + if item["date"] is not None and item["date"] <= date ) cumulative_pending_issues -= total_completed chart_data[str(date)] = cumulative_pending_issues - return chart_data \ No newline at end of file + return chart_data diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 9e134042a..853874b31 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -127,7 +127,7 @@ def group_results(results_data, group_by, sub_group_by=False): return main_responsive_dict else: - response_dict = dict() + response_dict = {} if group_by == "priority": response_dict = { diff --git a/apiserver/plane/utils/imports.py b/apiserver/plane/utils/imports.py index 1a0d2924e..5f9f1c98c 100644 --- a/apiserver/plane/utils/imports.py +++ b/apiserver/plane/utils/imports.py @@ -17,4 +17,4 @@ def import_submodules(context, root_module, path): for k, v in six.iteritems(vars(module)): if not k.startswith('_'): context[k] = v - context[module_name] = module \ No newline at end of file + context[module_name] = module diff --git a/apiserver/plane/utils/integrations/slack.py b/apiserver/plane/utils/integrations/slack.py new file mode 100644 index 000000000..70f26e160 --- /dev/null +++ b/apiserver/plane/utils/integrations/slack.py @@ -0,0 +1,20 @@ +import os +import requests + +def slack_oauth(code): + SLACK_OAUTH_URL = os.environ.get("SLACK_OAUTH_URL", False) + SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", False) + SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", False) + + # Oauth Slack + if SLACK_OAUTH_URL and SLACK_CLIENT_ID and SLACK_CLIENT_SECRET: + response = requests.get( + SLACK_OAUTH_URL, + params={ + "code": code, + "client_id": SLACK_CLIENT_ID, + "client_secret": SLACK_CLIENT_SECRET, + }, + ) + return response.json() + return {} diff --git a/apiserver/plane/utils/ip_address.py b/apiserver/plane/utils/ip_address.py index 29a2fa520..06ca4353d 100644 --- a/apiserver/plane/utils/ip_address.py +++ b/apiserver/plane/utils/ip_address.py @@ -4,4 +4,4 @@ def get_client_ip(request): ip = x_forwarded_for.split(',')[0] else: ip = request.META.get('REMOTE_ADDR') - return ip \ No newline at end of file + return ip diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 52c181622..75437fbee 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -150,6 +150,17 @@ def filter_assignees(params, filter, method): filter["assignees__in"] = params.get("assignees") return filter +def filter_mentions(params, filter, method): + if method == "GET": + mentions = [item for item in params.get("mentions").split(",") if item != 'null'] + mentions = filter_valid_uuids(mentions) + if len(mentions) and "" not in mentions: + filter["issue_mention__mention__id__in"] = mentions + else: + if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null': + filter["issue_mention__mention__id__in"] = params.get("mentions") + return filter + def filter_created_by(params, filter, method): if method == "GET": @@ -198,7 +209,7 @@ def filter_start_date(params, filter, method): date_filter(filter=filter, date_term="start_date", queries=start_dates) else: if params.get("start_date", None) and len(params.get("start_date")): - date_filter(filter=filter, date_term="start_date", queries=params.get("start_date", [])) + filter["start_date"] = params.get("start_date") return filter @@ -209,7 +220,7 @@ def filter_target_date(params, filter, method): date_filter(filter=filter, date_term="target_date", queries=target_dates) else: if params.get("target_date", None) and len(params.get("target_date")): - date_filter(filter=filter, date_term="target_date", queries=params.get("target_date", [])) + filter["target_date"] = params.get("target_date") return filter @@ -316,7 +327,7 @@ def filter_start_target_date_issues(params, filter, method): def issue_filters(query_params, method): - filter = dict() + filter = {} ISSUE_FILTER = { "state": filter_state, @@ -326,6 +337,7 @@ def issue_filters(query_params, method): "parent": filter_parent, "labels": filter_labels, "assignees": filter_assignees, + "mentions": filter_mentions, "created_by": filter_created_by, "name": filter_name, "created_at": filter_created_at, diff --git a/apiserver/plane/utils/markdown.py b/apiserver/plane/utils/markdown.py index 15d5b4dce..188c54fec 100644 --- a/apiserver/plane/utils/markdown.py +++ b/apiserver/plane/utils/markdown.py @@ -1,3 +1,3 @@ import mistune -markdown = mistune.Markdown() \ No newline at end of file +markdown = mistune.Markdown() diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index b3c50abd1..544ed8fef 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -21,12 +21,7 @@ class Cursor: ) def __repr__(self): - return "<{}: value={} offset={} is_prev={}>".format( - type(self).__name__, - self.value, - self.offset, - int(self.is_prev), - ) + return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" def __bool__(self): return bool(self.has_results) @@ -176,10 +171,6 @@ class BasePaginator: **paginator_kwargs, ): """Paginate the request""" - assert (paginator and not paginator_kwargs) or ( - paginator_cls and paginator_kwargs - ) - per_page = self.get_per_page(request, default_per_page, max_per_page) # Convert the cursor value to integer and float from string diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index fc7975153..63a211baf 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -34,4 +34,5 @@ psycopg-binary==3.1.10 psycopg-c==3.1.10 scout-apm==2.26.1 openpyxl==3.1.2 -python-json-logger==2.0.7 \ No newline at end of file +python-json-logger==2.0.7 +beautifulsoup4==4.12.2 diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 9a369a65f..c6f423127 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -10,6 +10,8 @@ x-app-env : &app-env - SENTRY_DSN=${SENTRY_DSN:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - DOCKERIZED=${DOCKERIZED:-1} + # Gunicorn Workers + - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS - PGHOST=${PGHOST:-plane-db} - PGDATABASE=${PGDATABASE:-plane} diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 1e507a54b..b12031126 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -61,3 +61,5 @@ MINIO_ROOT_PASSWORD="secret-key" BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 +# Gunicorn Workers +GUNICORN_WORKERS=2 diff --git a/package.json b/package.json index 2ed56291f..86f010f3f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", - "turbo": "^1.10.14" + "turbo": "^1.10.16" }, "resolutions": { "@types/react": "18.2.0" diff --git a/packages/editor/core/Readme.md b/packages/editor/core/Readme.md index 56d1a502c..aafda7008 100644 --- a/packages/editor/core/Readme.md +++ b/packages/editor/core/Readme.md @@ -19,27 +19,27 @@ This allows for extensive customization and flexibility in the Editors created u 1. useEditor - A hook that you can use to extend the Plane editor. - | Prop | Type | Description | - | --- | --- | --- | - | `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features | - | `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object | - | `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | - | `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | - | `value` | `html string` | The initial content of the editor. | - | `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | - | `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | - | `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | - | `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". | - | `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component | + | Prop | Type | Description | + | ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features | + | `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object | + | `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | + | `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | + | `value` | `html string` | The initial content of the editor. | + | `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | + | `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | + | `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | + | `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". | + | `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component | 2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor. - | Prop | Type | Description | - | --- | --- | --- | - | `value` | `string` | The initial content of the editor. | - | `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component | - | `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features | - | `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object | + | Prop | Type | Description | + | -------------- | ------------- | ------------------------------------------------------------------------------------------ | + | `value` | `string` | The initial content of the editor. | + | `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component | + | `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features | + | `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object | 3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods. @@ -51,7 +51,11 @@ This allows for extensive customization and flexibility in the Editors created u 5. Extending with Custom Styles ```ts -const customEditorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName }); +const customEditorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName, +}); ``` ## Core features diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 2c35ead1c..ab6c77724 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -2,6 +2,7 @@ "name": "@plane/editor-core", "version": "0.0.1", "description": "Core Editor that powers Plane", + "private": true, "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", @@ -21,18 +22,18 @@ "check-types": "tsc --noEmit" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "18.2.0", "next": "12.3.2", - "next-themes": "^0.2.1" + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "18.2.0" }, "dependencies": { - "react-moveable" : "^0.54.2", "@blueprintjs/popover2": "^2.0.10", "@tiptap/core": "^2.1.7", "@tiptap/extension-color": "^2.1.11", "@tiptap/extension-image": "^2.1.7", "@tiptap/extension-link": "^2.1.7", + "@tiptap/extension-mention": "^2.1.12", "@tiptap/extension-table": "^2.1.6", "@tiptap/extension-table-cell": "^2.1.6", "@tiptap/extension-table-header": "^2.1.6", @@ -41,12 +42,15 @@ "@tiptap/extension-task-list": "^2.1.7", "@tiptap/extension-text-style": "^2.1.11", "@tiptap/extension-underline": "^2.1.7", + "@tiptap/prosemirror-tables": "^1.1.4", + "jsx-dom-cjs": "^8.0.3", "@tiptap/pm": "^2.1.7", "@tiptap/react": "^2.1.7", "@tiptap/starter-kit": "^2.1.10", + "@tiptap/suggestion": "^2.0.4", + "@types/node": "18.15.3", "@types/react": "^18.2.5", "@types/react-dom": "18.0.11", - "@types/node": "18.15.3", "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "eslint": "8.36.0", @@ -54,6 +58,7 @@ "eventsource-parser": "^0.1.0", "lucide-react": "^0.244.0", "react-markdown": "^8.0.7", + "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.2", diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 590b17172..9c1c292b2 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -2,8 +2,11 @@ // import "./styles/tailwind.css"; // import "./styles/editor.css"; +export { isCellSelection } from "./ui/extensions/table/table/utilities/is-cell-selection"; + // utils export * from "./lib/utils"; +export * from "./ui/extensions/table/table"; export { startImageUpload } from "./ui/plugins/upload-image"; // components diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 497a63ca6..8f9e36350 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -3,18 +3,36 @@ import { UploadImage } from "../types/upload-image"; import { startImageUpload } from "../ui/plugins/upload-image"; export const toggleHeadingOne = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); - else editor.chain().focus().toggleHeading({ level: 1 }).run() + if (range) + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 1 }) + .run(); + 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(); - else editor.chain().focus().toggleHeading({ level: 2 }).run() + if (range) + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 2 }) + .run(); + 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(); - else editor.chain().focus().toggleHeading({ level: 3 }).run() + if (range) + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 3 }) + .run(); + else editor.chain().focus().toggleHeading({ level: 3 }).run(); }; export const toggleBold = (editor: Editor, range?: Range) => { @@ -37,7 +55,8 @@ export const toggleCode = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleCode().run(); }; export const toggleOrderedList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + if (range) + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); else editor.chain().focus().toggleOrderedList().run(); }; @@ -48,7 +67,7 @@ export const toggleBulletList = (editor: Editor, range?: Range) => { export const toggleTaskList = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run(); - else editor.chain().focus().toggleTaskList().run() + else editor.chain().focus().toggleTaskList().run(); }; export const toggleStrike = (editor: Editor, range?: Range) => { @@ -57,13 +76,37 @@ export const toggleStrike = (editor: Editor, range?: Range) => { }; export const toggleBlockquote = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(); - else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(); + if (range) + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .toggleBlockquote() + .run(); + else + editor + .chain() + .focus() + .toggleNode("paragraph", "paragraph") + .toggleBlockquote() + .run(); }; export const insertTableCommand = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); - else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + if (range) + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + else + editor + .chain() + .focus() + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); }; export const unsetLinkEditor = (editor: Editor) => { @@ -74,7 +117,14 @@ export const setLinkEditor = (editor: Editor, url: string) => { editor.chain().focus().setLink({ href: url }).run(); }; -export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, range?: Range) => { +export const insertImageCommand = ( + editor: Editor, + uploadFile: UploadImage, + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, + range?: Range, +) => { if (range) editor.chain().focus().deleteRange(range).run(); const input = document.createElement("input"); input.type = "file"; @@ -88,4 +138,3 @@ export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setI }; input.click(); }; - diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index 484674780..f426b70b7 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -6,19 +6,24 @@ interface EditorClassNames { customClassName?: string; } -export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => cn( - 'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md', - noBorder ? '' : 'border border-custom-border-200', - borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0', - customClassName -); +export const getEditorClassNames = ({ + noBorder, + borderOnFocus, + customClassName, +}: EditorClassNames) => + cn( + "relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md", + noBorder ? "" : "border border-custom-border-200", + borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0", + customClassName, + ); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export const findTableAncestor = ( - node: Node | null + node: Node | null, ): HTMLTableElement | null => { while (node !== null && node.nodeName !== "TABLE") { node = node.parentNode; @@ -27,10 +32,10 @@ export const findTableAncestor = ( }; export const getTrimmedHTML = (html: string) => { - html = html.replace(/^(

<\/p>)+/, ''); - html = html.replace(/(

<\/p>)+$/, ''); + html = html.replace(/^(

<\/p>)+/, ""); + html = html.replace(/(

<\/p>)+$/, ""); return html; -} +}; export const isValidHttpUrl = (string: string): boolean => { let url: URL; @@ -42,4 +47,4 @@ export const isValidHttpUrl = (string: string): boolean => { } return url.protocol === "http:" || url.protocol === "https:"; -} +}; diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts new file mode 100644 index 000000000..dcaa3148d --- /dev/null +++ b/packages/editor/core/src/types/mention-suggestion.ts @@ -0,0 +1,10 @@ +export type IMentionSuggestion = { + id: string; + type: string; + avatar: string; + title: string; + subtitle: string; + redirect_uri: string; +}; + +export type IMentionHighlight = string; diff --git a/packages/editor/core/src/ui/components/editor-container.tsx b/packages/editor/core/src/ui/components/editor-container.tsx index 8de6298b5..050755f5a 100644 --- a/packages/editor/core/src/ui/components/editor-container.tsx +++ b/packages/editor/core/src/ui/components/editor-container.tsx @@ -7,7 +7,11 @@ interface EditorContainerProps { children: ReactNode; } -export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => ( +export const EditorContainer = ({ + editor, + editorClassNames, + children, +}: EditorContainerProps) => (

{ diff --git a/packages/editor/core/src/ui/components/editor-content.tsx b/packages/editor/core/src/ui/components/editor-content.tsx index 972184b08..830b87d9c 100644 --- a/packages/editor/core/src/ui/components/editor-content.tsx +++ b/packages/editor/core/src/ui/components/editor-content.tsx @@ -1,7 +1,6 @@ import { Editor, EditorContent } from "@tiptap/react"; import { ReactNode } from "react"; import { ImageResizer } from "../extensions/image/image-resize"; -import { TableMenu } from "../menus/table-menu"; interface EditorContentProps { editor: Editor | null; @@ -9,12 +8,16 @@ interface EditorContentProps { children?: ReactNode; } -export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => ( -
- {/* @ts-ignore */} +export const EditorContentWrapper = ({ + editor, + editorContentCustomClassNames = "", + children, +}: EditorContentProps) => ( +
- {editor?.isEditable && } - {(editor?.isActive("image") && editor?.isEditable) && } + {editor?.isActive("image") && editor?.isEditable && ( + + )} {children}
); diff --git a/packages/editor/core/src/ui/extensions/image/image-resize.tsx b/packages/editor/core/src/ui/extensions/image/image-resize.tsx index 448b8811c..2545c7e44 100644 --- a/packages/editor/core/src/ui/extensions/image/image-resize.tsx +++ b/packages/editor/core/src/ui/extensions/image/image-resize.tsx @@ -3,7 +3,9 @@ import Moveable from "react-moveable"; export const ImageResizer = ({ editor }: { editor: Editor }) => { const updateMediaSize = () => { - const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; + const imageInfo = document.querySelector( + ".ProseMirror-selectednode", + ) as HTMLImageElement; if (imageInfo) { const selection = editor.state.selection; editor.commands.setImage({ @@ -23,8 +25,8 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => { origin={false} edge={false} throttleDrag={0} - keepRatio={true} - resizable={true} + keepRatio + resizable throttleResize={0} onResize={({ target, width, height, delta }: any) => { delta[0] && (target!.style.width = `${width}px`); @@ -33,7 +35,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => { onResizeEnd={() => { updateMediaSize(); }} - scalable={true} + scalable renderDirections={["w", "e"]} onScale={({ target, transform }: any) => { target!.style.transform = transform; diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index f9345509d..aea84c6b8 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -3,21 +3,28 @@ import TrackImageDeletionPlugin from "../../plugins/delete-image"; import UploadImagesPlugin from "../../plugins/upload-image"; import { DeleteImage } from "../../../types/delete-image"; -const ImageExtension = (deleteImage: DeleteImage) => Image.extend({ - addProseMirrorPlugins() { - return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)]; - }, - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - }; - }, -}); +const ImageExtension = ( + deleteImage: DeleteImage, + cancelUploadImage?: () => any, +) => + Image.extend({ + addProseMirrorPlugins() { + return [ + UploadImagesPlugin(cancelUploadImage), + TrackImageDeletionPlugin(deleteImage), + ]; + }, + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }); export default ImageExtension; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 62d53e0e1..3f191a912 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -8,90 +8,101 @@ import TaskList from "@tiptap/extension-task-list"; import { Markdown } from "tiptap-markdown"; import Gapcursor from "@tiptap/extension-gapcursor"; -import { CustomTableCell } from "./table/table-cell"; -import { Table } from "./table"; -import { TableHeader } from "./table/table-header"; -import { TableRow } from "@tiptap/extension-table-row"; +import TableHeader from "./table/table-header/table-header"; +import Table from "./table/table"; +import TableCell from "./table/table-cell/table-cell"; +import TableRow from "./table/table-row/table-row"; import ImageExtension from "./image"; import { DeleteImage } from "../../types/delete-image"; import { isValidHttpUrl } from "../../lib/utils"; - +import { IMentionSuggestion } from "../../types/mention-suggestion"; +import { Mentions } from "../mentions"; export const CoreEditorExtensions = ( + mentionConfig: { + mentionSuggestions: IMentionSuggestion[]; + mentionHighlights: string[]; + }, deleteFile: DeleteImage, + cancelUploadImage?: () => any, ) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc list-outside leading-3 -mt-2", - }, + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside leading-3 -mt-2", }, - orderedList: { - HTMLAttributes: { - class: "list-decimal list-outside leading-3 -mt-2", - }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside leading-3 -mt-2", }, - listItem: { - HTMLAttributes: { - class: "leading-normal -mb-2", - }, + }, + listItem: { + HTMLAttributes: { + class: "leading-normal -mb-2", }, - blockquote: { - HTMLAttributes: { - class: "border-l-4 border-custom-border-300", - }, + }, + blockquote: { + HTMLAttributes: { + class: "border-l-4 border-custom-border-300", }, - code: { - HTMLAttributes: { - class: - "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, - codeBlock: false, - horizontalRule: false, - dropcursor: { - color: "rgba(var(--color-text-100))", - width: 2, - }, - gapcursor: false, - }), - Gapcursor, - TiptapLink.configure({ - protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), + }, + code: { HTMLAttributes: { class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", + spellcheck: "false", }, - }), - ImageExtension(deleteFile).configure({ - HTMLAttributes: { - class: "rounded-lg border border-custom-border-300", - }, - }), - TiptapUnderline, - TextStyle, - Color, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "flex items-start my-4", - }, - nested: true, - }), - Markdown.configure({ - html: true, - transformCopiedText: true, - }), - Table, - TableHeader, - CustomTableCell, - TableRow, - ]; + }, + codeBlock: false, + horizontalRule: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 2, + }, + gapcursor: false, + }), + Gapcursor, + TiptapLink.configure({ + protocols: ["http", "https"], + validate: (url) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + ImageExtension(deleteFile, cancelUploadImage).configure({ + HTMLAttributes: { + class: "rounded-lg border border-custom-border-300", + }, + }), + TiptapUnderline, + TextStyle, + Color, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + Mentions( + mentionConfig.mentionSuggestions, + mentionConfig.mentionHighlights, + false, + ), +]; diff --git a/packages/editor/core/src/ui/extensions/table/index.ts b/packages/editor/core/src/ui/extensions/table/index.ts deleted file mode 100644 index 9b727bb51..000000000 --- a/packages/editor/core/src/ui/extensions/table/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Table as BaseTable } from "@tiptap/extension-table"; - -const Table = BaseTable.configure({ - resizable: true, - cellMinWidth: 100, - allowTableNodeSelection: true, -}); - -export { Table }; diff --git a/packages/editor/core/src/ui/extensions/table/table-cell.ts b/packages/editor/core/src/ui/extensions/table/table-cell.ts deleted file mode 100644 index 643cb8c64..000000000 --- a/packages/editor/core/src/ui/extensions/table/table-cell.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TableCell } from "@tiptap/extension-table-cell"; - -export const CustomTableCell = TableCell.extend({ - addAttributes() { - return { - ...this.parent?.(), - isHeader: { - default: false, - parseHTML: (element) => { - isHeader: element.tagName === "TD"; - }, - renderHTML: (attributes) => { - tag: attributes.isHeader ? "th" : "td"; - }, - }, - }; - }, - renderHTML({ HTMLAttributes }) { - if (HTMLAttributes.isHeader) { - return [ - "th", - { - ...HTMLAttributes, - class: `relative ${HTMLAttributes.class}`, - }, - ["span", { class: "absolute top-0 right-0" }], - 0, - ]; - } - return ["td", HTMLAttributes, 0]; - }, -}); diff --git a/packages/editor/core/src/ui/extensions/table/table-cell/index.ts b/packages/editor/core/src/ui/extensions/table/table-cell/index.ts new file mode 100644 index 000000000..fb2183381 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table-cell/index.ts @@ -0,0 +1 @@ +export { default as default } from "./table-cell"; diff --git a/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts b/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts new file mode 100644 index 000000000..1d3e57af9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts @@ -0,0 +1,58 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TableCellOptions { + HTMLAttributes: Record; +} + +export default Node.create({ + name: "tableCell", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + content: "paragraph+", + + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute("colwidth"); + const value = colwidth ? [parseInt(colwidth, 10)] : null; + + return value; + }, + }, + background: { + default: "none", + }, + }; + }, + + tableRole: "cell", + + isolating: true, + + parseHTML() { + return [{ tag: "td" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "td", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: `background-color: ${node.attrs.background}`, + }), + 0, + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/table/table-header.ts b/packages/editor/core/src/ui/extensions/table/table-header.ts deleted file mode 100644 index f23aa93ef..000000000 --- a/packages/editor/core/src/ui/extensions/table/table-header.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header"; - -const TableHeader = BaseTableHeader.extend({ - content: "paragraph", -}); - -export { TableHeader }; diff --git a/packages/editor/core/src/ui/extensions/table/table-header/index.ts b/packages/editor/core/src/ui/extensions/table/table-header/index.ts new file mode 100644 index 000000000..cb036c505 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table-header/index.ts @@ -0,0 +1 @@ +export { default as default } from "./table-header"; diff --git a/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts b/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts new file mode 100644 index 000000000..0148f1a6f --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts @@ -0,0 +1,57 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TableHeaderOptions { + HTMLAttributes: Record; +} +export default Node.create({ + name: "tableHeader", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + content: "paragraph+", + + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute("colwidth"); + const value = colwidth ? [parseInt(colwidth, 10)] : null; + + return value; + }, + }, + background: { + default: "rgb(var(--color-primary-100))", + }, + }; + }, + + tableRole: "header_cell", + + isolating: true, + + parseHTML() { + return [{ tag: "th" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "th", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + style: `background-color: ${node.attrs.background}`, + }), + 0, + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/table/table-row/index.ts b/packages/editor/core/src/ui/extensions/table/table-row/index.ts new file mode 100644 index 000000000..8c6eb55aa --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table-row/index.ts @@ -0,0 +1 @@ +export { default as default } from "./table-row"; diff --git a/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts b/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts new file mode 100644 index 000000000..1b576623b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts @@ -0,0 +1,31 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export interface TableRowOptions { + HTMLAttributes: Record; +} + +export default Node.create({ + name: "tableRow", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + content: "(tableCell | tableHeader)*", + + tableRole: "row", + + parseHTML() { + return [{ tag: "tr" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "tr", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts new file mode 100644 index 000000000..eda520759 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -0,0 +1,55 @@ +const icons = { + colorPicker: ``, + deleteColumn: ``, + deleteRow: ``, + insertLeftTableIcon: ` + + +`, + insertRightTableIcon: ` + + +`, + insertTopTableIcon: ` + + +`, + insertBottomTableIcon: ` + + +`, +}; + +export default icons; diff --git a/packages/editor/core/src/ui/extensions/table/table/index.ts b/packages/editor/core/src/ui/extensions/table/table/index.ts new file mode 100644 index 000000000..ac51d0e2c --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/index.ts @@ -0,0 +1 @@ +export { default as default } from "./table"; diff --git a/packages/editor/core/src/ui/extensions/table/table/table-controls.ts b/packages/editor/core/src/ui/extensions/table/table/table-controls.ts new file mode 100644 index 000000000..efaf84970 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/table-controls.ts @@ -0,0 +1,122 @@ +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { findParentNode } from "@tiptap/core"; +import { DecorationSet, Decoration } from "@tiptap/pm/view"; + +const key = new PluginKey("tableControls"); + +export function tableControls() { + return new Plugin({ + key, + state: { + init() { + return new TableControlsState(); + }, + apply(tr, prev) { + return prev.apply(tr); + }, + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + const pluginState = key.getState(view.state); + + if ( + !(event.target as HTMLElement).closest(".tableWrapper") && + pluginState.values.hoveredTable + ) { + return view.dispatch( + view.state.tr.setMeta(key, { + setHoveredTable: null, + setHoveredCell: null, + }), + ); + } + + const pos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (!pos) 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", + )(TextSelection.create(view.state.doc, pos.pos)); + + if (!table || !cell) return; + + if (pluginState.values.hoveredCell?.pos !== cell.pos) { + return view.dispatch( + view.state.tr.setMeta(key, { + setHoveredTable: table, + setHoveredCell: cell, + }), + ); + } + }, + }, + decorations: (state) => { + const pluginState = key.getState(state); + if (!pluginState) { + return null; + } + + const { hoveredTable, hoveredCell } = pluginState.values; + const docSize = state.doc.content.size; + if ( + hoveredTable && + hoveredCell && + hoveredTable.pos < docSize && + hoveredCell.pos < docSize + ) { + const decorations = [ + Decoration.node( + hoveredTable.pos, + hoveredTable.pos + hoveredTable.node.nodeSize, + {}, + { + hoveredTable, + hoveredCell, + }, + ), + ]; + + return DecorationSet.create(state.doc, decorations); + } + + return null; + }, + }, + }); +} + +class TableControlsState { + values; + + constructor(props = {}) { + this.values = { + hoveredTable: null, + hoveredCell: null, + ...props, + }; + } + + apply(tr: any) { + const actions = tr.getMeta(key); + + if (actions?.setHoveredTable !== undefined) { + this.values.hoveredTable = actions.setHoveredTable; + } + + if (actions?.setHoveredCell !== undefined) { + this.values.hoveredCell = actions.setHoveredCell; + } + + return this; + } +} diff --git a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx new file mode 100644 index 000000000..7f72a212e --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx @@ -0,0 +1,536 @@ +import { h } from "jsx-dom-cjs"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Decoration, NodeView } from "@tiptap/pm/view"; +import tippy, { Instance, Props } from "tippy.js"; + +import { Editor } from "@tiptap/core"; +import { + CellSelection, + TableMap, + updateColumnsOnResize, +} from "@tiptap/prosemirror-tables"; + +import icons from "./icons"; + +export function updateColumns( + node: ProseMirrorNode, + colgroup: HTMLElement, + table: HTMLElement, + cellMinWidth: number, + overrideCol?: number, + overrideValue?: any, +) { + let totalWidth = 0; + let fixedWidth = true; + let nextDOM = colgroup.firstChild as HTMLElement; + const row = node.firstChild; + + if (!row) return; + + for (let i = 0, col = 0; i < row.childCount; i += 1) { + 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 cssWidth = hasWidth ? `${hasWidth}px` : ""; + + totalWidth += hasWidth || cellMinWidth; + + if (!hasWidth) { + fixedWidth = false; + } + + if (!nextDOM) { + colgroup.appendChild(document.createElement("col")).style.width = + cssWidth; + } else { + if (nextDOM.style.width !== cssWidth) { + nextDOM.style.width = cssWidth; + } + + nextDOM = nextDOM.nextSibling as HTMLElement; + } + } + } + + while (nextDOM) { + const after = nextDOM.nextSibling; + + nextDOM.parentNode?.removeChild(nextDOM); + nextDOM = after as HTMLElement; + } + + if (fixedWidth) { + table.style.width = `${totalWidth}px`; + table.style.minWidth = ""; + } else { + table.style.width = ""; + table.style.minWidth = `${totalWidth}px`; + } +} + +const defaultTippyOptions: Partial = { + allowHTML: true, + arrow: false, + trigger: "click", + animation: "scale-subtle", + theme: "light-border no-padding", + interactive: true, + hideOnClick: true, + placement: "right", +}; + +function setCellsBackgroundColor(editor: Editor, backgroundColor) { + return editor + .chain() + .focus() + .updateAttributes("tableCell", { + background: backgroundColor, + }) + .updateAttributes("tableHeader", { + background: backgroundColor, + }) + .run(); +} + +const columnsToolboxItems = [ + { + label: "Add Column Before", + icon: icons.insertLeftTableIcon, + action: ({ editor }: { editor: Editor }) => + editor.chain().focus().addColumnBefore().run(), + }, + { + label: "Add Column After", + icon: icons.insertRightTableIcon, + action: ({ editor }: { editor: Editor }) => + editor.chain().focus().addColumnAfter().run(), + }, + { + label: "Pick Column Color", + icon: icons.colorPicker, + action: ({ + editor, + triggerButton, + controlsContainer, + }: { + editor: Editor; + triggerButton: HTMLElement; + controlsContainer; + }) => { + createColorPickerToolbox({ + triggerButton, + tippyOptions: { + appendTo: controlsContainer, + }, + onSelectColor: (color) => setCellsBackgroundColor(editor, color), + }); + }, + }, + { + label: "Delete Column", + icon: icons.deleteColumn, + action: ({ editor }: { editor: Editor }) => + editor.chain().focus().deleteColumn().run(), + }, +]; + +const rowsToolboxItems = [ + { + label: "Add Row Above", + icon: icons.insertTopTableIcon, + action: ({ editor }: { editor: Editor }) => + editor.chain().focus().addRowBefore().run(), + }, + { + label: "Add Row Below", + icon: icons.insertBottomTableIcon, + action: ({ editor }: { editor: Editor }) => + editor.chain().focus().addRowAfter().run(), + }, + { + label: "Pick Row Color", + icon: icons.colorPicker, + action: ({ + editor, + triggerButton, + controlsContainer, + }: { + editor: Editor; + triggerButton: HTMLButtonElement; + controlsContainer: + | Element + | "parent" + | ((ref: Element) => Element) + | undefined; + }) => { + createColorPickerToolbox({ + triggerButton, + tippyOptions: { + appendTo: controlsContainer, + }, + onSelectColor: (color) => setCellsBackgroundColor(editor, color), + }); + }, + }, + { + label: "Delete Row", + icon: icons.deleteRow, + action: ({ editor }: { editor: Editor }) => + editor.chain().focus().deleteRow().run(), + }, +]; + +function createToolbox({ + triggerButton, + items, + tippyOptions, + onClickItem, +}: { + triggerButton: HTMLElement; + items: { icon: string; label: string }[]; + tippyOptions: any; + onClickItem: any; +}): Instance { + const toolbox = tippy(triggerButton, { + content: h( + "div", + { className: "tableToolbox" }, + items.map((item) => + h( + "div", + { + className: "toolboxItem", + itemType: "button", + onClick() { + onClickItem(item); + }, + }, + [ + h("div", { + className: "iconContainer", + innerHTML: item.icon, + }), + h("div", { className: "label" }, item.label), + ], + ), + ), + ), + ...tippyOptions, + }); + + return Array.isArray(toolbox) ? toolbox[0] : toolbox; +} + +function createColorPickerToolbox({ + triggerButton, + tippyOptions, + onSelectColor = () => {}, +}: { + triggerButton: HTMLElement; + tippyOptions: Partial; + onSelectColor?: (color: string) => void; +}) { + const items = { + Default: "rgb(var(--color-primary-100))", + Orange: "#FFE5D1", + Grey: "#F1F1F1", + Yellow: "#FEF3C7", + Green: "#DCFCE7", + Red: "#FFDDDD", + Blue: "#D9E4FF", + Pink: "#FFE8FA", + Purple: "#E8DAFB", + }; + + const colorPicker = tippy(triggerButton, { + ...defaultTippyOptions, + content: h( + "div", + { className: "tableColorPickerToolbox" }, + Object.entries(items).map(([key, value]) => + h( + "div", + { + className: "toolboxItem", + itemType: "button", + onClick: () => { + onSelectColor(value); + colorPicker.hide(); + }, + }, + [ + h("div", { + className: "colorContainer", + style: { + backgroundColor: value, + }, + }), + h( + "div", + { + className: "label", + }, + key, + ), + ], + ), + ), + ), + onHidden: (instance) => { + instance.destroy(); + }, + showOnCreate: true, + ...tippyOptions, + }); + + return colorPicker; +} + +export class TableView implements NodeView { + node: ProseMirrorNode; + cellMinWidth: number; + decorations: Decoration[]; + editor: Editor; + getPos: () => number; + hoveredCell; + map: TableMap; + root: HTMLElement; + table: HTMLElement; + colgroup: HTMLElement; + tbody: HTMLElement; + rowsControl?: HTMLElement; + columnsControl?: HTMLElement; + columnsToolbox?: Instance; + rowsToolbox?: Instance; + controls?: HTMLElement; + + get dom() { + return this.root; + } + + get contentDOM() { + return this.tbody; + } + + constructor( + node: ProseMirrorNode, + cellMinWidth: number, + decorations: Decoration[], + editor: Editor, + getPos: () => number, + ) { + this.node = node; + this.cellMinWidth = cellMinWidth; + this.decorations = decorations; + this.editor = editor; + this.getPos = getPos; + this.hoveredCell = null; + this.map = TableMap.get(node); + + if (editor.isEditable) { + this.rowsControl = h( + "div", + { className: "rowsControl" }, + h("div", { + itemType: "button", + className: "rowsControlDiv", + onClick: () => this.selectRow(), + }), + ); + + this.columnsControl = h( + "div", + { className: "columnsControl" }, + h("div", { + itemType: "button", + className: "columnsControlDiv", + onClick: () => this.selectColumn(), + }), + ); + + this.controls = h( + "div", + { className: "tableControls", contentEditable: "false" }, + this.rowsControl, + this.columnsControl, + ); + + this.columnsToolbox = createToolbox({ + triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), + items: columnsToolboxItems, + tippyOptions: { + ...defaultTippyOptions, + appendTo: this.controls, + }, + onClickItem: (item) => { + item.action({ + editor: this.editor, + triggerButton: this.columnsControl?.firstElementChild, + controlsContainer: this.controls, + }); + this.columnsToolbox?.hide(); + }, + }); + + this.rowsToolbox = createToolbox({ + triggerButton: this.rowsControl.firstElementChild, + items: rowsToolboxItems, + tippyOptions: { + ...defaultTippyOptions, + appendTo: this.controls, + }, + onClickItem: (item) => { + item.action({ + editor: this.editor, + triggerButton: this.rowsControl?.firstElementChild, + controlsContainer: this.controls, + }); + this.rowsToolbox?.hide(); + }, + }); + } + + // Table + + this.colgroup = h( + "colgroup", + null, + Array.from({ length: this.map.width }, () => 1).map(() => h("col")), + ); + this.tbody = h("tbody"); + this.table = h("table", null, this.colgroup, this.tbody); + + this.root = h( + "div", + { + className: "tableWrapper controls--disabled", + }, + this.controls, + this.table, + ); + + this.render(); + } + + update(node: ProseMirrorNode, decorations) { + if (node.type !== this.node.type) { + return false; + } + + this.node = node; + this.decorations = decorations; + this.map = TableMap.get(this.node); + + if (this.editor.isEditable) { + this.updateControls(); + } + + this.render(); + + return true; + } + + render() { + if (this.colgroup.children.length !== this.map.width) { + const cols = Array.from({ length: this.map.width }, () => 1).map(() => + h("col"), + ); + this.colgroup.replaceChildren(...cols); + } + + updateColumnsOnResize( + this.node, + this.colgroup, + this.table, + this.cellMinWidth, + ); + } + + ignoreMutation() { + return true; + } + + updateControls() { + const { hoveredTable: table, hoveredCell: cell } = Object.values( + this.decorations, + ).reduce( + (acc, curr) => { + if (curr.spec.hoveredCell !== undefined) { + acc["hoveredCell"] = curr.spec.hoveredCell; + } + + if (curr.spec.hoveredTable !== undefined) { + acc["hoveredTable"] = curr.spec.hoveredTable; + } + return acc; + }, + {} as Record, + ) as any; + + if (table === undefined || cell === undefined) { + return this.root.classList.add("controls--disabled"); + } + + this.root.classList.remove("controls--disabled"); + this.hoveredCell = cell; + + const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; + + const tableRect = this.table.getBoundingClientRect(); + const cellRect = cellDom.getBoundingClientRect(); + + this.columnsControl.style.left = `${ + cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft + }px`; + this.columnsControl.style.width = `${cellRect.width}px`; + + this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`; + this.rowsControl.style.height = `${cellRect.height}px`; + } + + selectColumn() { + if (!this.hoveredCell) return; + + const colIndex = this.map.colCount( + this.hoveredCell.pos - (this.getPos() + 1), + ); + const anchorCellPos = this.hoveredCell.pos; + const headCellPos = + this.map.map[colIndex + this.map.width * (this.map.height - 1)] + + (this.getPos() + 1); + + const cellSelection = CellSelection.create( + this.editor.view.state.doc, + anchorCellPos, + headCellPos, + ); + this.editor.view.dispatch( + // @ts-ignore + this.editor.state.tr.setSelection(cellSelection), + ); + } + + selectRow() { + if (!this.hoveredCell) return; + + const anchorCellPos = this.hoveredCell.pos; + const anchorCellIndex = this.map.map.indexOf( + anchorCellPos - (this.getPos() + 1), + ); + const headCellPos = + this.map.map[anchorCellIndex + (this.map.width - 1)] + + (this.getPos() + 1); + + const cellSelection = CellSelection.create( + this.editor.state.doc, + anchorCellPos, + headCellPos, + ); + this.editor.view.dispatch( + // @ts-ignore + this.editor.view.state.tr.setSelection(cellSelection), + ); + } +} diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/core/src/ui/extensions/table/table/table.ts new file mode 100644 index 000000000..8571fdfba --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -0,0 +1,312 @@ +import { TextSelection } from "@tiptap/pm/state"; + +import { + callOrReturn, + getExtensionField, + mergeAttributes, + Node, + ParentConfig, +} from "@tiptap/core"; +import { + addColumnAfter, + addColumnBefore, + addRowAfter, + addRowBefore, + CellSelection, + columnResizing, + deleteColumn, + deleteRow, + deleteTable, + fixTables, + goToNextCell, + mergeCells, + setCellAttr, + splitCell, + tableEditing, + toggleHeader, + toggleHeaderCell, +} from "@tiptap/prosemirror-tables"; + +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"; + +export interface TableOptions { + HTMLAttributes: Record; + resizable: boolean; + handleWidth: number; + cellMinWidth: number; + lastColumnResizable: boolean; + allowTableNodeSelection: boolean; +} + +declare module "@tiptap/core" { + interface Commands { + table: { + insertTable: (options?: { + rows?: number; + cols?: number; + withHeaderRow?: boolean; + }) => ReturnType; + addColumnBefore: () => ReturnType; + addColumnAfter: () => ReturnType; + deleteColumn: () => ReturnType; + addRowBefore: () => ReturnType; + addRowAfter: () => ReturnType; + deleteRow: () => ReturnType; + deleteTable: () => ReturnType; + mergeCells: () => ReturnType; + splitCell: () => ReturnType; + toggleHeaderColumn: () => ReturnType; + toggleHeaderRow: () => ReturnType; + toggleHeaderCell: () => ReturnType; + mergeOrSplit: () => ReturnType; + setCellAttribute: (name: string, value: any) => ReturnType; + goToNextCell: () => ReturnType; + goToPreviousCell: () => ReturnType; + fixTables: () => ReturnType; + setCellSelection: (position: { + anchorCell: number; + headCell?: number; + }) => ReturnType; + }; + } + + interface NodeConfig { + tableRole?: + | string + | ((this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>["tableRole"]; + }) => string); + } +} + +export default Node.create({ + name: "table", + + addOptions() { + return { + HTMLAttributes: {}, + resizable: true, + handleWidth: 5, + cellMinWidth: 100, + lastColumnResizable: true, + allowTableNodeSelection: true, + }; + }, + + content: "tableRow+", + + tableRole: "table", + + isolating: true, + + group: "block", + + allowGapCursor: false, + + parseHTML() { + return [{ tag: "table" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "table", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ["tbody", 0], + ]; + }, + + addCommands() { + return { + insertTable: + ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ tr, dispatch, editor }) => { + const node = createTable(editor.schema, rows, cols, withHeaderRow); + + if (dispatch) { + const offset = tr.selection.anchor + 1; + + tr.replaceSelectionWith(node) + .scrollIntoView() + .setSelection(TextSelection.near(tr.doc.resolve(offset))); + } + + return true; + }, + addColumnBefore: + () => + ({ state, dispatch }) => + addColumnBefore(state, dispatch), + addColumnAfter: + () => + ({ state, dispatch }) => + addColumnAfter(state, dispatch), + deleteColumn: + () => + ({ state, dispatch }) => + deleteColumn(state, dispatch), + addRowBefore: + () => + ({ state, dispatch }) => + addRowBefore(state, dispatch), + addRowAfter: + () => + ({ state, dispatch }) => + addRowAfter(state, dispatch), + deleteRow: + () => + ({ state, dispatch }) => + deleteRow(state, dispatch), + deleteTable: + () => + ({ state, dispatch }) => + deleteTable(state, dispatch), + mergeCells: + () => + ({ state, dispatch }) => + mergeCells(state, dispatch), + splitCell: + () => + ({ state, dispatch }) => + splitCell(state, dispatch), + toggleHeaderColumn: + () => + ({ state, dispatch }) => + toggleHeader("column")(state, dispatch), + toggleHeaderRow: + () => + ({ state, dispatch }) => + toggleHeader("row")(state, dispatch), + toggleHeaderCell: + () => + ({ state, dispatch }) => + toggleHeaderCell(state, dispatch), + mergeOrSplit: + () => + ({ state, dispatch }) => { + if (mergeCells(state, dispatch)) { + return true; + } + + return splitCell(state, dispatch); + }, + setCellAttribute: + (name, value) => + ({ state, dispatch }) => + setCellAttr(name, value)(state, dispatch), + goToNextCell: + () => + ({ state, dispatch }) => + goToNextCell(1)(state, dispatch), + goToPreviousCell: + () => + ({ state, dispatch }) => + goToNextCell(-1)(state, dispatch), + fixTables: + () => + ({ state, dispatch }) => { + if (dispatch) { + fixTables(state); + } + + return true; + }, + setCellSelection: + (position) => + ({ tr, dispatch }) => { + if (dispatch) { + const selection = CellSelection.create( + tr.doc, + position.anchorCell, + position.headCell, + ); + + // @ts-ignore + tr.setSelection(selection); + } + + return true; + }, + }; + }, + + addKeyboardShortcuts() { + return { + Tab: () => { + if (this.editor.commands.goToNextCell()) { + return true; + } + + if (!this.editor.can().addRowAfter()) { + return false; + } + + return this.editor.chain().addRowAfter().goToNextCell().run(); + }, + "Shift-Tab": () => this.editor.commands.goToPreviousCell(), + Backspace: deleteTableWhenAllCellsSelected, + "Mod-Backspace": deleteTableWhenAllCellsSelected, + Delete: deleteTableWhenAllCellsSelected, + "Mod-Delete": deleteTableWhenAllCellsSelected, + }; + }, + + addNodeView() { + return ({ editor, getPos, node, decorations }) => { + const { cellMinWidth } = this.options; + + return new TableView( + node, + cellMinWidth, + decorations, + editor, + getPos as () => number, + ); + }; + }, + + addProseMirrorPlugins() { + const isResizable = this.options.resizable && this.editor.isEditable; + + const plugins = [ + tableEditing({ + allowTableNodeSelection: this.options.allowTableNodeSelection, + }), + tableControls(), + ]; + + if (isResizable) { + plugins.unshift( + columnResizing({ + handleWidth: this.options.handleWidth, + cellMinWidth: this.options.cellMinWidth, + // View: TableView, + + // @ts-ignore + lastColumnResizable: this.options.lastColumnResizable, + }), + ); + } + + return plugins; + }, + + extendNodeSchema(extension) { + const context = { + name: extension.name, + options: extension.options, + storage: extension.storage, + }; + + return { + tableRole: callOrReturn( + getExtensionField(extension, "tableRole", context), + ), + }; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/create-cell.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/create-cell.ts new file mode 100644 index 000000000..7811341e0 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/create-cell.ts @@ -0,0 +1,12 @@ +import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; + +export function createCell( + cellType: NodeType, + cellContent?: Fragment | ProsemirrorNode | Array, +): ProsemirrorNode | null | undefined { + if (cellContent) { + return cellType.createChecked(null, cellContent); + } + + return cellType.createAndFill(); +} diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/create-table.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/create-table.ts new file mode 100644 index 000000000..5805ecf86 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/create-table.ts @@ -0,0 +1,45 @@ +import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"; + +import { createCell } from "./create-cell"; +import { getTableNodeTypes } from "./get-table-node-types"; + +export function createTable( + schema: Schema, + rowsCount: number, + colsCount: number, + withHeaderRow: boolean, + cellContent?: Fragment | ProsemirrorNode | Array, +): ProsemirrorNode { + const types = getTableNodeTypes(schema); + const headerCells: ProsemirrorNode[] = []; + const cells: ProsemirrorNode[] = []; + + for (let index = 0; index < colsCount; index += 1) { + const cell = createCell(types.cell, cellContent); + + if (cell) { + cells.push(cell); + } + + if (withHeaderRow) { + const headerCell = createCell(types.header_cell, cellContent); + + if (headerCell) { + headerCells.push(headerCell); + } + } + } + + const rows: ProsemirrorNode[] = []; + + for (let index = 0; index < rowsCount; index += 1) { + rows.push( + types.row.createChecked( + null, + withHeaderRow && index === 0 ? headerCells : cells, + ), + ); + } + + return types.table.createChecked(null, rows); +} diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts new file mode 100644 index 000000000..7fed53705 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts @@ -0,0 +1,42 @@ +import { + findParentNodeClosestToPos, + KeyboardShortcutCommand, +} from "@tiptap/core"; + +import { isCellSelection } from "./is-cell-selection"; + +export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ + editor, +}) => { + const { selection } = editor.state; + + if (!isCellSelection(selection)) { + return false; + } + + let cellCount = 0; + const table = findParentNodeClosestToPos( + selection.ranges[0].$from, + (node) => node.type.name === "table", + ); + + table?.node.descendants((node) => { + if (node.type.name === "table") { + return false; + } + + if (["tableCell", "tableHeader"].includes(node.type.name)) { + cellCount += 1; + } + }); + + const allCellsSelected = cellCount === selection.ranges.length; + + if (!allCellsSelected) { + return false; + } + + editor.commands.deleteTable(); + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/get-table-node-types.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/get-table-node-types.ts new file mode 100644 index 000000000..28c322a1f --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/get-table-node-types.ts @@ -0,0 +1,21 @@ +import { NodeType, Schema } from "prosemirror-model"; + +export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { + if (schema.cached.tableNodeTypes) { + return schema.cached.tableNodeTypes; + } + + const roles: { [key: string]: NodeType } = {}; + + Object.keys(schema.nodes).forEach((type) => { + const nodeType = schema.nodes[type]; + + if (nodeType.spec.tableRole) { + roles[nodeType.spec.tableRole] = nodeType; + } + }); + + schema.cached.tableNodeTypes = roles; + + return roles; +} diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/is-cell-selection.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/is-cell-selection.ts new file mode 100644 index 000000000..28917a299 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/is-cell-selection.ts @@ -0,0 +1,5 @@ +import { CellSelection } from "@tiptap/prosemirror-tables"; + +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection; +} diff --git a/packages/editor/core/src/ui/hooks/useEditor.tsx b/packages/editor/core/src/ui/hooks/useEditor.tsx index 837700915..258da8652 100644 --- a/packages/editor/core/src/ui/hooks/useEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useEditor.tsx @@ -1,18 +1,24 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; -import { useImperativeHandle, useRef, MutableRefObject } from "react"; -import { useDebouncedCallback } from "use-debounce"; -import { DeleteImage } from '../../types/delete-image'; +import { + useImperativeHandle, + useRef, + MutableRefObject, + useEffect, +} from "react"; +import { DeleteImage } from "../../types/delete-image"; import { CoreEditorProps } from "../props"; import { CoreEditorExtensions } from "../extensions"; -import { EditorProps } from '@tiptap/pm/view'; +import { EditorProps } from "@tiptap/pm/view"; import { getTrimmedHTML } from "../../lib/utils"; import { UploadImage } from "../../types/upload-image"; - -const DEBOUNCE_DELAY = 1500; +import { useInitializedContent } from "./useInitializedContent"; +import { IMentionSuggestion } from "../../types/mention-suggestion"; interface CustomEditorProps { uploadFile: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; setShouldShowAlert?: (showAlert: boolean) => void; value: string; deleteFile: DeleteImage; @@ -21,27 +27,55 @@ interface CustomEditorProps { extensions?: any; editorProps?: EditorProps; forwardedRef?: any; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; + cancelUploadImage?: () => any; } -export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => { - const editor = useCustomEditor({ - editorProps: { - ...CoreEditorProps(uploadFile, setIsSubmitting), - ...editorProps, - }, - extensions: [...CoreEditorExtensions(deleteFile), ...extensions], - content: (typeof value === "string" && value.trim() !== "") ? value : "

", - onUpdate: async ({ editor }) => { - // for instant feedback loop - setIsSubmitting?.("submitting"); - setShouldShowAlert?.(true); - if (debouncedUpdatesEnabled) { - debouncedUpdates({ onChange: onChange, editor }); - } else { +export const useEditor = ({ + uploadFile, + deleteFile, + cancelUploadImage, + editorProps = {}, + value, + extensions = [], + onChange, + setIsSubmitting, + forwardedRef, + setShouldShowAlert, + mentionHighlights, + mentionSuggestions, +}: CustomEditorProps) => { + const editor = useCustomEditor( + { + editorProps: { + ...CoreEditorProps(uploadFile, setIsSubmitting), + ...editorProps, + }, + extensions: [ + ...CoreEditorExtensions( + { + mentionSuggestions: mentionSuggestions ?? [], + mentionHighlights: mentionHighlights ?? [], + }, + deleteFile, + cancelUploadImage, + ), + ...extensions, + ], + content: + typeof value === "string" && value.trim() !== "" ? value : "

", + onUpdate: async ({ editor }) => { + // for instant feedback loop + setIsSubmitting?.("submitting"); + setShouldShowAlert?.(true); onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); - } + }, }, - }); + [], + ); + + useInitializedContent(editor, value); const editorRef: MutableRefObject = useRef(null); editorRef.current = editor; @@ -55,12 +89,6 @@ export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, ext }, })); - const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => { - if (onChange) { - onChange(editor.getJSON(), getTrimmedHTML(editor.getHTML())); - } - }, DEBOUNCE_DELAY); - if (!editor) { return null; } diff --git a/packages/editor/core/src/ui/hooks/useInitializedContent.tsx b/packages/editor/core/src/ui/hooks/useInitializedContent.tsx new file mode 100644 index 000000000..8e2ce1717 --- /dev/null +++ b/packages/editor/core/src/ui/hooks/useInitializedContent.tsx @@ -0,0 +1,19 @@ +import { Editor } from "@tiptap/react"; +import { useEffect, useRef } from "react"; + +export const useInitializedContent = (editor: Editor | null, value: string) => { + const hasInitializedContent = useRef(false); + + useEffect(() => { + if (editor) { + const cleanedValue = + typeof value === "string" && value.trim() !== "" ? value : "

"; + if (cleanedValue !== "

" && !hasInitializedContent.current) { + editor.commands.setContent(cleanedValue); + hasInitializedContent.current = true; + } else if (cleanedValue === "

" && hasInitializedContent.current) { + hasInitializedContent.current = false; + } + } + }, [value, editor]); +}; diff --git a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx index 3e32c5044..75ebddd3c 100644 --- a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx @@ -1,27 +1,57 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; -import { useImperativeHandle, useRef, MutableRefObject } from "react"; +import { + useImperativeHandle, + useRef, + MutableRefObject, + useEffect, +} from "react"; import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions"; import { CoreReadOnlyEditorProps } from "../../ui/read-only/props"; -import { EditorProps } from '@tiptap/pm/view'; +import { EditorProps } from "@tiptap/pm/view"; +import { IMentionSuggestion } from "../../types/mention-suggestion"; interface CustomReadOnlyEditorProps { value: string; forwardedRef?: any; extensions?: any; editorProps?: EditorProps; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; } -export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => { +export const useReadOnlyEditor = ({ + value, + forwardedRef, + extensions = [], + editorProps = {}, + mentionHighlights, + mentionSuggestions, +}: CustomReadOnlyEditorProps) => { const editor = useCustomEditor({ editable: false, - content: (typeof value === "string" && value.trim() !== "") ? value : "

", + content: + typeof value === "string" && value.trim() !== "" ? value : "

", editorProps: { ...CoreReadOnlyEditorProps, ...editorProps, }, - extensions: [...CoreReadOnlyEditorExtensions, ...extensions], + extensions: [ + ...CoreReadOnlyEditorExtensions({ + mentionSuggestions: mentionSuggestions ?? [], + mentionHighlights: mentionHighlights ?? [], + }), + ...extensions, + ], }); + const hasIntiliazedContent = useRef(false); + useEffect(() => { + if (editor && !value && !hasIntiliazedContent.current) { + editor.commands.setContent(value); + hasIntiliazedContent.current = true; + } + }, [value]); + const editorRef: MutableRefObject = useRef(null); editorRef.current = editor; @@ -34,7 +64,6 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor }, })); - if (!editor) { return null; } diff --git a/packages/editor/core/src/ui/index.tsx b/packages/editor/core/src/ui/index.tsx index 3c64e8ba6..a314a2650 100644 --- a/packages/editor/core/src/ui/index.tsx +++ b/packages/editor/core/src/ui/index.tsx @@ -1,13 +1,14 @@ -"use client" -import * as React from 'react'; +"use client"; +import * as React from "react"; import { Extension } from "@tiptap/react"; -import { UploadImage } from '../types/upload-image'; -import { DeleteImage } from '../types/delete-image'; -import { getEditorClassNames } from '../lib/utils'; -import { EditorProps } from '@tiptap/pm/view'; -import { useEditor } from './hooks/useEditor'; -import { EditorContainer } from '../ui/components/editor-container'; -import { EditorContentWrapper } from '../ui/components/editor-content'; +import { UploadImage } from "../types/upload-image"; +import { DeleteImage } from "../types/delete-image"; +import { getEditorClassNames } from "../lib/utils"; +import { EditorProps } from "@tiptap/pm/view"; +import { useEditor } from "./hooks/useEditor"; +import { EditorContainer } from "../ui/components/editor-container"; +import { EditorContentWrapper } from "../ui/components/editor-content"; +import { IMentionSuggestion } from "../types/mention-suggestion"; interface ICoreEditor { value: string; @@ -18,7 +19,9 @@ interface ICoreEditor { customClassName?: string; editorContentCustomClassNames?: string; onChange?: (json: any, html: string) => void; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void; setShouldShowAlert?: (showAlert: boolean) => void; editable?: boolean; forwardedRef?: any; @@ -30,6 +33,8 @@ interface ICoreEditor { key: string; label: "Private" | "Public"; }[]; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; extensions?: Extension[]; editorProps?: EditorProps; } @@ -61,7 +66,6 @@ const CoreEditor = ({ const editor = useEditor({ onChange, debouncedUpdatesEnabled, - editable, setIsSubmitting, setShouldShowAlert, value, @@ -70,22 +74,29 @@ const CoreEditor = ({ forwardedRef, }); - const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName }); + const editorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName, + }); if (!editor) return null; return (
- +
-
+ ); }; -const CoreEditorWithRef = React.forwardRef((props, ref) => ( - -)); +const CoreEditorWithRef = React.forwardRef( + (props, ref) => , +); CoreEditorWithRef.displayName = "CoreEditorWithRef"; diff --git a/packages/editor/core/src/ui/mentions/MentionList.tsx b/packages/editor/core/src/ui/mentions/MentionList.tsx new file mode 100644 index 000000000..48aebaa11 --- /dev/null +++ b/packages/editor/core/src/ui/mentions/MentionList.tsx @@ -0,0 +1,120 @@ +import { Editor } from "@tiptap/react"; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from "react"; + +import { IMentionSuggestion } from "../../types/mention-suggestion"; + +interface MentionListProps { + items: IMentionSuggestion[]; + command: (item: { + id: string; + label: string; + target: string; + redirect_uri: string; + }) => void; + editor: Editor; +} + +// eslint-disable-next-line react/display-name +const MentionList = forwardRef((props: MentionListProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = (index: number) => { + const item = props.items[index]; + + if (item) { + props.command({ + id: item.id, + label: item.title, + target: "users", + redirect_uri: item.redirect_uri, + }); + } + }; + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length); + }; + + const enterHandler = () => { + selectItem(selectedIndex); + }; + + useEffect(() => { + setSelectedIndex(0); + }, [props.items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === "ArrowUp") { + upHandler(); + return true; + } + + if (event.key === "ArrowDown") { + downHandler(); + return true; + } + + if (event.key === "Enter") { + enterHandler(); + return true; + } + + return false; + }, + })); + + return props.items && props.items.length !== 0 ? ( +
+ {props.items.length ? ( + props.items.map((item, index) => ( +
selectItem(index)} + > +
+ {item.avatar && item.avatar.trim() !== "" ? ( + {item.title} + ) : ( +
+ {item.title[0]} +
+ )} +
+
+

{item.title}

+ {/*

{item.subtitle}

*/} +
+
+ )) + ) : ( +
No result
+ )} +
+ ) : ( + <> + ); +}); + +MentionList.displayName = "MentionList"; + +export default MentionList; diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx new file mode 100644 index 000000000..dc4ab5aad --- /dev/null +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -0,0 +1,57 @@ +import { Mention, MentionOptions } from "@tiptap/extension-mention"; +import { mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import mentionNodeView from "./mentionNodeView"; +import { IMentionHighlight } from "../../types/mention-suggestion"; +export interface CustomMentionOptions extends MentionOptions { + mentionHighlights: IMentionHighlight[]; + readonly?: boolean; +} + +export const CustomMention = Mention.extend({ + addAttributes() { + return { + id: { + default: null, + }, + label: { + default: null, + }, + target: { + default: null, + }, + self: { + default: false, + }, + redirect_uri: { + default: "/", + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(mentionNodeView); + }, + + parseHTML() { + return [ + { + tag: "mention-component", + getAttrs: (node: string | HTMLElement) => { + if (typeof node === "string") { + return null; + } + return { + id: node.getAttribute("data-mention-id") || "", + target: node.getAttribute("data-mention-target") || "", + label: node.innerText.slice(1) || "", + redirect_uri: node.getAttribute("redirect_uri"), + }; + }, + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, +}); diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx new file mode 100644 index 000000000..42ec92554 --- /dev/null +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -0,0 +1,22 @@ +// @ts-nocheck + +import suggestion from "./suggestion"; +import { CustomMention } from "./custom"; +import { + IMentionHighlight, + IMentionSuggestion, +} from "../../types/mention-suggestion"; + +export const Mentions = ( + mentionSuggestions: IMentionSuggestion[], + mentionHighlights: IMentionHighlight[], + readonly, +) => + CustomMention.configure({ + HTMLAttributes: { + class: "mention", + }, + readonly: readonly, + mentionHighlights: mentionHighlights, + suggestion: suggestion(mentionSuggestions), + }); diff --git a/packages/editor/core/src/ui/mentions/mentionNodeView.tsx b/packages/editor/core/src/ui/mentions/mentionNodeView.tsx new file mode 100644 index 000000000..331c701e2 --- /dev/null +++ b/packages/editor/core/src/ui/mentions/mentionNodeView.tsx @@ -0,0 +1,41 @@ +/* eslint-disable react/display-name */ +// @ts-nocheck +import { NodeViewWrapper } from "@tiptap/react"; +import { cn } from "../../lib/utils"; +import { useRouter } from "next/router"; +import { IMentionHighlight } from "../../types/mention-suggestion"; + +// eslint-disable-next-line import/no-anonymous-default-export +export default (props) => { + const router = useRouter(); + const highlights = props.extension.options + .mentionHighlights as IMentionHighlight[]; + + const handleClick = () => { + if (!props.extension.options.readonly) { + router.push(props.node.attrs.redirect_uri); + } + }; + + return ( + + + @{props.node.attrs.label} + + + ); +}; diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts new file mode 100644 index 000000000..ce09cb092 --- /dev/null +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -0,0 +1,63 @@ +import { ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; + +import MentionList from "./MentionList"; +import { IMentionSuggestion } from "../../types/mention-suggestion"; + +const Suggestion = (suggestions: IMentionSuggestion[]) => ({ + items: ({ query }: { query: string }) => + suggestions + .filter((suggestion) => + suggestion.title.toLowerCase().startsWith(query.toLowerCase()), + ) + .slice(0, 5), + render: () => { + let reactRenderer: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + // @ts-ignore + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector("#editor-container"), + content: reactRenderer.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + reactRenderer?.updateProps(props); + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + // @ts-ignore + return reactRenderer?.ref?.onKeyDown(props); + }, + onExit: () => { + popup?.[0].destroy(); + reactRenderer?.destroy(); + }, + }; + }, +}); + +export default Suggestion; diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.tsx index f31b6601e..8a2651d1e 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -1,7 +1,37 @@ -import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react"; +import { + BoldIcon, + Heading1, + CheckSquare, + Heading2, + Heading3, + QuoteIcon, + ImageIcon, + TableIcon, + ListIcon, + ListOrderedIcon, + ItalicIcon, + UnderlineIcon, + StrikethroughIcon, + CodeIcon, +} from "lucide-react"; import { Editor } from "@tiptap/react"; import { UploadImage } from "../../../types/upload-image"; -import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands"; +import { + insertImageCommand, + insertTableCommand, + toggleBlockquote, + toggleBold, + toggleBulletList, + toggleCode, + toggleHeadingOne, + toggleHeadingThree, + toggleHeadingTwo, + toggleItalic, + toggleOrderedList, + toggleStrike, + toggleTaskList, + toggleUnderline, +} from "../../../lib/editor-commands"; export interface EditorMenuItem { name: string; @@ -15,95 +45,101 @@ export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ isActive: () => editor.isActive("heading", { level: 1 }), command: () => toggleHeadingOne(editor), icon: Heading1, -}) +}); export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ name: "H2", isActive: () => editor.isActive("heading", { level: 2 }), command: () => toggleHeadingTwo(editor), icon: Heading2, -}) +}); export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ name: "H3", isActive: () => editor.isActive("heading", { level: 3 }), command: () => toggleHeadingThree(editor), icon: Heading3, -}) +}); export const BoldItem = (editor: Editor): EditorMenuItem => ({ name: "bold", isActive: () => editor?.isActive("bold"), command: () => toggleBold(editor), icon: BoldIcon, -}) +}); export const ItalicItem = (editor: Editor): EditorMenuItem => ({ name: "italic", isActive: () => editor?.isActive("italic"), command: () => toggleItalic(editor), icon: ItalicIcon, -}) +}); export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ name: "underline", isActive: () => editor?.isActive("underline"), command: () => toggleUnderline(editor), icon: UnderlineIcon, -}) +}); export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ name: "strike", isActive: () => editor?.isActive("strike"), command: () => toggleStrike(editor), icon: StrikethroughIcon, -}) +}); export const CodeItem = (editor: Editor): EditorMenuItem => ({ name: "code", isActive: () => editor?.isActive("code"), command: () => toggleCode(editor), icon: CodeIcon, -}) +}); export const BulletListItem = (editor: Editor): EditorMenuItem => ({ name: "bullet-list", isActive: () => editor?.isActive("bulletList"), command: () => toggleBulletList(editor), icon: ListIcon, -}) +}); export const TodoListItem = (editor: Editor): EditorMenuItem => ({ name: "To-do List", isActive: () => editor.isActive("taskItem"), command: () => toggleTaskList(editor), icon: CheckSquare, -}) +}); export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ name: "ordered-list", isActive: () => editor?.isActive("orderedList"), command: () => toggleOrderedList(editor), - icon: ListOrderedIcon -}) + icon: ListOrderedIcon, +}); export const QuoteItem = (editor: Editor): EditorMenuItem => ({ name: "quote", isActive: () => editor?.isActive("quote"), command: () => toggleBlockquote(editor), - icon: QuoteIcon -}) + icon: QuoteIcon, +}); export const TableItem = (editor: Editor): EditorMenuItem => ({ - name: "quote", + name: "table", isActive: () => editor?.isActive("table"), command: () => insertTableCommand(editor), - icon: TableIcon -}) + icon: TableIcon, +}); -export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({ +export const ImageItem = ( + editor: Editor, + uploadFile: UploadImage, + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, +): EditorMenuItem => ({ name: "image", isActive: () => editor?.isActive("image"), command: () => insertImageCommand(editor, uploadFile, setIsSubmitting), icon: ImageIcon, -}) +}); diff --git a/packages/editor/core/src/ui/menus/table-menu/InsertBottomTableIcon.tsx b/packages/editor/core/src/ui/menus/table-menu/InsertBottomTableIcon.tsx deleted file mode 100644 index 0e42ba648..000000000 --- a/packages/editor/core/src/ui/menus/table-menu/InsertBottomTableIcon.tsx +++ /dev/null @@ -1,16 +0,0 @@ -const InsertBottomTableIcon = (props: any) => ( - - - -); - -export default InsertBottomTableIcon; diff --git a/packages/editor/core/src/ui/menus/table-menu/InsertLeftTableIcon.tsx b/packages/editor/core/src/ui/menus/table-menu/InsertLeftTableIcon.tsx deleted file mode 100644 index 1fd75fe87..000000000 --- a/packages/editor/core/src/ui/menus/table-menu/InsertLeftTableIcon.tsx +++ /dev/null @@ -1,15 +0,0 @@ -const InsertLeftTableIcon = (props: any) => ( - - - -); -export default InsertLeftTableIcon; diff --git a/packages/editor/core/src/ui/menus/table-menu/InsertRightTableIcon.tsx b/packages/editor/core/src/ui/menus/table-menu/InsertRightTableIcon.tsx deleted file mode 100644 index 1a6570969..000000000 --- a/packages/editor/core/src/ui/menus/table-menu/InsertRightTableIcon.tsx +++ /dev/null @@ -1,16 +0,0 @@ -const InsertRightTableIcon = (props: any) => ( - - - -); - -export default InsertRightTableIcon; diff --git a/packages/editor/core/src/ui/menus/table-menu/InsertTopTableIcon.tsx b/packages/editor/core/src/ui/menus/table-menu/InsertTopTableIcon.tsx deleted file mode 100644 index 8f04f4f61..000000000 --- a/packages/editor/core/src/ui/menus/table-menu/InsertTopTableIcon.tsx +++ /dev/null @@ -1,15 +0,0 @@ -const InsertTopTableIcon = (props: any) => ( - - - -); -export default InsertTopTableIcon; diff --git a/packages/editor/core/src/ui/menus/table-menu/index.tsx b/packages/editor/core/src/ui/menus/table-menu/index.tsx deleted file mode 100644 index c115196db..000000000 --- a/packages/editor/core/src/ui/menus/table-menu/index.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useState, useEffect } from "react"; -import { Rows, Columns, ToggleRight } from "lucide-react"; -import InsertLeftTableIcon from "./InsertLeftTableIcon"; -import InsertRightTableIcon from "./InsertRightTableIcon"; -import InsertTopTableIcon from "./InsertTopTableIcon"; -import InsertBottomTableIcon from "./InsertBottomTableIcon"; -import { cn, findTableAncestor } from "../../../lib/utils"; -import { Tooltip } from "./tooltip"; - -interface TableMenuItem { - command: () => void; - icon: any; - key: string; - name: string; -} - - - -export const TableMenu = ({ editor }: { editor: any }) => { - const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 }); - const isOpen = editor?.isActive("table"); - - const items: TableMenuItem[] = [ - { - command: () => editor.chain().focus().addColumnBefore().run(), - icon: InsertLeftTableIcon, - key: "insert-column-left", - name: "Insert 1 column left", - }, - { - command: () => editor.chain().focus().addColumnAfter().run(), - icon: InsertRightTableIcon, - key: "insert-column-right", - name: "Insert 1 column right", - }, - { - command: () => editor.chain().focus().addRowBefore().run(), - icon: InsertTopTableIcon, - key: "insert-row-above", - name: "Insert 1 row above", - }, - { - command: () => editor.chain().focus().addRowAfter().run(), - icon: InsertBottomTableIcon, - key: "insert-row-below", - name: "Insert 1 row below", - }, - { - command: () => editor.chain().focus().deleteColumn().run(), - icon: Columns, - key: "delete-column", - name: "Delete column", - }, - { - command: () => editor.chain().focus().deleteRow().run(), - icon: Rows, - key: "delete-row", - name: "Delete row", - }, - { - command: () => editor.chain().focus().toggleHeaderRow().run(), - icon: ToggleRight, - key: "toggle-header-row", - name: "Toggle header row", - }, - ]; - - useEffect(() => { - if (!window) return; - - const handleWindowClick = () => { - const selection: any = window?.getSelection(); - - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - const tableNode = findTableAncestor(range.startContainer); - - if (tableNode) { - const tableRect = tableNode.getBoundingClientRect(); - const tableCenter = tableRect.left + tableRect.width / 2; - const menuWidth = 45; - const menuLeft = tableCenter - menuWidth / 2; - const tableBottom = tableRect.bottom; - - setTableLocation({ bottom: tableBottom, left: menuLeft }); - } - } - }; - - window.addEventListener("click", handleWindowClick); - - return () => { - window.removeEventListener("click", handleWindowClick); - }; - }, [tableLocation, editor]); - - return ( -
- {items.map((item, index) => ( - - - - ))} -
- ); -}; diff --git a/packages/editor/core/src/ui/menus/table-menu/tooltip.tsx b/packages/editor/core/src/ui/menus/table-menu/tooltip.tsx deleted file mode 100644 index f29d8a491..000000000 --- a/packages/editor/core/src/ui/menus/table-menu/tooltip.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from 'react'; - -// next-themes -import { useTheme } from "next-themes"; -// tooltip2 -import { Tooltip2 } from "@blueprintjs/popover2"; - -type Props = { - tooltipHeading?: string; - tooltipContent: string | React.ReactNode; - position?: - | "top" - | "right" - | "bottom" - | "left" - | "auto" - | "auto-end" - | "auto-start" - | "bottom-left" - | "bottom-right" - | "left-bottom" - | "left-top" - | "right-bottom" - | "right-top" - | "top-left" - | "top-right"; - children: JSX.Element; - disabled?: boolean; - className?: string; - openDelay?: number; - closeDelay?: number; -}; - -export const Tooltip: React.FC = ({ - tooltipHeading, - tooltipContent, - position = "top", - children, - disabled = false, - className = "", - openDelay = 200, - closeDelay, -}) => { - const { theme } = useTheme(); - - return ( - - {tooltipHeading && ( -
- {tooltipHeading} -
- )} - {tooltipContent} -
- } - position={position} - renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => - React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props }) - } - /> - ); -}; diff --git a/packages/editor/core/src/ui/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx index ba21d686d..48ec244fc 100644 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ b/packages/editor/core/src/ui/plugins/delete-image.tsx @@ -15,8 +15,12 @@ interface ImageNode extends ProseMirrorNode { const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => new Plugin({ key: deleteKey, - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); + appendTransaction: ( + transactions: readonly Transaction[], + oldState: EditorState, + newState: EditorState, + ) => { + const newImageSources = new Set(); newState.doc.descendants((node) => { if (node.type.name === IMAGE_NODE_TYPE) { newImageSources.add(node.attrs.src); @@ -55,7 +59,10 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => export default TrackImageDeletionPlugin; -async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { +async function onNodeDeleted( + src: string, + deleteImage: DeleteImage, +): Promise { try { const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); const resStatus = await deleteImage(assetUrlWithWorkspaceId); diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx index cdd62ae48..256460073 100644 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ b/packages/editor/core/src/ui/plugins/upload-image.tsx @@ -4,7 +4,7 @@ import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; const uploadKey = new PluginKey("upload-image"); -const UploadImagesPlugin = () => +const UploadImagesPlugin = (cancelUploadImage?: () => any) => new Plugin({ key: uploadKey, state: { @@ -21,15 +21,46 @@ const UploadImagesPlugin = () => const placeholder = document.createElement("div"); placeholder.setAttribute("class", "img-placeholder"); const image = document.createElement("img"); - image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300"); + image.setAttribute( + "class", + "opacity-10 rounded-lg border border-custom-border-300", + ); image.src = src; placeholder.appendChild(image); + + // Create cancel button + const cancelButton = document.createElement("button"); + cancelButton.style.position = "absolute"; + cancelButton.style.right = "3px"; + cancelButton.style.top = "3px"; + cancelButton.setAttribute("class", "opacity-90 rounded-lg"); + + cancelButton.onclick = () => { + cancelUploadImage?.(); + }; + + // Create an SVG element from the SVG string + const svgString = ``; + const parser = new DOMParser(); + const svgElement = parser.parseFromString( + svgString, + "image/svg+xml", + ).documentElement; + + cancelButton.appendChild(svgElement); + placeholder.appendChild(cancelButton); const deco = Decoration.widget(pos + 1, placeholder, { id, }); set = set.add(tr.doc, [deco]); } else if (action && action.remove) { - set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); + set = set.remove( + set.find( + undefined, + undefined, + (spec) => spec.id == action.remove.id, + ), + ); } return set; }, @@ -48,19 +79,39 @@ function findPlaceholder(state: EditorState, id: {}) { const found = decos.find( undefined, undefined, - (spec: { id: number | undefined }) => spec.id == id + (spec: { id: number | undefined }) => spec.id == id, ); return found.length ? found[0].from : null; } +const removePlaceholder = (view: EditorView, id: {}) => { + const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { + remove: { id }, + }); + view.dispatch(removePlaceholderTr); +}; + export async function startImageUpload( file: File, view: EditorView, pos: number, uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, ) { + if (!file) { + alert("No file selected. Please select a file to upload."); + return; + } + if (!file.type.includes("image/")) { + alert("Invalid file type. Please select an image file."); + return; + } + + if (file.size > 5 * 1024 * 1024) { + alert("File size too large. Please select a file smaller than 5MB."); return; } @@ -82,28 +133,42 @@ export async function startImageUpload( view.dispatch(tr); }; + // Handle FileReader errors + reader.onerror = (error) => { + console.error("FileReader error: ", error); + removePlaceholder(view, id); + return; + }; + setIsSubmitting?.("submitting"); - const src = await UploadImageHandler(file, uploadFile); - const { schema } = view.state; - pos = findPlaceholder(view.state, id); - if (pos == null) return; - const imageSrc = typeof src === "object" ? reader.result : src; + try { + const src = await UploadImageHandler(file, uploadFile); + const { schema } = view.state; + pos = findPlaceholder(view.state, id); - const node = schema.nodes.image.create({ src: imageSrc }); - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); + if (pos == null) return; + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + const transaction = view.state.tr + .replaceWith(pos, pos, node) + .setMeta(uploadKey, { remove: { id } }); + view.dispatch(transaction); + } catch (error) { + console.error("Upload error: ", error); + removePlaceholder(view, id); + } } -const UploadImageHandler = (file: File, - uploadFile: UploadImage +const UploadImageHandler = ( + file: File, + uploadFile: UploadImage, ): Promise => { try { return new Promise(async (resolve, reject) => { try { - const imageUrl = await uploadFile(file) + const imageUrl = await uploadFile(file); const image = new Image(); image.src = imageUrl; @@ -118,9 +183,6 @@ const UploadImageHandler = (file: File, } }); } catch (error) { - if (error instanceof Error) { - console.log(error.message); - } return Promise.reject(error); } }; diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 8f002b76c..865e0d2c7 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -5,7 +5,9 @@ import { UploadImage } from "../types/upload-image"; export function CoreEditorProps( uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, ): EditorProps { return { attributes: { @@ -32,7 +34,11 @@ export function CoreEditorProps( } } } - if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { + if ( + event.clipboardData && + event.clipboardData.files && + event.clipboardData.files[0] + ) { event.preventDefault(); const file = event.clipboardData.files[0]; const pos = view.state.selection.from; @@ -51,7 +57,12 @@ export function CoreEditorProps( } } } - if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + if ( + !moved && + event.dataTransfer && + event.dataTransfer.files && + event.dataTransfer.files[0] + ) { event.preventDefault(); const file = event.dataTransfer.files[0]; const coordinates = view.posAtCoords({ @@ -59,7 +70,13 @@ export function CoreEditorProps( top: event.clientY, }); if (coordinates) { - startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting); + startImageUpload( + file, + view, + coordinates.pos - 1, + uploadFile, + setIsSubmitting, + ); } return true; } diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 2246c64f9..b8fc9bb95 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -8,15 +8,20 @@ import TaskList from "@tiptap/extension-task-list"; import { Markdown } from "tiptap-markdown"; import Gapcursor from "@tiptap/extension-gapcursor"; -import { CustomTableCell } from "../extensions/table/table-cell"; -import { Table } from "../extensions/table"; -import { TableHeader } from "../extensions/table/table-header"; -import { TableRow } from "@tiptap/extension-table-row"; +import TableHeader from "../extensions/table/table-header/table-header"; +import Table from "../extensions/table/table"; +import TableCell from "../extensions/table/table-cell/table-cell"; +import TableRow from "../extensions/table/table-row/table-row"; import ReadOnlyImageExtension from "../extensions/image/read-only-image"; import { isValidHttpUrl } from "../../lib/utils"; +import { Mentions } from "../mentions"; +import { IMentionSuggestion } from "../../types/mention-suggestion"; -export const CoreReadOnlyEditorExtensions = [ +export const CoreReadOnlyEditorExtensions = (mentionConfig: { + mentionSuggestions: IMentionSuggestion[]; + mentionHighlights: string[]; +}) => [ StarterKit.configure({ bulletList: { HTMLAttributes: { @@ -53,40 +58,45 @@ export const CoreReadOnlyEditorExtensions = [ }, gapcursor: false, }), - Gapcursor, - TiptapLink.configure({ - protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - ReadOnlyImageExtension.configure({ - HTMLAttributes: { - class: "rounded-lg border border-custom-border-300", - }, - }), - TiptapUnderline, - TextStyle, - Color, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "flex items-start my-4", - }, - nested: true, - }), - Markdown.configure({ - html: true, - transformCopiedText: true, - }), - Table, - TableHeader, - CustomTableCell, - TableRow, - ]; + Gapcursor, + TiptapLink.configure({ + protocols: ["http", "https"], + validate: (url) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + ReadOnlyImageExtension.configure({ + HTMLAttributes: { + class: "rounded-lg border border-custom-border-300", + }, + }), + TiptapUnderline, + TextStyle, + Color, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex items-start my-4", + }, + nested: true, + }), + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + Mentions( + mentionConfig.mentionSuggestions, + mentionConfig.mentionHighlights, + true, + ), +]; diff --git a/packages/editor/core/src/ui/read-only/props.tsx b/packages/editor/core/src/ui/read-only/props.tsx index 25db2b68c..79f9fcb0d 100644 --- a/packages/editor/core/src/ui/read-only/props.tsx +++ b/packages/editor/core/src/ui/read-only/props.tsx @@ -1,7 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; -export const CoreReadOnlyEditorProps: EditorProps = -{ +export const CoreReadOnlyEditorProps: EditorProps = { attributes: { class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, }, diff --git a/packages/editor/lite-text-editor/Readme.md b/packages/editor/lite-text-editor/Readme.md index 948e2c34b..1f10f5ff4 100644 --- a/packages/editor/lite-text-editor/Readme.md +++ b/packages/editor/lite-text-editor/Readme.md @@ -10,25 +10,25 @@ The `@plane/lite-text-editor` package extends from the `editor-core` package, in `LiteTextEditor` & `LiteTextEditorWithRef` -- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Lite editor types (with and without Ref) +- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref) `LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef` ## LiteTextEditor -| Prop | Type | Description | -| --- | --- | --- | -| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | -| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | -| `value` | `html string` | The initial content of the editor. | -| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press | -| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | -| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | -| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | -| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | +| Prop | Type | Description | +| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | +| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | +| `value` | `html string` | The initial content of the editor. | +| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press | +| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | +| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | +| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | +| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | ### Usage @@ -36,62 +36,62 @@ The `@plane/lite-text-editor` package extends from the `editor-core` package, in ```tsx { - onChange(comment_html); - }} - /> + onEnterKeyPress={handleSubmit(handleCommentUpdate)} + uploadFile={fileService.getUploadFileFunction(workspaceSlug)} + deleteFile={fileService.deleteImage} + value={value} + debouncedUpdatesEnabled={false} + customClassName="min-h-[50px] p-3 shadow-sm" + onChange={(comment_json: Object, comment_html: string) => { + onChange(comment_html); + }} +/> ``` 2. Example of how to use the `LiteTextEditorWithRef` component ```tsx - const editorRef = useRef(null); +const editorRef = useRef(null); - // can use it to set the editor's value - editorRef.current?.setEditorValue(`${watch("description_html")}`); +// can use it to set the editor's value +editorRef.current?.setEditorValue(`${watch("description_html")}`); - // can use it to clear the editor - editorRef?.current?.clearEditor(); +// can use it to clear the editor +editorRef?.current?.clearEditor(); - return ( - { - onChange(comment_html); - }} - /> -) +return ( + { + onChange(comment_html); + }} + /> +); ``` ## LiteReadOnlyEditor -| Prop | Type | Description | -| --- | --- | --- | -| `value` | `html string` | The initial content of the editor. | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | +| Prop | Type | Description | +| ------------------------------- | ------------- | --------------------------------------------------------------------- | +| `value` | `html string` | The initial content of the editor. | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | ### Usage Here is an example of how to use the `RichReadOnlyEditor` component ```tsx - + ``` diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 47ef154c6..52f27fb29 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -2,6 +2,7 @@ "name": "@plane/lite-text-editor", "version": "0.0.1", "description": "Package that powers Plane's Comment Editor", + "private": true, "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", @@ -28,10 +29,8 @@ }, "dependencies": { "@plane/editor-core": "*", + "@plane/ui": "*", "@tiptap/extension-list-item": "^2.1.11", - "@types/node": "18.15.3", - "@types/react": "^18.2.5", - "@types/react-dom": "18.0.11", "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "eslint": "8.36.0", @@ -46,6 +45,9 @@ "use-debounce": "^9.0.4" }, "devDependencies": { + "@types/node": "18.15.3", + "@types/react": "^18.2.35", + "@types/react-dom": "^18.2.14", "eslint": "^7.32.0", "postcss": "^8.4.29", "tailwind-config-custom": "*", diff --git a/packages/editor/lite-text-editor/src/index.ts b/packages/editor/lite-text-editor/src/index.ts index de9323b3c..ba916e666 100644 --- a/packages/editor/lite-text-editor/src/index.ts +++ b/packages/editor/lite-text-editor/src/index.ts @@ -1,2 +1,3 @@ export { LiteTextEditor, LiteTextEditorWithRef } from "./ui"; export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only"; +export type { IMentionSuggestion, IMentionHighlight } from "./ui"; diff --git a/packages/editor/lite-text-editor/src/ui/extensions/custom-list-extension.tsx b/packages/editor/lite-text-editor/src/ui/extensions/custom-list-extension.tsx deleted file mode 100644 index f0bc70cff..000000000 --- a/packages/editor/lite-text-editor/src/ui/extensions/custom-list-extension.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import ListItem from '@tiptap/extension-list-item' - -export const CustomListItem = ListItem.extend({ - addKeyboardShortcuts() { - return { - 'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(), - } - }, -}) diff --git a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx index 04c4a1fbe..129efa4ee 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx +++ b/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx @@ -1,16 +1,25 @@ -import { Extension } from '@tiptap/core'; +import { Extension } from "@tiptap/core"; -export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ - name: 'enterKey', +export const EnterKeyExtension = (onEnterKeyPress?: () => void) => + Extension.create({ + name: "enterKey", - addKeyboardShortcuts() { - return { - 'Enter': () => { - if (onEnterKeyPress) { - onEnterKeyPress(); - } - return true; - }, - } - }, -}); + addKeyboardShortcuts() { + return { + Enter: () => { + if (onEnterKeyPress) { + onEnterKeyPress(); + } + return true; + }, + "Shift-Enter": ({ editor }) => + editor.commands.first(({ commands }) => [ + () => commands.newlineInCode(), + () => commands.splitListItem("listItem"), + () => commands.createParagraphNear(), + () => commands.liftEmptyBlock(), + () => commands.splitBlock(), + ]), + }; + }, + }); diff --git a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx index ccd04a395..4531e9516 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx @@ -1,7 +1,5 @@ -import { CustomListItem } from "./custom-list-extension"; import { EnterKeyExtension } from "./enter-key-extension"; export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [ - CustomListItem, - EnterKeyExtension(onEnterKeyPress), + // EnterKeyExtension(onEnterKeyPress), ]; diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 5b525d92b..e7decbcac 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -1,4 +1,3 @@ -"use client"; import * as React from "react"; import { EditorContainer, @@ -11,6 +10,16 @@ import { LiteTextEditorExtensions } from "./extensions"; export type UploadImage = (file: File) => Promise; export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; +export type IMentionSuggestion = { + id: string; + type: string; + avatar: string; + title: string; + subtitle: string; + redirect_uri: string; +}; + +export type IMentionHighlight = string; interface ILiteTextEditor { value: string; @@ -22,7 +31,7 @@ interface ILiteTextEditor { editorContentCustomClassNames?: string; onChange?: (json: any, html: string) => void; setIsSubmitting?: ( - isSubmitting: "submitting" | "submitted" | "saved" + isSubmitting: "submitting" | "submitted" | "saved", ) => void; setShouldShowAlert?: (showAlert: boolean) => void; forwardedRef?: any; @@ -38,6 +47,10 @@ interface ILiteTextEditor { }[]; }; onEnterKeyPress?: (e?: any) => void; + cancelUploadImage?: () => any; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; + submitButton?: React.ReactNode; } interface LiteTextEditorProps extends ILiteTextEditor { @@ -49,24 +62,31 @@ interface EditorHandle { setEditorValue: (content: string) => void; } -const LiteTextEditor = ({ - onChange, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, - editorContentCustomClassNames, - value, - uploadFile, - deleteFile, - noBorder, - borderOnFocus, - customClassName, - forwardedRef, - commentAccessSpecifier, - onEnterKeyPress, -}: LiteTextEditorProps) => { +const LiteTextEditor = (props: LiteTextEditorProps) => { + const { + onChange, + cancelUploadImage, + debouncedUpdatesEnabled, + setIsSubmitting, + setShouldShowAlert, + editorContentCustomClassNames, + value, + uploadFile, + deleteFile, + noBorder, + borderOnFocus, + customClassName, + forwardedRef, + commentAccessSpecifier, + onEnterKeyPress, + mentionHighlights, + mentionSuggestions, + submitButton, + } = props; + const editor = useEditor({ onChange, + cancelUploadImage, debouncedUpdatesEnabled, setIsSubmitting, setShouldShowAlert, @@ -75,6 +95,8 @@ const LiteTextEditor = ({ deleteFile, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), + mentionHighlights, + mentionSuggestions, }); const editorClassNames = getEditorClassNames({ @@ -98,6 +120,7 @@ const LiteTextEditor = ({ uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} commentAccessSpecifier={commentAccessSpecifier} + submitButton={submitButton} />
@@ -106,7 +129,7 @@ const LiteTextEditor = ({ }; const LiteTextEditorWithRef = React.forwardRef( - (props, ref) => + (props, ref) => , ); LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef"; diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx index c0006b3f2..60878f9bf 100644 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx +++ b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx @@ -6,8 +6,9 @@ type Props = { }; export const Icon: React.FC = ({ iconName, className = "" }) => ( - + {iconName} ); - diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx index a42421b04..a4fb0479c 100644 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx @@ -1,5 +1,5 @@ import { Editor } from "@tiptap/react"; -import { BoldIcon, LucideIcon } from "lucide-react"; +import { BoldIcon } from "lucide-react"; import { BoldItem, @@ -14,9 +14,8 @@ import { TableItem, UnderLineItem, } from "@plane/editor-core"; -import { Icon } from "./icon"; -import { Tooltip } from "../../tooltip"; -import { UploadImage } from "../.."; +import { Tooltip } from "@plane/ui"; +import { UploadImage } from "../../"; export interface BubbleMenuItem { name: string; @@ -41,8 +40,9 @@ type EditorBubbleMenuProps = { }; uploadFile: UploadImage; setIsSubmitting?: ( - isSubmitting: "submitting" | "submitted" | "saved" + isSubmitting: "submitting" | "submitted" | "saved", ) => void; + submitButton: React.ReactNode; }; export const FixedMenu = (props: EditorBubbleMenuProps) => { @@ -73,115 +73,145 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { }; return ( -
+
{props.commentAccessSpecifier && ( -
+
{props?.commentAccessSpecifier.commentAccess?.map((access) => ( ))}
)} -
- {basicMarkItems.map((item, index) => ( - - ))} -
-
- {listItems.map((item, index) => ( - - ))} -
-
- {userActionItems.map((item, index) => ( - - ))} -
-
- {complexItems.map((item, index) => ( - - ))} +
+
+
+ {basicMarkItems.map((item, index) => ( + {item.name}} + > + + + ))} +
+
+ {listItems.map((item, index) => ( + {item.name}} + > + + + ))} +
+
+ {userActionItems.map((item, index) => ( + {item.name}} + > + + + ))} +
+
+ {complexItems.map((item, index) => ( + {item.name}} + > + + + ))} +
+
+
{props.submitButton}
); diff --git a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx index 3990cb734..a3de061ae 100644 --- a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx @@ -1,6 +1,10 @@ -"use client" -import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core'; -import * as React from 'react'; +import * as React from "react"; +import { + EditorContainer, + EditorContentWrapper, + getEditorClassNames, + useReadOnlyEditor, +} from "@plane/editor-core"; interface ICoreReadOnlyEditor { value: string; @@ -8,6 +12,7 @@ interface ICoreReadOnlyEditor { noBorder?: boolean; borderOnFocus?: boolean; customClassName?: string; + mentionHighlights: string[]; } interface EditorCoreProps extends ICoreReadOnlyEditor { @@ -26,29 +31,39 @@ const LiteReadOnlyEditor = ({ customClassName, value, forwardedRef, + mentionHighlights, }: EditorCoreProps) => { const editor = useReadOnlyEditor({ value, forwardedRef, + mentionHighlights, }); - const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName }); + const editorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName, + }); if (!editor) return null; return (
- +
-
+ ); }; -const LiteReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - -)); +const LiteReadOnlyEditorWithRef = React.forwardRef< + EditorHandle, + ICoreReadOnlyEditor +>((props, ref) => ); LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; -export { LiteReadOnlyEditor , LiteReadOnlyEditorWithRef }; +export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef }; diff --git a/packages/editor/lite-text-editor/src/ui/tooltip.tsx b/packages/editor/lite-text-editor/src/ui/tooltip.tsx index f29d8a491..a2f2414e5 100644 --- a/packages/editor/lite-text-editor/src/ui/tooltip.tsx +++ b/packages/editor/lite-text-editor/src/ui/tooltip.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; - +import * as React from "react"; // next-themes import { useTheme } from "next-themes"; // tooltip2 @@ -69,8 +68,16 @@ export const Tooltip: React.FC = ({
} position={position} - renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => - React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props }) + renderTarget={({ + isOpen: isTooltipOpen, + ref: eleReference, + ...tooltipProps + }) => + React.cloneElement(children, { + ref: eleReference, + ...tooltipProps, + ...children.props, + }) } /> ); diff --git a/packages/editor/rich-text-editor/Readme.md b/packages/editor/rich-text-editor/Readme.md index c8414f62d..44ed9ba5e 100644 --- a/packages/editor/rich-text-editor/Readme.md +++ b/packages/editor/rich-text-editor/Readme.md @@ -10,24 +10,24 @@ The `@plane/rich-text-editor` package extends from the `editor-core` package, in `RichTextEditor` & `RichTextEditorWithRef` -- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Rich editor types (with and without Ref) +- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Rich editor types (with and without Ref) `RichReadOnlyEditor` &`RichReadOnlyEditorWithRef` ## RichTextEditor -| Prop | Type | Description | -| --- | --- | --- | -| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | -| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | -| `value` | `html string` | The initial content of the editor. | -| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | -| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | -| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | -| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | +| Prop | Type | Description | +| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | +| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | +| `value` | `html string` | The initial content of the editor. | +| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | +| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | +| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | +| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | ### Usage @@ -57,43 +57,47 @@ The `@plane/rich-text-editor` package extends from the `editor-core` package, in 2. Example of how to use the `RichTextEditorWithRef` component ```tsx - const editorRef = useRef(null); +const editorRef = useRef(null); - // can use it to set the editor's value - editorRef.current?.setEditorValue(`${watch("description_html")}`); +// can use it to set the editor's value +editorRef.current?.setEditorValue(`${watch("description_html")}`); - // can use it to clear the editor - editorRef?.current?.clearEditor(); +// can use it to clear the editor +editorRef?.current?.clearEditor(); - return ( { - onChange(description_html); - // custom stuff you want to do - } } />) +return ( + { + onChange(description_html); + // custom stuff you want to do + }} + /> +); ``` ## RichReadOnlyEditor -| Prop | Type | Description | -| --- | --- | --- | -| `value` | `html string` | The initial content of the editor. | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | +| Prop | Type | Description | +| ------------------------------- | ------------- | --------------------------------------------------------------------- | +| `value` | `html string` | The initial content of the editor. | +| `noBorder` | `boolean` | If set to true, the editor will not have a border. | +| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | +| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | +| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | ### Usage Here is an example of how to use the `RichReadOnlyEditor` component ```tsx - + ``` diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 7bdd0a58b..db793261c 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -2,6 +2,7 @@ "name": "@plane/rich-text-editor", "version": "0.0.1", "description": "Rich Text Editor that powers Plane", + "private": true, "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", @@ -21,19 +22,19 @@ "check-types": "tsc --noEmit" }, "peerDependencies": { + "@tiptap/core": "^2.1.11", "next": "12.3.2", "next-themes": "^0.2.1", "react": "^18.2.0", - "react-dom": "18.2.0", - "@tiptap/core": "^2.1.11" + "react-dom": "18.2.0" }, "dependencies": { "@plane/editor-core": "*", "@tiptap/extension-code-block-lowlight": "^2.1.11", "@tiptap/extension-horizontal-rule": "^2.1.11", "@tiptap/extension-placeholder": "^2.1.11", - "class-variance-authority": "^0.7.0", "@tiptap/suggestion": "^2.1.7", + "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "highlight.js": "^11.8.0", "lowlight": "^3.0.0", @@ -41,8 +42,8 @@ }, "devDependencies": { "@types/node": "18.15.3", - "@types/react": "^18.2.5", - "@types/react-dom": "18.0.11", + "@types/react": "^18.2.35", + "@types/react-dom": "^18.2.14", "eslint": "^7.32.0", "postcss": "^8.4.29", "react": "^18.2.0", diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts index 36d0a95f9..9ea7f9a39 100644 --- a/packages/editor/rich-text-editor/src/index.ts +++ b/packages/editor/rich-text-editor/src/index.ts @@ -2,3 +2,4 @@ import "./styles/github-dark.css"; export { RichTextEditor, RichTextEditorWithRef } from "./ui"; export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only"; +export type { IMentionSuggestion, IMentionHighlight } from "./ui"; diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index f0f3bed34..a28982da3 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,7 +1,7 @@ import HorizontalRule from "@tiptap/extension-horizontal-rule"; import Placeholder from "@tiptap/extension-placeholder"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; -import { common, createLowlight } from 'lowlight' +import { common, createLowlight } from "lowlight"; import { InputRule } from "@tiptap/core"; import ts from "highlight.js/lib/languages/typescript"; @@ -9,51 +9,53 @@ import ts from "highlight.js/lib/languages/typescript"; import SlashCommand from "./slash-command"; import { UploadImage } from "../"; -const lowlight = createLowlight(common) +const lowlight = createLowlight(common); lowlight.register("ts", ts); export const RichTextEditorExtensions = ( uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, ) => [ - HorizontalRule.extend({ - addInputRules() { - return [ - new InputRule({ - find: /^(?:---|—-|___\s|\*\*\*\s)$/, - handler: ({ state, range, commands }) => { - commands.splitBlock(); + HorizontalRule.extend({ + addInputRules() { + return [ + new InputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + handler: ({ state, range, commands }) => { + commands.splitBlock(); - const attributes = {}; - const { tr } = state; - const start = range.from; - const end = range.to; - // @ts-ignore - tr.replaceWith(start - 1, end, this.type.create(attributes)); - }, - }), - ]; - }, - }).configure({ - HTMLAttributes: { - class: "mb-6 border-t border-custom-border-300", - }, - }), - SlashCommand(uploadFile, setIsSubmitting), - CodeBlockLowlight.configure({ - lowlight, - }), - Placeholder.configure({ - placeholder: ({ node }) => { - if (node.type.name === "heading") { - return `Heading ${node.attrs.level}`; - } - if (node.type.name === "image" || node.type.name === "table") { - return ""; - } + const attributes = {}; + const { tr } = state; + const start = range.from; + const end = range.to; + // @ts-ignore + tr.replaceWith(start - 1, end, this.type.create(attributes)); + }, + }), + ]; + }, + }).configure({ + HTMLAttributes: { + class: "mb-6 border-t border-custom-border-300", + }, + }), + SlashCommand(uploadFile, setIsSubmitting), + CodeBlockLowlight.configure({ + lowlight, + }), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + if (node.type.name === "image" || node.type.name === "table") { + return ""; + } - return "Press '/' for commands..."; - }, - includeChildren: true, - }), - ]; + return "Press '/' for commands..."; + }, + includeChildren: true, + }), +]; diff --git a/packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx b/packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx index e00585dd8..bab13304a 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/slash-command.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; +import { + useState, + useEffect, + useCallback, + ReactNode, + useRef, + useLayoutEffect, +} from "react"; import { Editor, Range, Extension } from "@tiptap/core"; import Suggestion from "@tiptap/suggestion"; import { ReactRenderer } from "@tiptap/react"; @@ -18,7 +25,18 @@ import { Table, } from "lucide-react"; import { UploadImage } from "../"; -import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core"; +import { + cn, + insertTableCommand, + toggleBlockquote, + toggleBulletList, + toggleOrderedList, + toggleTaskList, + insertImageCommand, + toggleHeadingOne, + toggleHeadingTwo, + toggleHeadingThree, +} from "@plane/editor-core"; interface CommandItemProps { title: string; @@ -37,7 +55,15 @@ const Command = Extension.create({ return { suggestion: { char: "/", - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + command: ({ + editor, + range, + props, + }: { + editor: Editor; + range: Range; + props: any; + }) => { props.command({ editor, range }); }, }, @@ -59,127 +85,135 @@ const Command = Extension.create({ const getSuggestionItems = ( uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + setIsSubmitting?: ( + isSubmitting: "submitting" | "submitted" | "saved", + ) => void, ) => - ({ query }: { query: string }) => - [ - { - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); - }, + ({ query }: { query: string }) => + [ + { + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .run(); }, - { - title: "Heading 1", - description: "Big section heading.", - searchTerms: ["title", "big", "large"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); - }, + }, + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingOne(editor, range); }, - { - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); - }, + }, + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingTwo(editor, range); }, - { - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); - }, + }, + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingThree(editor, range); }, - { - title: "To-do List", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleTaskList(editor, range) - }, + }, + { + title: "To-do List", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleTaskList(editor, range); }, - { - title: "Bullet List", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); - }, + }, + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleBulletList(editor, range); }, - { - title: "Divider", - description: "Visually divide blocks", - searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run(); - }, + }, + { + title: "Divider", + description: "Visually divide blocks", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }: CommandProps) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, - { - title: "Table", - description: "Create a Table", - searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); - }, + }, + { + title: "Table", + description: "Create a Table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }: CommandProps) => { + insertTableCommand(editor, range); }, - { - title: "Numbered List", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range) - }, + }, + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleOrderedList(editor, range); }, - { - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => - toggleBlockquote(editor, range) + }, + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }: CommandProps) => + toggleBlockquote(editor, range), + }, + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + title: "Image", + description: "Upload an image from your computer.", + searchTerms: ["photo", "picture", "media"], + icon: , + command: ({ editor, range }: CommandProps) => { + insertImageCommand(editor, uploadFile, setIsSubmitting, range); }, - { - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - command: ({ editor, range }: CommandProps) => - editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), - }, - { - title: "Image", - description: "Upload an image from your computer.", - searchTerms: ["photo", "picture", "media"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertImageCommand(editor, uploadFile, setIsSubmitting, range); - }, - }, - ].filter((item) => { - if (typeof query === "string" && query.length > 0) { - const search = query.toLowerCase(); - return ( - item.title.toLowerCase().includes(search) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) - ); - } - return true; - }); + }, + ].filter((item) => { + if (typeof query === "string" && query.length > 0) { + const search = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(search) || + item.description.toLowerCase().includes(search) || + (item.searchTerms && + item.searchTerms.some((term: string) => term.includes(search))) + ); + } + return true; + }); export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { const containerHeight = container.offsetHeight; @@ -213,7 +247,7 @@ const CommandList = ({ command(item); } }, - [command, items] + [command, items], ); useEffect(() => { @@ -266,11 +300,17 @@ const CommandList = ({ - ))} - + )} + { + setIsLinkSelectorOpen(!isLinkSelectorOpen); + setIsNodeSelectorOpen(false); + }} + /> +
+ {items.map((item, index) => ( + + ))} +
+ + )} ); }; diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx index 7dddc9d98..f8f1f17bb 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx @@ -1,7 +1,19 @@ import { Editor } from "@tiptap/core"; import { Check, Trash } from "lucide-react"; -import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; -import { cn, isValidHttpUrl, setLinkEditor, unsetLinkEditor, } from "@plane/editor-core"; +import { + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useRef, +} from "react"; +import { + cn, + isValidHttpUrl, + setLinkEditor, + unsetLinkEditor, +} from "@plane/editor-core"; interface LinkSelectorProps { editor: Editor; @@ -9,7 +21,11 @@ interface LinkSelectorProps { setIsOpen: Dispatch>; } -export const LinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const LinkSelector: FC = ({ + editor, + isOpen, + setIsOpen, +}) => { const inputRef = useRef(null); const onLinkSubmit = useCallback(() => { @@ -31,7 +47,7 @@ export const LinkSelector: FC = ({ editor, isOpen, setIsOpen type="button" className={cn( "flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100", - { "bg-custom-background-100": isOpen } + { "bg-custom-background-100": isOpen }, )} onClick={() => { setIsOpen(!isOpen); diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx index b8b7ffc58..965e7a42e 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx @@ -1,10 +1,16 @@ -import { BulletListItem, cn, CodeItem, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, NumberedListItem, QuoteItem, TodoListItem } from "@plane/editor-core"; -import { Editor } from "@tiptap/react"; import { - Check, - ChevronDown, - TextIcon, -} from "lucide-react"; + BulletListItem, + cn, + CodeItem, + HeadingOneItem, + HeadingThreeItem, + HeadingTwoItem, + NumberedListItem, + QuoteItem, + TodoListItem, +} from "@plane/editor-core"; +import { Editor } from "@tiptap/react"; +import { Check, ChevronDown, TextIcon } from "lucide-react"; import { Dispatch, FC, SetStateAction } from "react"; import { BubbleMenuItem } from "."; @@ -15,12 +21,17 @@ interface NodeSelectorProps { setIsOpen: Dispatch>; } -export const NodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const NodeSelector: FC = ({ + editor, + isOpen, + setIsOpen, +}) => { const items: BubbleMenuItem[] = [ { name: "Text", icon: TextIcon, - command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(), + command: () => + editor.chain().focus().toggleNode("paragraph", "paragraph").run(), isActive: () => editor.isActive("paragraph") && !editor.isActive("bulletList") && @@ -63,7 +74,10 @@ export const NodeSelector: FC = ({ editor, isOpen, setIsOpen }} className={cn( "flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100", - { "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name } + { + "bg-custom-primary-100/5 text-custom-text-100": + activeItem.name === item.name, + }, )} >
diff --git a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx index dc058cf89..f6ccdddf5 100644 --- a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx @@ -1,6 +1,11 @@ -"use client" -import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core'; -import * as React from 'react'; +"use client"; +import { + EditorContainer, + EditorContentWrapper, + getEditorClassNames, + useReadOnlyEditor, +} from "@plane/editor-core"; +import * as React from "react"; interface IRichTextReadOnlyEditor { value: string; @@ -8,6 +13,7 @@ interface IRichTextReadOnlyEditor { noBorder?: boolean; borderOnFocus?: boolean; customClassName?: string; + mentionHighlights?: string[]; } interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor { @@ -26,29 +32,39 @@ const RichReadOnlyEditor = ({ customClassName, value, forwardedRef, + mentionHighlights, }: RichTextReadOnlyEditorProps) => { const editor = useReadOnlyEditor({ value, forwardedRef, + mentionHighlights, }); - const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName }); + const editorClassNames = getEditorClassNames({ + noBorder, + borderOnFocus, + customClassName, + }); if (!editor) return null; return (
- +
-
+ ); }; -const RichReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - -)); +const RichReadOnlyEditorWithRef = React.forwardRef< + EditorHandle, + IRichTextReadOnlyEditor +>((props, ref) => ); RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; -export { RichReadOnlyEditor , RichReadOnlyEditorWithRef }; +export { RichReadOnlyEditor, RichReadOnlyEditorWithRef }; diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 12a7ab8c8..11e970d0e 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,5 +1,6 @@ { "name": "eslint-config-custom", + "private": true, "version": "0.13.2", "main": "index.js", "license": "MIT", diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 1336379b7..286dfc3b6 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -3,6 +3,7 @@ "version": "0.13.2", "description": "common tailwind configuration across monorepo", "main": "index.js", + "private": true, "devDependencies": { "@tailwindcss/typography": "^0.5.9", "autoprefixer": "^10.4.14", diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index b877dc7c0..5aef561e9 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -12,7 +12,7 @@ module.exports = { "./pages/**/*.tsx", "./ui/**/*.tsx", "../packages/ui/**/*.{js,ts,jsx,tsx}", - "../packages/editor/**/*.{js,ts,jsx,tsx}", + "../packages/editor/**/src/**/*.{js,ts,jsx,tsx}", ], }, theme: { @@ -174,7 +174,7 @@ module.exports = { DEFAULT: convertToRGB("--color-sidebar-border-200"), }, }, - backdrop: "#131313", + backdrop: "rgba(0, 0, 0, 0.25)", }, }, keyframes: { diff --git a/packages/ui/package.json b/packages/ui/package.json index 3a89a5c71..72413eb7c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,5 +1,7 @@ { "name": "@plane/ui", + "description": "UI components shared across multiple apps internally", + "private": true, "version": "0.0.1", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -10,13 +12,13 @@ "dist/**" ], "scripts": { - "build": "tsup src/index.tsx --format esm,cjs --dts --external react", - "dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react", + "build": "tsup src/index.ts --format esm,cjs --dts --external react", + "dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react", "lint": "eslint src/", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, "devDependencies": { - "@types/react-color" : "^3.0.9", + "@types/react-color": "^3.0.9", "@types/node": "^20.5.2", "@types/react": "18.2.0", "@types/react-dom": "18.2.0", diff --git a/packages/ui/src/avatar/avatar-group.tsx b/packages/ui/src/avatar/avatar-group.tsx new file mode 100644 index 000000000..25a3c76fc --- /dev/null +++ b/packages/ui/src/avatar/avatar-group.tsx @@ -0,0 +1,91 @@ +import React from "react"; +// ui +import { Tooltip } from "../tooltip"; +// types +import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar"; + +type Props = { + /** + * The children of the avatar group. + * These should ideally should be `Avatar` components + */ + children: React.ReactNode; + /** + * The maximum number of avatars to display. + * If the number of children exceeds this value, the additional avatars will be replaced by a count of the remaining avatars. + * @default 2 + */ + max?: number; + /** + * Whether to show the tooltip or not + * @default true + */ + showTooltip?: boolean; + /** + * The size of the avatars + * Possible values: "sm", "md", "base", "lg" + * @default "md" + */ + size?: TAvatarSize; +}; + +export const AvatarGroup: React.FC = (props) => { + const { children, max = 2, showTooltip = true, size = "md" } = props; + + // calculate total length of avatars inside the group + const totalAvatars = React.Children.toArray(children).length; + + // if avatars are equal to max + 1, then we need to show the last avatar as well, if avatars are more than max + 1, then we need to show the count of the remaining avatars + const maxAvatarsToRender = totalAvatars <= max + 1 ? max + 1 : max; + + // slice the children to the maximum number of avatars + const avatars = React.Children.toArray(children).slice(0, maxAvatarsToRender); + + // assign the necessary props from the AvatarGroup component to the Avatar components + const avatarsWithUpdatedProps = avatars.map((avatar) => { + const updatedProps: Partial = { + showTooltip, + size, + }; + + return React.cloneElement(avatar as React.ReactElement, updatedProps); + }); + + // get size details based on the size prop + const sizeInfo = getSizeInfo(size); + + return ( +
+ {avatarsWithUpdatedProps.map((avatar, index) => ( +
+ {avatar} +
+ ))} + {maxAvatarsToRender < totalAvatars && ( + +
+ +{totalAvatars - max} +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx new file mode 100644 index 000000000..431d693c9 --- /dev/null +++ b/packages/ui/src/avatar/avatar.tsx @@ -0,0 +1,175 @@ +import React from "react"; +// ui +import { Tooltip } from "../tooltip"; + +export type TAvatarSize = "sm" | "md" | "base" | "lg" | number; + +type Props = { + /** + * The name of the avatar which will be displayed on the tooltip + */ + name?: string; + /** + * The background color if the avatar image fails to load + */ + fallbackBackgroundColor?: string; + /** + * The text to display if the avatar image fails to load + */ + fallbackText?: string; + /** + * The text color if the avatar image fails to load + */ + fallbackTextColor?: string; + /** + * Whether to show the tooltip or not + * @default true + */ + showTooltip?: boolean; + /** + * The size of the avatars + * Possible values: "sm", "md", "base", "lg" + * @default "md" + */ + size?: TAvatarSize; + /** + * The shape of the avatar + * Possible values: "circle", "square" + * @default "circle" + */ + shape?: "circle" | "square"; + /** + * The source of the avatar image + */ + src?: string; + /** + * The custom CSS class name to apply to the component + */ + className?: string; +}; + +/** + * Get the size details based on the size prop + * @param size The size of the avatar + * @returns The size details + */ +export const getSizeInfo = (size: TAvatarSize) => { + switch (size) { + case "sm": + return { + avatarSize: "h-4 w-4", + fontSize: "text-xs", + spacing: "-space-x-1", + }; + case "md": + return { + avatarSize: "h-5 w-5", + fontSize: "text-xs", + spacing: "-space-x-1", + }; + case "base": + return { + avatarSize: "h-6 w-6", + fontSize: "text-sm", + spacing: "-space-x-1.5", + }; + case "lg": + return { + avatarSize: "h-7 w-7", + fontSize: "text-sm", + spacing: "-space-x-1.5", + }; + default: + return { + avatarSize: "h-5 w-5", + fontSize: "text-xs", + spacing: "-space-x-1", + }; + } +}; + +/** + * Get the border radius based on the shape prop + * @param shape The shape of the avatar + * @returns The border radius + */ +export const getBorderRadius = (shape: "circle" | "square") => { + switch (shape) { + case "circle": + return "rounded-full"; + case "square": + return "rounded"; + default: + return "rounded-full"; + } +}; + +/** + * Check if the value is a valid number + * @param value The value to check + * @returns Whether the value is a valid number or not + */ +export const isAValidNumber = (value: any) => { + return typeof value === "number" && !isNaN(value); +}; + +export const Avatar: React.FC = (props) => { + const { + name, + fallbackBackgroundColor, + fallbackText, + fallbackTextColor, + showTooltip = true, + size = "md", + shape = "circle", + src, + className = "", + } = props; + + // get size details based on the size prop + const sizeInfo = getSizeInfo(size); + + return ( + +
+ {src ? ( + {name} + ) : ( +
+ {name ? name[0].toUpperCase() : fallbackText ?? "?"} +
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/avatar/index.ts b/packages/ui/src/avatar/index.ts new file mode 100644 index 000000000..3ccfbeca0 --- /dev/null +++ b/packages/ui/src/avatar/index.ts @@ -0,0 +1,2 @@ +export * from "./avatar-group"; +export * from "./avatar"; diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 41fe14739..94f317825 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -1,59 +1,81 @@ import * as React from "react"; // icons -import { MoveLeft } from "lucide-react"; +import { ChevronRight } from "lucide-react"; +// components +import { Tooltip } from "../tooltip"; type BreadcrumbsProps = { - onBack: () => void; children: any; }; -const Breadcrumbs = ({ onBack, children }: BreadcrumbsProps) => ( - <> -
- - {children} -
- -); - -type BreadcrumbItemProps = { - title?: string; - link?: JSX.Element; - icon?: any; - unshrinkTitle?: boolean; -}; - -const BreadcrumbItem: React.FC = ({ - title, - link, - icon, - unshrinkTitle = false, -}) => ( - <> - {link ? ( - link - ) : ( -
-

- {icon} - {title} -

+const Breadcrumbs = ({ children }: BreadcrumbsProps) => ( +
+ {React.Children.map(children, (child, index) => ( +
+ {child} + {index !== React.Children.count(children) - 1 && ( +
- )} - + ))} +
); +type Props = { + type?: "text" | "component"; + component?: React.ReactNode; + label?: string; + icon?: React.ReactNode; + link?: string; +}; +const BreadcrumbItem: React.FC = (props) => { + const { type = "text", component, label, icon, link } = props; + return ( + <> + {type != "text" ? ( +
{component}
+ ) : ( + +
  • +
    + {link ? ( + + {icon && ( +
    + {icon} +
    + )} +
    + {label} +
    +
    + ) : ( +
    + {icon && ( +
    + {icon} +
    + )} +
    + {label} +
    +
    + )} +
    +
  • +
    + )} + + ); +}; + Breadcrumbs.BreadcrumbItem = BreadcrumbItem; export { Breadcrumbs, BreadcrumbItem }; diff --git a/packages/ui/src/breadcrumbs/index.tsx b/packages/ui/src/breadcrumbs/index.ts similarity index 100% rename from packages/ui/src/breadcrumbs/index.tsx rename to packages/ui/src/breadcrumbs/index.ts diff --git a/packages/ui/src/button/button.tsx b/packages/ui/src/button/button.tsx index 138e105e0..d7377bcf8 100644 --- a/packages/ui/src/button/button.tsx +++ b/packages/ui/src/button/button.tsx @@ -58,7 +58,7 @@ const Button = React.forwardRef( )} ); - } + }, ); Button.displayName = "plane-ui-button"; diff --git a/packages/ui/src/button/helper.tsx b/packages/ui/src/button/helper.tsx index 82489c3e8..48b1fc94a 100644 --- a/packages/ui/src/button/helper.tsx +++ b/packages/ui/src/button/helper.tsx @@ -102,7 +102,7 @@ export const buttonStyling: IButtonStyling = { export const getButtonStyling = ( variant: TButtonVariant, size: TButtonSizes, - disabled: boolean = false + disabled: boolean = false, ): string => { let _variant: string = ``; const currentVariant = buttonStyling[variant]; diff --git a/packages/ui/src/button/index.tsx b/packages/ui/src/button/index.ts similarity index 100% rename from packages/ui/src/button/index.tsx rename to packages/ui/src/button/index.ts diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index 8ba95c28c..0fb4c67cf 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -35,7 +35,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState( - null + null, ); const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -46,7 +46,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { query === "" ? options : options?.filter((option) => - option.query.toLowerCase().includes(query.toLowerCase()) + option.query.toLowerCase().includes(query.toLowerCase()), ); const comboboxProps: any = { @@ -87,8 +87,8 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { when the value changes. const useAutoSizeTextArea = ( textAreaRef: HTMLTextAreaElement | null, - value: any + value: any, ) => { React.useEffect(() => { if (textAreaRef) { @@ -63,7 +63,7 @@ const TextArea = React.forwardRef( {...rest} /> ); - } + }, ); export { TextArea }; diff --git a/packages/ui/src/icons/panel-center-icon.tsx b/packages/ui/src/icons/center-panel-icon.tsx similarity index 50% rename from packages/ui/src/icons/panel-center-icon.tsx rename to packages/ui/src/icons/center-panel-icon.tsx index 4546474e4..321b61b88 100644 --- a/packages/ui/src/icons/panel-center-icon.tsx +++ b/packages/ui/src/icons/center-panel-icon.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { ISvgIcons } from "./type"; -export const FullScreenPeekIcon: React.FC = ({ +export const CenterPanelIcon: React.FC = ({ className = "text-current", ...rest }) => ( @@ -16,14 +16,18 @@ export const FullScreenPeekIcon: React.FC = ({ > ); diff --git a/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx index 0a2c46e99..53ea05906 100644 --- a/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx +++ b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx @@ -7,14 +7,19 @@ export const CircleDotFullIcon: React.FC = ({ ...rest }) => ( - + ); diff --git a/packages/ui/src/icons/modal-peek-icon.tsx b/packages/ui/src/icons/full-screen-panel-icon.tsx similarity index 93% rename from packages/ui/src/icons/modal-peek-icon.tsx rename to packages/ui/src/icons/full-screen-panel-icon.tsx index 9df00f54a..da21aca1f 100644 --- a/packages/ui/src/icons/modal-peek-icon.tsx +++ b/packages/ui/src/icons/full-screen-panel-icon.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { ISvgIcons } from "./type"; -export const ModalPeekIcon: React.FC = ({ +export const FullScreenPanelIcon: React.FC = ({ className = "text-current", ...rest }) => ( diff --git a/packages/ui/src/icons/index.tsx b/packages/ui/src/icons/index.ts similarity index 87% rename from packages/ui/src/icons/index.tsx rename to packages/ui/src/icons/index.ts index 518a4bfad..4cb4e30f7 100644 --- a/packages/ui/src/icons/index.tsx +++ b/packages/ui/src/icons/index.ts @@ -9,9 +9,9 @@ export * from "./subscribe-icon"; export * from "./external-link-icon"; export * from "./copy-icon"; export * from "./layer-stack"; -export * from "./side-peek-icon"; -export * from "./modal-peek-icon"; -export * from "./panel-center-icon"; +export * from "./side-panel-icon"; +export * from "./center-panel-icon"; +export * from "./full-screen-panel-icon"; export * from "./priority-icon"; export * from "./state"; export * from "./blocked-icon"; diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index 36c6fa18f..2c2e012e9 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -18,18 +18,21 @@ export const PriorityIcon: React.FC = ({ }) => { if (!className || className === "") className = "h-3.5 w-3.5"; + // Convert to lowercase for string comparison + const lowercasePriority = priority?.toLowerCase(); + return ( <> - {priority === "urgent" ? ( - - ) : priority === "high" ? ( - - ) : priority === "medium" ? ( - - ) : priority === "low" ? ( - + {lowercasePriority === "urgent" ? ( + + ) : lowercasePriority === "high" ? ( + + ) : lowercasePriority === "medium" ? ( + + ) : lowercasePriority === "low" ? ( + ) : ( - + )} ); diff --git a/packages/ui/src/icons/side-peek-icon.tsx b/packages/ui/src/icons/side-panel-icon.tsx similarity index 92% rename from packages/ui/src/icons/side-peek-icon.tsx rename to packages/ui/src/icons/side-panel-icon.tsx index de4103a1a..8185da768 100644 --- a/packages/ui/src/icons/side-peek-icon.tsx +++ b/packages/ui/src/icons/side-panel-icon.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { ISvgIcons } from "./type"; -export const SidePeekIcon: React.FC = ({ +export const SidePanelIcon: React.FC = ({ className = "text-current", ...rest }) => ( diff --git a/packages/ui/src/icons/state/index.tsx b/packages/ui/src/icons/state/index.ts similarity index 100% rename from packages/ui/src/icons/state/index.tsx rename to packages/ui/src/icons/state/index.ts diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.ts similarity index 90% rename from packages/ui/src/index.tsx rename to packages/ui/src/index.ts index 1cd193a65..1d75c9271 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.ts @@ -1,9 +1,10 @@ +export * from "./avatar"; +export * from "./breadcrumbs"; export * from "./button"; +export * from "./dropdowns"; export * from "./form-fields"; +export * from "./icons"; export * from "./progress"; export * from "./spinners"; -export * from "./loader"; export * from "./tooltip"; -export * from "./icons"; -export * from "./breadcrumbs"; -export * from "./dropdowns"; +export * from "./loader"; diff --git a/packages/ui/src/progress/circular-progress-indicator.tsx b/packages/ui/src/progress/circular-progress-indicator.tsx index 8ef74ea52..7386e6632 100644 --- a/packages/ui/src/progress/circular-progress-indicator.tsx +++ b/packages/ui/src/progress/circular-progress-indicator.tsx @@ -9,7 +9,7 @@ interface ICircularProgressIndicator { } export const CircularProgressIndicator: React.FC = ( - props + props, ) => { const { size = 40, percentage = 25, strokeWidth = 6, children } = props; diff --git a/packages/ui/src/progress/index.tsx b/packages/ui/src/progress/index.ts similarity index 100% rename from packages/ui/src/progress/index.tsx rename to packages/ui/src/progress/index.ts diff --git a/packages/ui/src/spinners/circular-spinner.tsx b/packages/ui/src/spinners/circular-spinner.tsx index b96188030..e7e952295 100644 --- a/packages/ui/src/spinners/circular-spinner.tsx +++ b/packages/ui/src/spinners/circular-spinner.tsx @@ -1,10 +1,23 @@ import * as React from "react"; -export const Spinner: React.FC = () => ( +export interface ISpinner extends React.SVGAttributes { + height?: string; + width?: string; + className?: string | undefined; +} + +export const Spinner: React.FC = ({ + height = "32px", + width = "32px", + className = "", + ...rest +}) => (
    - {data?.google && } + {data?.google_client_id && ( + + )}

    diff --git a/space/components/icons/index.ts b/space/components/icons/index.ts deleted file mode 100644 index 28162f591..000000000 --- a/space/components/icons/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./state-group"; diff --git a/space/components/icons/state-group/backlog-state-icon.tsx b/space/components/icons/state-group/backlog-state-icon.tsx deleted file mode 100644 index f2f62d24a..000000000 --- a/space/components/icons/state-group/backlog-state-icon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -// types -import type { Props } from "../types"; -// constants -import { issueGroupColors } from "constants/data"; - -export const BacklogStateIcon: React.FC = ({ - width = "14", - height = "14", - className, - color = issueGroupColors["backlog"], -}) => ( - - - -); diff --git a/space/components/icons/state-group/cancelled-state-icon.tsx b/space/components/icons/state-group/cancelled-state-icon.tsx deleted file mode 100644 index e244c191a..000000000 --- a/space/components/icons/state-group/cancelled-state-icon.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -// types -import type { Props } from "../types"; -// constants -import { issueGroupColors } from "constants/data"; - -export const CancelledStateIcon: React.FC = ({ - width = "14", - height = "14", - className, - color = issueGroupColors["cancelled"], -}) => ( - - - - - - - - - - - - - -); diff --git a/space/components/icons/state-group/completed-state-icon.tsx b/space/components/icons/state-group/completed-state-icon.tsx deleted file mode 100644 index 417ebbf3f..000000000 --- a/space/components/icons/state-group/completed-state-icon.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react"; -// types -import type { Props } from "../types"; -// constants -import { issueGroupColors } from "constants/data"; - -export const CompletedStateIcon: React.FC = ({ - width = "14", - height = "14", - className, - color = issueGroupColors["completed"], -}) => ( - - - - - - - - - - - - -); diff --git a/space/components/icons/state-group/index.ts b/space/components/icons/state-group/index.ts deleted file mode 100644 index 6ede38df6..000000000 --- a/space/components/icons/state-group/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./backlog-state-icon"; -export * from "./cancelled-state-icon"; -export * from "./completed-state-icon"; -export * from "./started-state-icon"; -export * from "./state-group-icon"; -export * from "./unstarted-state-icon"; diff --git a/space/components/icons/state-group/started-state-icon.tsx b/space/components/icons/state-group/started-state-icon.tsx deleted file mode 100644 index 4ebd1771f..000000000 --- a/space/components/icons/state-group/started-state-icon.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -// types -import type { Props } from "../types"; -// constants -import { issueGroupColors } from "constants/data"; - -export const StartedStateIcon: React.FC = ({ - width = "14", - height = "14", - className, - color = issueGroupColors["started"], -}) => ( - - - - - - - - - - - - -); diff --git a/space/components/icons/state-group/state-group-icon.tsx b/space/components/icons/state-group/state-group-icon.tsx deleted file mode 100644 index 1af523400..000000000 --- a/space/components/icons/state-group/state-group-icon.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// icons -import { - BacklogStateIcon, - CancelledStateIcon, - CompletedStateIcon, - StartedStateIcon, - UnstartedStateIcon, -} from "components/icons"; -import { TIssueGroupKey } from "types/issue"; - -type Props = { - stateGroup: TIssueGroupKey; - color: string; - className?: string; - height?: string; - width?: string; -}; - -export const StateGroupIcon: React.FC = ({ stateGroup, className, color, height = "12px", width = "12px" }) => { - if (stateGroup === "backlog") - return ; - else if (stateGroup === "cancelled") - return ; - else if (stateGroup === "completed") - return ; - else if (stateGroup === "started") - return ; - else return ; -}; diff --git a/space/components/icons/state-group/unstarted-state-icon.tsx b/space/components/icons/state-group/unstarted-state-icon.tsx deleted file mode 100644 index f79bc00fc..000000000 --- a/space/components/icons/state-group/unstarted-state-icon.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -// types -import type { Props } from "../types"; -// constants -import { issueGroupColors } from "constants/data"; - -export const UnstartedStateIcon: React.FC = ({ - width = "14", - height = "14", - className, - color = issueGroupColors["unstarted"], -}) => ( - - - - - - - - - - -); diff --git a/space/components/icons/types.d.ts b/space/components/icons/types.d.ts deleted file mode 100644 index f82a18147..000000000 --- a/space/components/icons/types.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type Props = { - width?: string | number; - height?: string | number; - color?: string; - className?: string; -}; diff --git a/space/components/issues/board-views/block-state.tsx b/space/components/issues/board-views/block-state.tsx index 16792c81b..2daba1226 100644 --- a/space/components/issues/board-views/block-state.tsx +++ b/space/components/issues/board-views/block-state.tsx @@ -1,3 +1,5 @@ +// ui +import { StateGroupIcon } from "@plane/ui"; // constants import { issueGroupFilter } from "constants/data"; @@ -8,7 +10,7 @@ export const IssueBlockState = ({ state }: any) => { return (

    - +
    {state?.name}
    diff --git a/space/components/issues/board-views/kanban/block.tsx b/space/components/issues/board-views/kanban/block.tsx index b4de76f2b..b2effc4ad 100644 --- a/space/components/issues/board-views/kanban/block.tsx +++ b/space/components/issues/board-views/kanban/block.tsx @@ -7,7 +7,6 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { IssueBlockPriority } from "components/issues/board-views/block-priority"; import { IssueBlockState } from "components/issues/board-views/block-state"; -import { IssueBlockLabels } from "components/issues/board-views/block-labels"; import { IssueBlockDueDate } from "components/issues/board-views/block-due-date"; // interfaces import { IIssue } from "types/issue"; @@ -37,7 +36,7 @@ export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => { }; return ( -
    +
    {/* id */}
    {projectStore?.project?.identifier}-{issue?.sequence_id} diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx index 5645e2b3b..8f2f28496 100644 --- a/space/components/issues/board-views/kanban/header.tsx +++ b/space/components/issues/board-views/kanban/header.tsx @@ -4,8 +4,8 @@ import { observer } from "mobx-react-lite"; import { IIssueState } from "types/issue"; // constants import { issueGroupFilter } from "constants/data"; -// icons -import { StateGroupIcon } from "components/icons"; +// ui +import { StateGroupIcon } from "@plane/ui"; // mobx hook import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; @@ -18,11 +18,11 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { if (stateGroup === null) return <>; return ( -
    -
    - +
    +
    +
    -
    {state?.name}
    +
    {state?.name}
    {store.issue.getCountOfIssuesByState(state.id)} diff --git a/space/components/issues/board-views/list/block.tsx b/space/components/issues/board-views/list/block.tsx index bdf39b84f..57011d033 100644 --- a/space/components/issues/board-views/list/block.tsx +++ b/space/components/issues/board-views/list/block.tsx @@ -38,10 +38,10 @@ export const IssueListBlock: FC<{ issue: IIssue }> = observer((props) => { }; return ( -
    -
    +
    +
    {/* id */} -
    +
    {projectStore?.project?.identifier}-{issue?.sequence_id}
    {/* name */} diff --git a/space/components/issues/board-views/list/header.tsx b/space/components/issues/board-views/list/header.tsx index 83312e7b9..fc7e5ef61 100644 --- a/space/components/issues/board-views/list/header.tsx +++ b/space/components/issues/board-views/list/header.tsx @@ -2,8 +2,8 @@ import { observer } from "mobx-react-lite"; // interfaces import { IIssueState } from "types/issue"; -// icons -import { StateGroupIcon } from "components/icons"; +// ui +import { StateGroupIcon } from "@plane/ui"; // constants import { issueGroupFilter } from "constants/data"; // mobx hook @@ -18,12 +18,12 @@ export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { if (stateGroup === null) return <>; return ( -
    -
    - +
    +
    +
    -
    {state?.name}
    -
    {store.issue.getCountOfIssuesByState(state.id)}
    +
    {state?.name}
    +
    {store.issue.getCountOfIssuesByState(state.id)}
    ); }); diff --git a/space/components/issues/board-views/list/index.tsx b/space/components/issues/board-views/list/index.tsx index 1c6900dd9..d6b11d026 100644 --- a/space/components/issues/board-views/list/index.tsx +++ b/space/components/issues/board-views/list/index.tsx @@ -27,9 +27,7 @@ export const IssueListView = observer(() => { ))}
    ) : ( -
    - No Issues are available. -
    +
    No issues.
    )}
    ))} diff --git a/space/components/issues/filters-render/state/filter-state-block.tsx b/space/components/issues/filters-render/state/filter-state-block.tsx index 8445386a4..9b6447cb6 100644 --- a/space/components/issues/filters-render/state/filter-state-block.tsx +++ b/space/components/issues/filters-render/state/filter-state-block.tsx @@ -29,7 +29,7 @@ export const RenderIssueState = observer(({ state }: { state: IIssueState }) => return (
    - + {/* */}
    {state?.name}
    { return { display: ( - {stateGroup && } + {/* {stateGroup && } */} {state.name} ), diff --git a/space/components/issues/peek-overview/comment/add-comment.tsx b/space/components/issues/peek-overview/comment/add-comment.tsx index c7e7a468e..9878fd00a 100644 --- a/space/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/components/issues/peek-overview/comment/add-comment.tsx @@ -7,7 +7,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useToast from "hooks/use-toast"; // ui -import { SecondaryButton } from "components/ui"; +import { Button } from "@plane/ui"; // types import { Comment } from "types/issue"; // components @@ -29,7 +29,6 @@ export const AddComment: React.FC = observer((props) => { const { handleSubmit, control, - setValue, watch, formState: { isSubmitting }, reset, @@ -77,6 +76,7 @@ export const AddComment: React.FC = observer((props) => { handleSubmit(onSubmit)(e); }); }} + cancelUploadImage={fileService.cancelUpload} uploadFile={fileService.getUploadFileFunction(workspace_slug as string)} deleteFile={fileService.deleteImage} ref={editorRef} @@ -85,27 +85,30 @@ export const AddComment: React.FC = observer((props) => { ? watch("comment_html") : value } - customClassName="p-3 min-h-[50px] shadow-sm" + customClassName="p-2" + editorContentCustomClassNames="min-h-[35px]" debouncedUpdatesEnabled={false} onChange={(comment_json: Object, comment_html: string) => { onChange(comment_html); }} + submitButton={ + + } /> )} /> - - { - userStore.requiredLogin(() => { - handleSubmit(onSubmit)(e); - }); - }} - type="submit" - disabled={isSubmitting || disabled} - className="mt-2" - > - {isSubmitting ? "Adding..." : "Comment"} -
    ); diff --git a/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/components/issues/peek-overview/comment/comment-detail-card.tsx index b4754f098..ab09b2490 100644 --- a/space/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -15,6 +15,7 @@ import { timeAgo } from "helpers/date-time.helper"; import { Comment } from "types/issue"; // services import fileService from "services/file.service"; +import useEditorSuggestions from "hooks/use-editor-suggestions"; type Props = { workspaceSlug: string; @@ -28,6 +29,8 @@ export const CommentCard: React.FC = observer((props) => { // states const [isEditing, setIsEditing] = useState(false); + const mentionsConfig = useEditorSuggestions(); + const editorRef = React.useRef(null); const showEditorRef = React.useRef(null); @@ -100,6 +103,7 @@ export const CommentCard: React.FC = observer((props) => { render={({ field: { onChange, value } }) => ( = observer((props) => { ref={showEditorRef} value={comment.comment_html} customClassName="text-xs border border-custom-border-200 bg-custom-background-100" + mentionHighlights={mentionsConfig.mentionHighlights} />
    diff --git a/space/components/issues/peek-overview/issue-details.tsx b/space/components/issues/peek-overview/issue-details.tsx index 24dd65651..d84103388 100644 --- a/space/components/issues/peek-overview/issue-details.tsx +++ b/space/components/issues/peek-overview/issue-details.tsx @@ -2,27 +2,36 @@ import { IssueReactions } from "components/issues/peek-overview"; import { RichReadOnlyEditor } from "@plane/rich-text-editor"; // types import { IIssue } from "types/issue"; +import useEditorSuggestions from "hooks/use-editor-suggestions"; type Props = { issueDetails: IIssue; }; -export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => ( -
    -
    - {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} -
    -

    {issueDetails.name}

    - {issueDetails.description_html !== "" && issueDetails.description_html !== "

    " && ( -

    " - : issueDetails.description_html} - customClassName="p-3 min-h-[50px] shadow-sm" /> - )} - -
    -); +export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => { + const mentionConfig = useEditorSuggestions(); + + return ( +
    +
    + {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} +
    +

    {issueDetails.name}

    + {issueDetails.description_html !== "" && issueDetails.description_html !== "

    " && ( +

    " + : issueDetails.description_html + } + customClassName="p-3 min-h-[50px] shadow-sm" + mentionHighlights={mentionConfig.mentionHighlights} + /> + )} + +
    + ); +}; diff --git a/space/components/issues/peek-overview/issue-properties.tsx b/space/components/issues/peek-overview/issue-properties.tsx index f7ccab18f..54e9c4f6a 100644 --- a/space/components/issues/peek-overview/issue-properties.tsx +++ b/space/components/issues/peek-overview/issue-properties.tsx @@ -1,5 +1,7 @@ // hooks import useToast from "hooks/use-toast"; +// ui +import { StateGroupIcon } from "@plane/ui"; // icons import { Icon } from "components/ui"; // helpers @@ -63,7 +65,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod {stateGroup && (
    - + {addSpaceIfCamelCase(state?.name ?? "")}
    diff --git a/space/constants/data.ts b/space/constants/data.ts index 29d411342..bb9030696 100644 --- a/space/constants/data.ts +++ b/space/constants/data.ts @@ -1,6 +1,5 @@ // interfaces import { - IIssueBoardViews, // priority TIssuePriorityKey, // state groups @@ -8,14 +7,6 @@ import { IIssuePriorityFilters, IIssueGroup, } from "types/issue"; -// icons -import { - BacklogStateIcon, - UnstartedStateIcon, - StartedStateIcon, - CompletedStateIcon, - CancelledStateIcon, -} from "components/icons"; // all issue views export const issueViews: any = { @@ -92,35 +83,30 @@ export const issueGroups: IIssueGroup[] = [ title: "Backlog", color: "#d9d9d9", className: `text-[#d9d9d9] bg-[#d9d9d9]/10`, - icon: BacklogStateIcon, }, { key: "unstarted", title: "Unstarted", color: "#3f76ff", className: `text-[#3f76ff] bg-[#3f76ff]/10`, - icon: UnstartedStateIcon, }, { key: "started", title: "Started", color: "#f59e0b", className: `text-[#f59e0b] bg-[#f59e0b]/10`, - icon: StartedStateIcon, }, { key: "completed", title: "Completed", color: "#16a34a", className: `text-[#16a34a] bg-[#16a34a]/10`, - icon: CompletedStateIcon, }, { key: "cancelled", title: "Cancelled", color: "#dc2626", className: `text-[#dc2626] bg-[#dc2626]/10`, - icon: CancelledStateIcon, }, ]; diff --git a/space/hooks/use-editor-suggestions.tsx b/space/hooks/use-editor-suggestions.tsx new file mode 100644 index 000000000..0659121b7 --- /dev/null +++ b/space/hooks/use-editor-suggestions.tsx @@ -0,0 +1,13 @@ +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +const useEditorSuggestions = () => { + const { mentionsStore }: RootStore = useMobxStore(); + + return { + // mentionSuggestions: mentionsStore.mentionSuggestions, + mentionHighlights: mentionsStore.mentionHighlights, + }; +}; + +export default useEditorSuggestions; diff --git a/space/pages/_app.tsx b/space/pages/_app.tsx index 33c137d41..7e00f4d8c 100644 --- a/space/pages/_app.tsx +++ b/space/pages/_app.tsx @@ -4,6 +4,8 @@ import { ThemeProvider } from "next-themes"; // styles import "styles/globals.css"; import "styles/editor.css"; +import "styles/table.css"; + // contexts import { ToastContextProvider } from "contexts/toast.context"; // mobx store provider diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts index 713cda3da..09a6989ef 100644 --- a/space/services/app-config.service.ts +++ b/space/services/app-config.service.ts @@ -3,12 +3,13 @@ import APIService from "services/api.service"; // helper import { API_BASE_URL } from "helpers/common.helper"; -export interface IEnvConfig { - github: string; - google: string; - github_app_name: string | null; +export interface IAppConfig { email_password_login: boolean; + google_client_id: string | null; + github_app_name: string | null; + github_client_id: string | null; magic_login: boolean; + slack_client_id: string | null; } export class AppConfigService extends APIService { @@ -16,7 +17,7 @@ export class AppConfigService extends APIService { super(API_BASE_URL); } - async envConfig(): Promise { + async envConfig(): Promise { return this.get("/api/configs/", { headers: { "Content-Type": "application/json", diff --git a/space/services/file.service.ts b/space/services/file.service.ts index 1ba4cd4d2..ce1f50e70 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -1,5 +1,6 @@ import APIService from "services/api.service"; import { API_BASE_URL } from "helpers/common.helper"; +import axios from "axios"; interface UnSplashImage { id: string; @@ -26,25 +27,37 @@ interface UnSplashImageUrls { } class FileService extends APIService { + private cancelSource: any; + constructor() { super(API_BASE_URL); this.uploadFile = this.uploadFile.bind(this); this.deleteImage = this.deleteImage.bind(this); + this.cancelUpload = this.cancelUpload.bind(this); } async uploadFile(workspaceSlug: string, file: FormData): Promise { + this.cancelSource = axios.CancelToken.source(); return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, { headers: { ...this.getHeaders(), "Content-Type": "multipart/form-data", }, + cancelToken: this.cancelSource.token, }) .then((response) => response?.data) .catch((error) => { - throw error?.response?.data; + if (axios.isCancel(error)) { + console.log(error.message); + } else { + throw error?.response?.data; + } }); } + cancelUpload() { + this.cancelSource.cancel("Upload cancelled"); + } getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { return async (file: File) => { const formData = new FormData(); diff --git a/space/store/mentions.store.ts b/space/store/mentions.store.ts new file mode 100644 index 000000000..e890681d3 --- /dev/null +++ b/space/store/mentions.store.ts @@ -0,0 +1,43 @@ +import { IMentionHighlight } from "@plane/lite-text-editor"; +import { RootStore } from "./root"; +import { computed, makeObservable } from "mobx"; + +export interface IMentionsStore { + // mentionSuggestions: IMentionSuggestion[]; + mentionHighlights: IMentionHighlight[]; +} + +export class MentionsStore implements IMentionsStore { + // root store + rootStore; + + constructor(_rootStore: RootStore) { + // rootStore + this.rootStore = _rootStore; + + makeObservable(this, { + mentionHighlights: computed, + // mentionSuggestions: computed + }); + } + + // get mentionSuggestions() { + // const projectMembers = this.rootStore.project.project. + + // const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({ + // id: member.member.id, + // type: "User", + // title: member.member.display_name, + // subtitle: member.member.email ?? "", + // avatar: member.member.avatar, + // redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`, + // })) + + // return suggestions + // } + + get mentionHighlights() { + const user = this.rootStore.user.currentUser; + return user ? [user.id] : []; + } +} diff --git a/space/store/root.ts b/space/store/root.ts index 6b87020ef..22b951d20 100644 --- a/space/store/root.ts +++ b/space/store/root.ts @@ -5,6 +5,7 @@ import UserStore from "./user"; import IssueStore, { IIssueStore } from "./issue"; import ProjectStore, { IProjectStore } from "./project"; import IssueDetailStore, { IIssueDetailStore } from "./issue_details"; +import { IMentionsStore, MentionsStore } from "./mentions.store"; enableStaticRendering(typeof window === "undefined"); @@ -13,11 +14,13 @@ export class RootStore { issue: IIssueStore; issueDetails: IIssueDetailStore; project: IProjectStore; + mentionsStore: IMentionsStore; constructor() { this.user = new UserStore(this); this.issue = new IssueStore(this); this.project = new ProjectStore(this); this.issueDetails = new IssueDetailStore(this); + this.mentionsStore = new MentionsStore(this); } } diff --git a/space/store/user.ts b/space/store/user.ts index cec2d340f..e2b6428ef 100644 --- a/space/store/user.ts +++ b/space/store/user.ts @@ -2,7 +2,6 @@ import { observable, action, computed, makeObservable, runInAction } from "mobx"; // service import UserService from "services/user.service"; -import { ActorDetail } from "types/issue"; // types import { IUser } from "types/user"; diff --git a/space/styles/globals.css b/space/styles/globals.css index 1782b9b81..ea04bcda6 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -199,9 +199,9 @@ --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ - --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */ - --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */ - --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ + --color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ } } diff --git a/space/styles/table.css b/space/styles/table.css new file mode 100644 index 000000000..8a47a8c59 --- /dev/null +++ b/space/styles/table.css @@ -0,0 +1,210 @@ +.tableWrapper { + overflow-x: auto; + padding: 2px; + width: fit-content; + max-width: 100%; +} + +.tableWrapper table { + border-collapse: collapse; + table-layout: fixed; + margin: 0; + margin-bottom: 3rem; + border: 1px solid rgba(var(--color-border-200)); + width: 100%; +} + +.tableWrapper table td, +.tableWrapper table th { + min-width: 1em; + border: 1px solid rgba(var(--color-border-200)); + padding: 10px 15px; + vertical-align: top; + box-sizing: border-box; + position: relative; + transition: background-color 0.3s ease; + + > * { + margin-bottom: 0; + } +} + +.tableWrapper table td > *, +.tableWrapper table th > * { + margin: 0 !important; + padding: 0.25rem 0 !important; +} + +.tableWrapper table td.has-focus, +.tableWrapper table th.has-focus { + box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important; +} + +.tableWrapper table th { + font-weight: bold; + text-align: left; + background-color: rgba(var(--color-primary-100)); +} + +.tableWrapper table th * { + font-weight: 600; +} + +.tableWrapper table .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: rgba(var(--color-primary-300), 0.1); + pointer-events: none; +} + +.tableWrapper table .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 4px; + z-index: 99; + background-color: rgba(var(--color-primary-400)); + pointer-events: none; +} + +.tableWrapper .tableControls { + position: absolute; +} + +.tableWrapper .tableControls .columnsControl, +.tableWrapper .tableControls .rowsControl { + transition: opacity ease-in 100ms; + position: absolute; + z-index: 99; + display: flex; + justify-content: center; + align-items: center; +} + +.tableWrapper .tableControls .columnsControl { + height: 20px; + transform: translateY(-50%); +} + +.tableWrapper .tableControls .columnsControl .columnsControlDiv { + color: white; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E"); + width: 30px; + height: 15px; +} + +.tableWrapper .tableControls .rowsControl { + width: 20px; + transform: translateX(-50%); +} + +.tableWrapper .tableControls .rowsControl .rowsControlDiv { + color: white; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E"); + height: 30px; + width: 15px; +} + +.tableWrapper .tableControls .rowsControlDiv { + background-color: rgba(var(--color-primary-100)); + border: 1px solid rgba(var(--color-border-200)); + border-radius: 2px; + background-size: 1.25rem; + background-repeat: no-repeat; + background-position: center; + transition: + transform ease-out 100ms, + background-color ease-out 100ms; + outline: none; + box-shadow: #000 0px 2px 4px; + cursor: pointer; +} + +.tableWrapper .tableControls .columnsControlDiv { + background-color: rgba(var(--color-primary-100)); + border: 1px solid rgba(var(--color-border-200)); + border-radius: 2px; + background-size: 1.25rem; + background-repeat: no-repeat; + background-position: center; + transition: + transform ease-out 100ms, + background-color ease-out 100ms; + outline: none; + box-shadow: #000 0px 2px 4px; + cursor: pointer; +} +.tableWrapper .tableControls .tableToolbox, +.tableWrapper .tableControls .tableColorPickerToolbox { + border: 1px solid rgba(var(--color-border-300)); + background-color: rgba(var(--color-background-100)); + padding: 0.25rem; + display: flex; + flex-direction: column; + width: 200px; + gap: 0.25rem; +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem { + background-color: rgba(var(--color-background-100)); + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + padding: 0.1rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem:hover, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover { + background-color: rgba(var(--color-background-100), 0.5); +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer, +.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer { + border: 1px solid rgba(var(--color-border-300)); + border-radius: 3px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, +.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { + width: 2rem; + height: 2rem; +} + +.tableToolbox { + background-color: rgba(var(--color-background-100)); +} + +.tableWrapper .tableControls .tableToolbox .toolboxItem .label, +.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label { + font-size: 0.85rem; + color: rgba(var(--color-text-300)); +} + +.resize-cursor .tableWrapper .tableControls .rowsControl, +.tableWrapper.controls--disabled .tableControls .rowsControl, +.resize-cursor .tableWrapper .tableControls .columnsControl, +.tableWrapper.controls--disabled .tableControls .columnsControl { + opacity: 0; + pointer-events: none; +} diff --git a/space/types/issue.ts b/space/types/issue.ts index 206327fcd..4b76c75e8 100644 --- a/space/types/issue.ts +++ b/space/types/issue.ts @@ -24,7 +24,6 @@ export interface IIssueGroup { title: TIssueGroupTitle; color: string; className: string; - icon: React.FC; } export interface IIssue { @@ -40,7 +39,12 @@ export interface IIssue { sequence_id: number; start_date: any; state: string; - state_detail: any; + state_detail: { + id: string; + name: string; + group: TIssueGroupKey; + color: string; + }; target_date: any; votes: IVote[]; } diff --git a/turbo.json b/turbo.json index 62afa90bb..ac462d08b 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,6 @@ "NEXT_PUBLIC_DEPLOY_URL", "NEXT_PUBLIC_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_ENVIRONMENT", - "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH", "NEXT_PUBLIC_TRACK_EVENTS", diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index 9ea5b7df2..fc140f632 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -32,8 +32,7 @@ export const GithubLoginButton: FC = (props) => { }, [code, gitCode, handleSignIn]); useEffect(() => { - const origin = - typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; setLoginCallBackURL(`${origin}/` as any); }, []); diff --git a/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx b/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx index e58d81666..3544ae46d 100644 --- a/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx +++ b/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx @@ -49,10 +49,7 @@ export const CustomTooltip: React.FC = ({ datum, analytics, params }) => : "" }`} > - {params.segment === "assignees__id" - ? renderAssigneeName(tooltipValue.toString()) - : tooltipValue} - : + {params.segment === "assignees__id" ? renderAssigneeName(tooltipValue.toString()) : tooltipValue}: {datum.value}
    diff --git a/web/components/analytics/custom-analytics/graph/index.tsx b/web/components/analytics/custom-analytics/graph/index.tsx index 9c9fff9ad..0908609c9 100644 --- a/web/components/analytics/custom-analytics/graph/index.tsx +++ b/web/components/analytics/custom-analytics/graph/index.tsx @@ -3,7 +3,7 @@ import { BarDatum } from "@nivo/bar"; // components import { CustomTooltip } from "./custom-tooltip"; // ui -import { BarGraph } from "components/ui"; +import { BarGraph, Tooltip } from "components/ui"; // helpers import { findStringWithMostCharacters } from "helpers/array.helper"; import { generateBarColor, generateDisplayName } from "helpers/analytics.helper"; @@ -72,42 +72,51 @@ export const AnalyticsGraph: React.FC = ({ analytics, barGraphData, param renderTick: params.x_axis === "assignees__id" ? (datum) => { - const avatar = analytics.extras.assignee_details?.find( - (a) => a?.assignees__display_name === datum?.value - )?.assignees__avatar; + const assignee = analytics.extras.assignee_details?.find((a) => a?.assignees__id === datum?.value); - if (avatar && avatar !== "") + if (assignee?.assignees__avatar && assignee?.assignees__avatar !== "") return ( - - - + + + + + ); else return ( - - - - {params.x_axis === "assignees__id" - ? datum.value && datum.value !== "None" - ? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase() - : "?" - : datum.value && datum.value !== "None" - ? `${datum.value}`.toUpperCase()[0] - : "?"} - - + + + + + {params.x_axis === "assignees__id" + ? datum.value && datum.value !== "None" + ? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase() + : "?" + : datum.value && datum.value !== "None" + ? `${datum.value}`.toUpperCase()[0] + : "?"} + + + ); } : (datum) => ( - - + + 7 ? "end" : "middle"}`} + fontSize={10} + fill="rgb(var(--color-text-200))" + className={`${barGraphData.data.length > 7 ? "-rotate-45" : ""}`} + > {generateDisplayName(datum.value, analytics, params, "x_axis")} diff --git a/web/components/analytics/custom-analytics/select-bar.tsx b/web/components/analytics/custom-analytics/select-bar.tsx index ef289f5c8..195aa2e56 100644 --- a/web/components/analytics/custom-analytics/select-bar.tsx +++ b/web/components/analytics/custom-analytics/select-bar.tsx @@ -66,6 +66,7 @@ export const CustomAnalyticsSelectBar: React.FC = observer((props) => { onChange(val); }} + params={params} /> )} /> diff --git a/web/components/analytics/custom-analytics/select/x-axis.tsx b/web/components/analytics/custom-analytics/select/x-axis.tsx index 99e872340..66582a1e9 100644 --- a/web/components/analytics/custom-analytics/select/x-axis.tsx +++ b/web/components/analytics/custom-analytics/select/x-axis.tsx @@ -3,16 +3,19 @@ import { useRouter } from "next/router"; // ui import { CustomSelect } from "@plane/ui"; // types -import { TXAxisValues } from "types"; +import { IAnalyticsParams, TXAxisValues } from "types"; // constants import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; type Props = { value: TXAxisValues; onChange: (val: string) => void; + params: IAnalyticsParams; }; -export const SelectXAxis: React.FC = ({ value, onChange }) => { +export const SelectXAxis: React.FC = (props) => { + const { value, onChange, params } = props; + const router = useRouter(); const { cycleId, moduleId } = router.query; @@ -25,6 +28,7 @@ export const SelectXAxis: React.FC = ({ value, onChange }) => { maxHeight="lg" > {ANALYTICS_X_AXIS_VALUES.map((item) => { + if (params.segment === item.value) return null; if (cycleId && item.value === "issue_cycle__cycle_id") return null; if (moduleId && item.value === "issue_module__module_id") return null; diff --git a/web/components/analytics/custom-analytics/table.tsx b/web/components/analytics/custom-analytics/table.tsx index 5c7bb1146..1d578b0d8 100644 --- a/web/components/analytics/custom-analytics/table.tsx +++ b/web/components/analytics/custom-analytics/table.tsx @@ -69,7 +69,6 @@ export const AnalyticsTable: React.FC = ({ analytics, barGraphData, param }`} > {params.x_axis === "priority" ? ( - // TODO: incorrect priority icon being rendered ) : ( = ({ defaultAnalytics }) => ( keys={["count"]} height="250px" colors={() => `#f97316`} - customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => - d.count > 0 ? d.count : 50 - )} + customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => (d.count > 0 ? d.count : 50))} tooltip={(datum) => { const assignee = defaultAnalytics.pending_issue_user.find( (a) => a.assignees__id === `${datum.indexValue}` diff --git a/web/components/auth-screens/not-authorized-view.tsx b/web/components/auth-screens/not-authorized-view.tsx index 32d96eff2..509bc1e84 100644 --- a/web/components/auth-screens/not-authorized-view.tsx +++ b/web/components/auth-screens/not-authorized-view.tsx @@ -31,9 +31,7 @@ export const NotAuthorizedView: React.FC = ({ actionButton, type }) => { alt="ProjectSettingImg" />
    -

    - Oops! You are not authorized to view this page -

    +

    Oops! You are not authorized to view this page

    {user ? ( diff --git a/web/components/auth-screens/project/join-project.tsx b/web/components/auth-screens/project/join-project.tsx index 0f5ccd297..5713e2ad8 100644 --- a/web/components/auth-screens/project/join-project.tsx +++ b/web/components/auth-screens/project/join-project.tsx @@ -1,23 +1,20 @@ import { useState } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; -import { mutate } from "swr"; -// services -import { ProjectService } from "services/project"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // ui import { Button } from "@plane/ui"; // icons import { ClipboardList } from "lucide-react"; // images import JoinProjectImg from "public/auth/project-not-authorized.svg"; -// fetch-keys -import { USER_PROJECT_VIEW } from "constants/fetch-keys"; - -const projectService = new ProjectService(); export const JoinProject: React.FC = () => { const [isJoiningProject, setIsJoiningProject] = useState(false); + const { project: projectStore } = useMobxStore(); + const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -25,16 +22,10 @@ export const JoinProject: React.FC = () => { if (!workspaceSlug || !projectId) return; setIsJoiningProject(true); - projectService - .joinProject(workspaceSlug as string, [projectId as string]) - .then(async () => { - await mutate(USER_PROJECT_VIEW(projectId.toString())); - setIsJoiningProject(false); - }) - .catch((err) => { - console.error(err); - setIsJoiningProject(false); - }); + + projectStore.joinProject(workspaceSlug.toString(), [projectId.toString()]).finally(() => { + setIsJoiningProject(false); + }); }; return ( diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 30a7c6251..1083073da 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -1,7 +1,9 @@ import React, { useState } from "react"; - +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // component -import { CustomSelect, ToggleSwitch } from "@plane/ui"; +import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui"; import { SelectMonthModal } from "components/automation"; // icon import { ArchiveRestore } from "lucide-react"; @@ -11,15 +13,21 @@ import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { IProject } from "types"; type Props = { - projectDetails: IProject | undefined; handleChange: (formData: Partial) => Promise; - disabled?: boolean; }; -export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleChange, disabled = false }) => { +const initialValues: Partial = { archive_in: 1 }; + +export const AutoArchiveAutomation: React.FC = observer((props) => { + const { handleChange } = props; + // states const [monthModal, setmonthModal] = useState(false); - const initialValues: Partial = { archive_in: 1 }; + const { user: userStore, project: projectStore } = useMobxStore(); + + const projectDetails = projectStore.currentProjectDetails; + const userRole = userStore.currentProjectRole; + return ( <> = ({ projectDetails, handleC handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
    +
    @@ -38,7 +46,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC

    Auto-archive closed issues

    - Plane will auto archive issues that have been completed or canceled. + Plane will auto archive issues that have been completed or cancelled.

    @@ -48,46 +56,52 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 }) } size="sm" - disabled={disabled} + disabled={userRole !== 20} />
    - {projectDetails?.archive_in !== 0 && ( -
    -
    -
    Auto-archive issues that are closed for
    -
    - { - handleChange({ archive_in: val }); - }} - input - width="w-full" - disabled={disabled} - > - <> - {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} - - ))} + {projectDetails ? ( + projectDetails.archive_in !== 0 && ( +
    +
    +
    Auto-archive issues that are closed for
    +
    + { + handleChange({ archive_in: val }); + }} + input + width="w-full" + disabled={userRole !== 20} + > + <> + {PROJECT_AUTOMATION_MONTHS.map((month) => ( + + {month.label} + + ))} - - - + + + +
    -
    + ) + ) : ( + + + )}
    ); -}; +}); diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index f5e284ebf..1f0ef1c31 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -1,42 +1,32 @@ import React, { useState } from "react"; -import useSWR from "swr"; -import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // component import { SelectMonthModal } from "components/automation"; -import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon } from "@plane/ui"; +import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui"; // icons import { ArchiveX } from "lucide-react"; -// services -import { ProjectStateService } from "services/project"; -// constants -import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; -import { STATES_LIST } from "constants/fetch-keys"; // types import { IProject } from "types"; -// helper -import { getStatesList } from "helpers/state.helper"; +// fetch keys +import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; type Props = { - projectDetails: IProject | undefined; handleChange: (formData: Partial) => Promise; - disabled?: boolean; }; -const projectStateService = new ProjectStateService(); - -export const AutoCloseAutomation: React.FC = ({ projectDetails, handleChange, disabled = false }) => { +export const AutoCloseAutomation: React.FC = observer((props) => { + const { handleChange } = props; + // states const [monthModal, setmonthModal] = useState(false); - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { user: userStore, project: projectStore, projectState: projectStateStore } = useMobxStore(); - const { data: stateGroups } = useSWR( - workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => projectStateService.getStates(workspaceSlug as string, projectId as string) - : null - ); - const states = getStatesList(stateGroups); + const userRole = userStore.currentProjectRole; + const projectDetails = projectStore.currentProjectDetails; + // const stateGroups = projectStateStore.groupedProjectStates ?? undefined; + const states = projectStateStore.projectStates; const options = states ?.filter((state) => state.group === "cancelled") @@ -53,7 +43,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha const multipleOptions = (options ?? []).length > 1; - const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null; + const defaultState = states?.find((s) => s.group === "cancelled")?.id || null; const selectedOption = states?.find((s) => s.id === projectDetails?.default_state ?? defaultState); const currentDefaultState = states?.find((s) => s.id === defaultState); @@ -72,7 +62,6 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
    @@ -82,7 +71,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha

    Auto-close issues

    - Plane will automatically close issue that haven’t been completed or canceled. + Plane will automatically close issue that haven{"'"}t been completed or cancelled.

    @@ -94,87 +83,93 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha : handleChange({ close_in: 0, default_state: null }) } size="sm" - disabled={disabled} + disabled={userRole !== 20} />
    - {projectDetails?.close_in !== 0 && ( -
    -
    -
    -
    Auto-close issues that are inactive for
    -
    - { - handleChange({ close_in: val }); - }} - input - width="w-full" - disabled={disabled} - > - <> - {PROJECT_AUTOMATION_MONTHS.map((month) => ( - - {month.label} - - ))} - - - + {projectDetails ? ( + projectDetails.close_in !== 0 && ( +
    +
    +
    +
    Auto-close issues that are inactive for
    +
    + { + handleChange({ close_in: val }); + }} + input + width="w-full" + disabled={userRole !== 20} + > + <> + {PROJECT_AUTOMATION_MONTHS.map((month) => ( + + {month.label} + + ))} + + + +
    -
    -
    -
    Auto-close Status
    -
    - - {selectedOption ? ( - - ) : currentDefaultState ? ( - - ) : ( - - )} - {selectedOption?.name - ? selectedOption.name - : currentDefaultState?.name ?? State} -
    - } - onChange={(val: string) => { - handleChange({ default_state: val }); - }} - options={options} - disabled={!multipleOptions} - width="w-full" - input - /> +
    +
    Auto-close Status
    +
    + + {selectedOption ? ( + + ) : currentDefaultState ? ( + + ) : ( + + )} + {selectedOption?.name + ? selectedOption.name + : currentDefaultState?.name ?? State} +
    + } + onChange={(val: string) => { + handleChange({ default_state: val }); + }} + options={options} + disabled={!multipleOptions} + width="w-full" + input + /> +
    -
    + ) + ) : ( + + + )}
    ); -}; +}); diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index 88095b251..ad53527d7 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -54,7 +54,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -68,7 +68,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -144,10 +144,10 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen,
    - -
    diff --git a/web/components/command-palette/change-interface-theme.tsx b/web/components/command-palette/change-interface-theme.tsx index 5e5651d93..0b899f811 100644 --- a/web/components/command-palette/change-interface-theme.tsx +++ b/web/components/command-palette/change-interface-theme.tsx @@ -1,33 +1,36 @@ -import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; +import React, { FC, Dispatch, SetStateAction, useEffect, useState } from "react"; import { Command } from "cmdk"; -import { THEME_OPTIONS } from "constants/themes"; import { useTheme } from "next-themes"; -import useUser from "hooks/use-user"; import { Settings } from "lucide-react"; import { observer } from "mobx-react-lite"; -// mobx store +// hooks +import useToast from "hooks/use-toast"; import { useMobxStore } from "lib/mobx/store-provider"; +// constants +import { THEME_OPTIONS } from "constants/themes"; type Props = { setIsPaletteOpen: Dispatch>; }; -export const ChangeInterfaceTheme: React.FC = observer(({ setIsPaletteOpen }) => { - const store: any = useMobxStore(); - +export const ChangeInterfaceTheme: FC = observer((props) => { + const { setIsPaletteOpen } = props; + // store + const { user: userStore } = useMobxStore(); + // states const [mounted, setMounted] = useState(false); - + // hooks const { setTheme } = useTheme(); - - const { user } = useUser(); + const { setToastAlert } = useToast(); const updateUserTheme = (newTheme: string) => { - if (!user) return; setTheme(newTheme); - return store.user - .updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } }) - .then((response: any) => response) - .catch((error: any) => error); + return userStore.updateCurrentUserTheme(newTheme).catch(() => { + setToastAlert({ + title: "Failed to save user theme settings!", + type: "error", + }); + }); }; // useEffect only runs on the client, so now we can safely show the UI diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index b3a3325eb..04625f77a 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; import { Command } from "cmdk"; import { Dialog, Transition } from "@headlessui/react"; +import { observer } from "mobx-react-lite"; import { FileText, FolderPlus, @@ -16,12 +17,13 @@ import { UserMinus2, UserPlus2, } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // services import { WorkspaceService } from "services/workspace.service"; import { IssueService } from "services/issue"; // hooks import useDebounce from "hooks/use-debounce"; -import useUser from "hooks/use-user"; import useToast from "hooks/use-toast"; // components import { @@ -61,11 +63,8 @@ type Props = { const workspaceService = new WorkspaceService(); const issueService = new IssueService(); -export const CommandModal: React.FC = (props) => { +export const CommandModal: React.FC = observer((props) => { const { deleteIssue, isPaletteOpen, closePalette } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; // states const [placeholder, setPlaceholder] = useState("Type a command or search..."); const [resultsCount, setResultsCount] = useState(0); @@ -86,14 +85,19 @@ export const CommandModal: React.FC = (props) => { const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const [pages, setPages] = useState([]); + const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore(); + const user = userStore.currentUser ?? undefined; + + // router + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + const page = pages[pages.length - 1]; const debouncedSearchTerm = useDebounce(searchTerm, 500); const { setToastAlert } = useToast(); - const { user } = useUser(); - const { data: issueDetails } = useSWR( workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId @@ -144,7 +148,7 @@ export const CommandModal: React.FC = (props) => { } else { updatedAssignees.push(assignee); } - updateIssue({ assignees_list: updatedAssignees }); + updateIssue({ assignees: updatedAssignees }); }; const redirect = (path: string) => { @@ -241,7 +245,7 @@ export const CommandModal: React.FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -255,7 +259,7 @@ export const CommandModal: React.FC = (props) => { leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > -
    +
    { if (value.toLowerCase().includes(search.toLowerCase())) return 1; @@ -468,10 +472,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "c", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleCreateIssueModal(true); }} className="focus:bg-custom-background-80" > @@ -488,10 +489,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "p", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleCreateProjectModal(true); }} className="focus:outline-none" > @@ -510,10 +508,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "q", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleCreateCycleModal(true); }} className="focus:outline-none" > @@ -528,10 +523,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "m", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleCreateModuleModal(true); }} className="focus:outline-none" > @@ -546,10 +538,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "v", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleCreateViewModal(true); }} className="focus:outline-none" > @@ -564,10 +553,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "d", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleCreatePageModal(true); }} className="focus:outline-none" > @@ -621,10 +607,7 @@ export const CommandModal: React.FC = (props) => { { closePalette(); - const e = new KeyboardEvent("keydown", { - key: "h", - }); - document.dispatchEvent(e); + commandPaletteStore.toggleShortcutModal(true); }} className="focus:outline-none" > @@ -762,4 +745,4 @@ export const CommandModal: React.FC = (props) => { ); -}; +}); diff --git a/web/components/command-palette/issue/change-issue-assignee.tsx b/web/components/command-palette/issue/change-issue-assignee.tsx index d7ed90bd6..512428310 100644 --- a/web/components/command-palette/issue/change-issue-assignee.tsx +++ b/web/components/command-palette/issue/change-issue-assignee.tsx @@ -1,19 +1,19 @@ import { Dispatch, SetStateAction, useCallback, FC } from "react"; import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; import { mutate } from "swr"; import { Command } from "cmdk"; +import { Check } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // services import { IssueService } from "services/issue"; -// hooks -import useProjectMembers from "hooks/use-project-members"; -// constants -import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; // ui -import { Avatar } from "components/ui"; -// icons -import { Check } from "lucide-react"; +import { Avatar } from "@plane/ui"; // types import { IUser, IIssue } from "types"; +// constants +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; type Props = { setIsPaletteOpen: Dispatch>; @@ -24,20 +24,24 @@ type Props = { // services const issueService = new IssueService(); -export const ChangeIssueAssignee: FC = ({ setIsPaletteOpen, issue, user }) => { +export const ChangeIssueAssignee: FC = observer((props) => { + const { setIsPaletteOpen, issue, user } = props; + // router const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; - - const { members } = useProjectMembers(workspaceSlug as string, projectId as string); + // store + const { + projectMember: { projectMembers }, + } = useMobxStore(); const options = - members?.map(({ member }: any) => ({ + projectMembers?.map(({ member }) => ({ value: member.id, query: member.display_name, content: ( <>
    - + {member.display_name}
    {issue.assignees.includes(member.id) && ( @@ -79,7 +83,7 @@ export const ChangeIssueAssignee: FC = ({ setIsPaletteOpen, issue, user } ); const handleIssueAssignees = (assignee: string) => { - const updatedAssignees = issue.assignees_list ?? []; + const updatedAssignees = issue.assignees ?? []; if (updatedAssignees.includes(assignee)) { updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); @@ -87,7 +91,7 @@ export const ChangeIssueAssignee: FC = ({ setIsPaletteOpen, issue, user } updatedAssignees.push(assignee); } - updateIssue({ assignees_list: updatedAssignees }); + updateIssue({ assignees: updatedAssignees }); setIsPaletteOpen(false); }; @@ -104,4 +108,4 @@ export const ChangeIssueAssignee: FC = ({ setIsPaletteOpen, issue, user } ))} ); -}; +}); diff --git a/web/components/command-palette/issue/change-issue-state.tsx b/web/components/command-palette/issue/change-issue-state.tsx index 03f495603..688aeb49f 100644 --- a/web/components/command-palette/issue/change-issue-state.tsx +++ b/web/components/command-palette/issue/change-issue-state.tsx @@ -1,9 +1,6 @@ import React, { Dispatch, SetStateAction, useCallback } from "react"; - import { useRouter } from "next/router"; - import useSWR, { mutate } from "swr"; - // cmdk import { Command } from "cmdk"; // services @@ -13,8 +10,6 @@ import { ProjectStateService } from "services/project"; import { Spinner, StateGroupIcon } from "@plane/ui"; // icons import { Check } from "lucide-react"; -// helpers -import { getStatesList } from "helpers/state.helper"; // types import { IUser, IIssue } from "types"; // fetch keys @@ -34,11 +29,10 @@ export const ChangeIssueState: React.FC = ({ setIsPaletteOpen, issue, use const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; - const { data: stateGroups, mutate: mutateIssueDetails } = useSWR( + const { data: states, mutate: mutateStates } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null ); - const states = getStatesList(stateGroups); const submitChanges = useCallback( async (formData: Partial) => { @@ -60,14 +54,14 @@ export const ChangeIssueState: React.FC = ({ setIsPaletteOpen, issue, use await issueService .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) .then(() => { - mutateIssueDetails(); + mutateStates(); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }) .catch((e) => { console.error(e); }); }, - [workspaceSlug, issueId, projectId, mutateIssueDetails, user] + [workspaceSlug, issueId, projectId, mutateStates, user] ); const handleIssueState = (stateId: string) => { diff --git a/web/components/command-palette/shortcuts-modal.tsx b/web/components/command-palette/shortcuts-modal.tsx index 85d2b4fe4..40317013c 100644 --- a/web/components/command-palette/shortcuts-modal.tsx +++ b/web/components/command-palette/shortcuts-modal.tsx @@ -1,5 +1,4 @@ -import { FC, useEffect, useState, Dispatch, SetStateAction, Fragment } from "react"; -// headless ui +import { FC, useEffect, useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; // icons import { Command, Search, X } from "lucide-react"; @@ -68,7 +67,7 @@ export const ShortcutsModal: FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -82,8 +81,8 @@ export const ShortcutsModal: FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - -
    + +
    = ({ {description &&

    {description}

    }
    {primaryButton && ( - - {primaryButton.icon} + )} {secondaryButton}
    diff --git a/web/components/common/product-updates-modal.tsx b/web/components/common/product-updates-modal.tsx index aa4618d3a..b09a44db0 100644 --- a/web/components/common/product-updates-modal.tsx +++ b/web/components/common/product-updates-modal.tsx @@ -37,7 +37,7 @@ export const ProductUpdatesModal: React.FC = ({ isOpen, setIsOpen }) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -51,7 +51,7 @@ export const ProductUpdatesModal: React.FC = ({ isOpen, setIsOpen }) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    Product Updates diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index b44447fb9..1712dd53e 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -89,6 +89,7 @@ const LabelPill = ({ labelId }: { labelId: string }) => { /> ); }; + const EstimatePoint = ({ point }: { point: string }) => { const { estimateValue, isEstimateActive } = useEstimateOption(Number(point)); const currentPoint = Number(point) + 1; diff --git a/web/components/core/filters/date-filter-modal.tsx b/web/components/core/filters/date-filter-modal.tsx index 66c4f4771..0266ae8f4 100644 --- a/web/components/core/filters/date-filter-modal.tsx +++ b/web/components/core/filters/date-filter-modal.tsx @@ -62,7 +62,7 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -75,7 +75,7 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    = ({ title, handleClose, isOpen, o )}
    - -
    diff --git a/web/components/core/filters/filters-list.tsx b/web/components/core/filters/filters-list.tsx deleted file mode 100644 index f92958504..000000000 --- a/web/components/core/filters/filters-list.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React from "react"; - -// icons -import { PriorityIcon, StateGroupIcon } from "@plane/ui"; -// ui -import { Avatar } from "components/ui"; -// helpers -import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -// helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; -// types -import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types"; -// constants -import { STATE_GROUP_COLORS } from "constants/state"; -import { X } from "lucide-react"; - -type Props = { - filters: Partial; - setFilters: (updatedFilter: Partial) => void; - clearAllFilters: (...args: any) => void; - labels: IIssueLabels[] | undefined; - members: IUserLite[] | undefined; - states: IState[] | undefined; -}; - -export const FiltersList: React.FC = ({ filters, setFilters, clearAllFilters, labels, members, states }) => { - if (!filters) return <>; - - const nullFilters = Object.keys(filters).filter((key) => filters[key as keyof IIssueFilterOptions] === null); - - return ( -
    - {Object.keys(filters).map((filterKey) => { - const key = filterKey as keyof typeof filters; - - if (filters[key] === null) return null; - - return ( -
    - - {key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}: - - {filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? ( - None - ) : Array.isArray(filters[key]) ? ( -
    -
    - {key === "state" - ? filters.state?.map((stateId: string) => { - const state = states?.find((s) => s.id === stateId); - - return ( -

    - - - - {state?.name ?? ""} - - setFilters({ - state: filters.state?.filter((s: any) => s !== stateId), - }) - } - > - - -

    - ); - }) - : key === "state_group" - ? filters.state_group?.map((stateGroup) => { - const group = stateGroup as TStateGroups; - - return ( -

    - - - - {group} - - setFilters({ - state_group: filters.state_group?.filter((g) => g !== group), - }) - } - > - - -

    - ); - }) - : key === "priority" - ? filters.priority?.map((priority: any) => ( -

    - - - - {priority === "null" ? "None" : priority} - - setFilters({ - priority: filters.priority?.filter((p: any) => p !== priority), - }) - } - > - - -

    - )) - : key === "assignees" - ? filters.assignees?.map((memberId: string) => { - const member = members?.find((m) => m.id === memberId); - - return ( -
    - - {member?.display_name} - - setFilters({ - subscriber: filters.subscriber?.filter((p: any) => p !== memberId), - }) - } - > - - -
    - ); - }) - : key === "created_by" - ? filters.created_by?.map((memberId: string) => { - const member = members?.find((m) => m.id === memberId); - - return ( -
    - - {member?.display_name} - - setFilters({ - created_by: filters.created_by?.filter((p: any) => p !== memberId), - }) - } - > - - -
    - ); - }) - : key === "labels" - ? filters.labels?.map((labelId: string) => { - const label = labels?.find((l) => l.id === labelId); - - if (!label) return null; - const color = label.color !== "" ? label.color : "#0f172a"; - return ( -
    -
    - {label.name} - - setFilters({ - labels: filters.labels?.filter((l: any) => l !== labelId), - }) - } - > - - -
    - ); - }) - : key === "start_date" - ? filters.start_date?.map((date: string) => { - if (filters.start_date && filters.start_date.length <= 0) return null; - - const splitDate = date.split(";"); - - return ( -
    -
    - - {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} - - - setFilters({ - start_date: filters.start_date?.filter((d: any) => d !== date), - }) - } - > - - -
    - ); - }) - : key === "target_date" - ? filters.target_date?.map((date: string) => { - if (filters.target_date && filters.target_date.length <= 0) return null; - - const splitDate = date.split(";"); - - return ( -
    -
    - - {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} - - - setFilters({ - target_date: filters.target_date?.filter((d: any) => d !== date), - }) - } - > - - -
    - ); - }) - : (filters[key] as any)?.join(", ")} - -
    -
    - ) : ( -
    - {filters[key as keyof typeof filters]} - -
    - )} -
    - ); - })} - {Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && ( - - )} -
    - ); -}; diff --git a/web/components/core/filters/index.ts b/web/components/core/filters/index.ts index 035e1bb16..28e36d3e6 100644 --- a/web/components/core/filters/index.ts +++ b/web/components/core/filters/index.ts @@ -1,4 +1,2 @@ export * from "./date-filter-modal"; export * from "./date-filter-select"; -export * from "./filters-list"; -export * from "./workspace-filters-list"; diff --git a/web/components/core/filters/workspace-filters-list.tsx b/web/components/core/filters/workspace-filters-list.tsx deleted file mode 100644 index b2c2ef1b8..000000000 --- a/web/components/core/filters/workspace-filters-list.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import { FC } from "react"; -// icons -import { X } from "lucide-react"; -import { PriorityIcon, StateGroupIcon } from "@plane/ui"; -// ui -import { Avatar } from "components/ui"; -// helpers -import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; -// helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; -// types -import { IIssueLabels, IProject, IUserLite, IWorkspaceIssueFilterOptions, TStateGroups } from "types"; -// constants -import { STATE_GROUP_COLORS } from "constants/state"; - -type Props = { - filters: Partial; - setFilters: (updatedFilter: Partial) => void; - clearAllFilters: (...args: any) => void; - labels: IIssueLabels[] | undefined; - members: IUserLite[] | undefined; - stateGroup: string[] | undefined; - project?: IProject[] | undefined; -}; - -export const WorkspaceFiltersList: FC = (props) => { - const { filters, setFilters, clearAllFilters, labels, members, project } = props; - - if (!filters) return <>; - - const nullFilters = Object.keys(filters).filter((key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null); - - return ( -
    - {Object.keys(filters).map((filterKey) => { - const key = filterKey as keyof typeof filters; - - if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null; - - return ( -
    - - {key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}: - - {filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? ( - None - ) : Array.isArray(filters[key]) ? ( -
    -
    - {key === "state_group" - ? filters.state_group?.map((stateGroup) => { - const group = stateGroup as TStateGroups; - - return ( -

    - - - - {group} - - setFilters({ - state_group: filters.state_group?.filter((g) => g !== group), - }) - } - > - - -

    - ); - }) - : key === "priority" - ? filters.priority?.map((priority: any) => ( -

    - - - - {priority === "null" ? "None" : priority} - - setFilters({ - priority: filters.priority?.filter((p: any) => p !== priority), - }) - } - > - - -

    - )) - : key === "assignees" - ? filters.assignees?.map((memberId: string) => { - const member = members?.find((m) => m.id === memberId); - return ( -
    - - {member?.display_name} - - setFilters({ - assignees: filters.assignees?.filter((p: any) => p !== memberId), - }) - } - > - - -
    - ); - }) - : key === "subscriber" - ? filters.subscriber?.map((memberId: string) => { - const member = members?.find((m) => m.id === memberId); - - return ( -
    - - {member?.display_name} - - setFilters({ - assignees: filters.assignees?.filter((p: any) => p !== memberId), - }) - } - > - - -
    - ); - }) - : key === "created_by" - ? filters.created_by?.map((memberId: string) => { - const member = members?.find((m) => m.id === memberId); - - return ( -
    - - {member?.display_name} - - setFilters({ - created_by: filters.created_by?.filter((p: any) => p !== memberId), - }) - } - > - - -
    - ); - }) - : key === "labels" - ? filters.labels?.map((labelId: string) => { - const label = labels?.find((l) => l.id === labelId); - - if (!label) return null; - const color = label.color !== "" ? label.color : "#0f172a"; - return ( -
    -
    - {label.name} - - setFilters({ - labels: filters.labels?.filter((l: any) => l !== labelId), - }) - } - > - - -
    - ); - }) - : key === "start_date" - ? filters.start_date?.map((date: string) => { - if (filters.start_date && filters.start_date.length <= 0) return null; - - const splitDate = date.split(";"); - - return ( -
    -
    - - {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} - - - setFilters({ - start_date: filters.start_date?.filter((d: any) => d !== date), - }) - } - > - - -
    - ); - }) - : key === "target_date" - ? filters.target_date?.map((date: string) => { - if (filters.target_date && filters.target_date.length <= 0) return null; - - const splitDate = date.split(";"); - - return ( -
    -
    - - {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} - - - setFilters({ - target_date: filters.target_date?.filter((d: any) => d !== date), - }) - } - > - - -
    - ); - }) - : key === "project" - ? filters.project?.map((projectId) => { - const currentProject = project?.find((p) => p.id === projectId); - return ( -

    - {currentProject?.name} - - setFilters({ - project: filters.project?.filter((p) => p !== projectId), - }) - } - > - - -

    - ); - }) - : (filters[key] as any)?.join(", ")} - -
    -
    - ) : ( -
    - {filters[key as keyof typeof filters]} - -
    - )} -
    - ); - })} - {Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && ( - - )} -
    - ); -}; diff --git a/web/components/core/index.ts b/web/components/core/index.ts index 6989b05c0..ff0fabc4e 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -2,7 +2,6 @@ export * from "./filters"; export * from "./modals"; export * from "./sidebar"; export * from "./theme"; -export * from "./views"; export * from "./activity"; export * from "./reaction-selector"; export * from "./image-picker-popover"; diff --git a/web/components/core/modals/bulk-delete-issues-modal.tsx b/web/components/core/modals/bulk-delete-issues-modal.tsx index 6392fc557..f1c951385 100644 --- a/web/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import useSWR from "swr"; // react hook form import { SubmitHandler, useForm } from "react-hook-form"; // headless ui @@ -9,7 +9,6 @@ import { Combobox, Dialog, Transition } from "@headlessui/react"; import { IssueService } from "services/issue"; // hooks import useToast from "hooks/use-toast"; -import useIssuesView from "hooks/use-issues-view"; // ui import { Button, LayersIcon } from "@plane/ui"; // icons @@ -17,15 +16,7 @@ import { Search } from "lucide-react"; // types import { IUser, IIssue } from "types"; // fetch keys -import { - CYCLE_DETAILS, - CYCLE_ISSUES_WITH_PARAMS, - MODULE_DETAILS, - MODULE_ISSUES_WITH_PARAMS, - PROJECT_ISSUES_LIST, - PROJECT_ISSUES_LIST_WITH_PARAMS, - VIEW_ISSUES, -} from "constants/fetch-keys"; +import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; type FormInput = { delete_issue_ids: string[]; @@ -43,7 +34,7 @@ export const BulkDeleteIssuesModal: React.FC = (props) => { const { isOpen, onClose, user } = props; // router const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, projectId } = router.query; // states const [query, setQuery] = useState(""); // fetching project issues. @@ -53,9 +44,6 @@ export const BulkDeleteIssuesModal: React.FC = (props) => { ); const { setToastAlert } = useToast(); - const { displayFilters, params } = useIssuesView(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { order_by, group_by, ...viewGanttParams } = params; const { handleSubmit, @@ -89,14 +77,6 @@ export const BulkDeleteIssuesModal: React.FC = (props) => { if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids]; - const ganttFetchKey = cycleId - ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) - : moduleId - ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) - : viewId - ? VIEW_ISSUES(viewId.toString(), viewGanttParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); - await issueService .bulkDeleteIssues( workspaceSlug as string, @@ -113,17 +93,6 @@ export const BulkDeleteIssuesModal: React.FC = (props) => { message: "Issues deleted successfully!", }); - if (displayFilters.layout === "gantt_chart") mutate(ganttFetchKey); - else { - if (cycleId) { - mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)); - mutate(CYCLE_DETAILS(cycleId.toString())); - } else if (moduleId) { - mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); - mutate(MODULE_DETAILS(moduleId as string)); - } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)); - } - handleClose(); }) .catch(() => @@ -147,7 +116,7 @@ export const BulkDeleteIssuesModal: React.FC = (props) => { return ( setQuery("")} appear> -
    +
    = (props) => { leaveTo="opacity-0 scale-95" > -
    +
    { @@ -242,10 +211,10 @@ export const BulkDeleteIssuesModal: React.FC = (props) => { {filteredIssues.length > 0 && (
    - -
    diff --git a/web/components/core/modals/existing-issues-list-modal.tsx b/web/components/core/modals/existing-issues-list-modal.tsx index 799968791..6b11617ad 100644 --- a/web/components/core/modals/existing-issues-list-modal.tsx +++ b/web/components/core/modals/existing-issues-list-modal.tsx @@ -1,30 +1,16 @@ import React, { useEffect, useState } from "react"; - import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -// headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; +import { Rocket, Search, X } from "lucide-react"; // services import { ProjectService } from "services/project"; // hooks import useToast from "hooks/use-toast"; -import useIssuesView from "hooks/use-issues-view"; import useDebounce from "hooks/use-debounce"; // ui import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; -// icons -import { Rocket, Search, X } from "lucide-react"; // types import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types"; -// fetch-keys -import { - CYCLE_DETAILS, - CYCLE_ISSUES_WITH_PARAMS, - MODULE_DETAILS, - MODULE_ISSUES_WITH_PARAMS, -} from "constants/fetch-keys"; type Props = { isOpen: boolean; @@ -53,12 +39,10 @@ export const ExistingIssuesListModal: React.FC = ({ const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { workspaceSlug, projectId } = router.query; const { setToastAlert } = useToast(); - const { params } = useIssuesView(); - const handleClose = () => { onClose(); setSearchTerm(""); @@ -81,16 +65,6 @@ export const ExistingIssuesListModal: React.FC = ({ await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false)); - if (cycleId) { - mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); - mutate(CYCLE_DETAILS(cycleId as string)); - } - - if (moduleId) { - mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); - mutate(MODULE_DETAILS(moduleId as string)); - } - handleClose(); setToastAlert({ @@ -128,7 +102,7 @@ export const ExistingIssuesListModal: React.FC = ({ leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -141,7 +115,7 @@ export const ExistingIssuesListModal: React.FC = ({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + { @@ -288,10 +262,10 @@ export const ExistingIssuesListModal: React.FC = ({ {selectedIssues.length > 0 && (
    - -
    diff --git a/web/components/core/modals/gpt-assistant-modal.tsx b/web/components/core/modals/gpt-assistant-modal.tsx index 672fc9a05..b98b1bc1c 100644 --- a/web/components/core/modals/gpt-assistant-modal.tsx +++ b/web/components/core/modals/gpt-assistant-modal.tsx @@ -203,10 +203,10 @@ export const GptAssistantModal: React.FC = ({ )}
    - -
    diff --git a/web/components/core/modals/image-upload-modal.tsx b/web/components/core/modals/image-upload-modal.tsx index e4cdb16d6..5640d62f6 100644 --- a/web/components/core/modals/image-upload-modal.tsx +++ b/web/components/core/modals/image-upload-modal.tsx @@ -106,7 +106,7 @@ export const ImageUploadModal: React.FC = observer((props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -120,7 +120,7 @@ export const ImageUploadModal: React.FC = observer((props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    Upload Image @@ -175,15 +175,15 @@ export const ImageUploadModal: React.FC = observer((props) => {

    -
    - -
    diff --git a/web/components/core/modals/link-modal.tsx b/web/components/core/modals/link-modal.tsx index e2833f61f..02a216f06 100644 --- a/web/components/core/modals/link-modal.tsx +++ b/web/components/core/modals/link-modal.tsx @@ -76,7 +76,7 @@ export const LinkModal: FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -90,7 +90,7 @@ export const LinkModal: FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -149,10 +149,10 @@ export const LinkModal: FC = (props) => {
    - - @@ -153,7 +143,7 @@ export const ActiveCycleDetails: React.FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { + cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -166,7 +156,7 @@ export const ActiveCycleDetails: React.FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { + cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -306,7 +296,11 @@ export const ActiveCycleDetails: React.FC = (props) => { {cycle.assignees.length > 0 && (
    - + + {cycle.assignees.map((assignee) => ( + + ))} +
    )}
    @@ -406,7 +400,11 @@ export const ActiveCycleDetails: React.FC = (props) => {
    {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
    - + + {issue.assignee_details.map((assignee: any) => ( + + ))} +
    ) : ( "" @@ -473,7 +471,7 @@ export const ActiveCycleDetails: React.FC = (props) => {
    = (props) => {
    ); -}; +}); diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle-stats.tsx index e26b44803..1367d74e0 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle-stats.tsx @@ -1,16 +1,14 @@ import React, { Fragment } from "react"; - -// headless ui import { Tab } from "@headlessui/react"; // hooks import useLocalStorage from "hooks/use-local-storage"; // components import { SingleProgressStats } from "components/core"; // ui -import { Avatar } from "components/ui"; +import { Avatar } from "@plane/ui"; // types import { ICycle } from "types"; -// types + type Props = { cycle: ICycle; }; @@ -69,28 +67,18 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { Labels - {cycle.total_issues > 0 ? ( + {cycle && cycle.total_issues > 0 ? ( - - {cycle.distribution.assignees.map((assignee, index) => { + + {cycle.distribution?.assignees?.map((assignee, index) => { if (assignee.assignee_id) return ( - + + {assignee.display_name}
    } @@ -105,13 +93,7 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { title={
    - User + User
    No assignee
    @@ -122,11 +104,8 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ); })} - - {cycle.distribution.labels.map((label, index) => ( + + {cycle.distribution?.labels?.map((label, index) => ( = observer(({ projectId, workspa {peekCycle && (
    = (props) => { const isCompleted = cycleStatus === "completed"; const endDate = new Date(cycle.end_date ?? ""); const startDate = new Date(cycle.start_date ?? ""); + const isDateValid = cycle.start_date || cycle.end_date; const router = useRouter(); @@ -68,9 +65,7 @@ export const CyclesBoardCard: FC = (props) => { ? cycleTotalIssues === 0 ? "0 Issue" : cycleTotalIssues === cycle.completed_issues - ? cycleTotalIssues > 1 - ? `${cycleTotalIssues} Issues` - : `${cycleTotalIssues} Issue` + ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` : `${cycle.completed_issues}/${cycleTotalIssues} Issues` : "0 Issue"; @@ -92,7 +87,7 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { + cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -105,7 +100,7 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { + cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -157,34 +152,32 @@ export const CyclesBoardCard: FC = (props) => { -
    -
    -
    - - +
    +
    + + + + + {cycle.name} + +
    +
    + {currentCycle && ( + + {currentCycle.value === "current" + ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}` + : `${currentCycle.label}`} - - {cycle.name} - -
    -
    - {currentCycle && ( - - {currentCycle.value === "current" - ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}` - : `${currentCycle.label}`} - - )} - -
    + )} +
    @@ -197,7 +190,11 @@ export const CyclesBoardCard: FC = (props) => { {cycle.assignees.length > 0 && (
    - + + {cycle.assignees.map((assignee) => ( + + ))} +
    )} @@ -225,10 +222,14 @@ export const CyclesBoardCard: FC = (props) => {
    - - {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} - {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} - + {isDateValid ? ( + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + + ) : ( + No due date + )}
    {cycle.is_favorite ? ( @@ -67,4 +67,4 @@ export const CyclesBoard: FC = (props) => { )} ); -}; +}); diff --git a/web/components/cycles/cycles-gantt.tsx b/web/components/cycles/cycles-gantt.tsx deleted file mode 100644 index 7b370ba30..000000000 --- a/web/components/cycles/cycles-gantt.tsx +++ /dev/null @@ -1 +0,0 @@ -export const CycleGantt = () => <>; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 96efceddb..f93e428e7 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -8,9 +8,8 @@ import { useMobxStore } from "lib/mobx/store-provider"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; -import { AssigneesList } from "components/ui"; // ui -import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon } from "@plane/ui"; +import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar } from "@plane/ui"; // icons import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; // helpers @@ -88,7 +87,7 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { + cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -101,7 +100,7 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle.id).catch(() => { + cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -150,15 +149,17 @@ export const CyclesListItem: FC = (props) => { projectId={projectId} /> - +
    {isCompleted ? ( - {`!`} - ) : progress === 100 ? ( - + progress === 100 ? ( + + ) : ( + {`!`} + ) ) : ( {`${progress}%`} )} @@ -207,7 +208,11 @@ export const CyclesListItem: FC = (props) => {
    {cycle.assignees.length > 0 ? ( - + + {cycle.assignees.map((assignee) => ( + + ))} + ) : ( @@ -226,7 +231,7 @@ export const CyclesListItem: FC = (props) => { )} - + {!isCompleted && ( <> @@ -238,7 +243,7 @@ export const CyclesListItem: FC = (props) => { - Delete module + Delete cycle diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx index 03698f1d8..0cff682af 100644 --- a/web/components/cycles/cycles-list.tsx +++ b/web/components/cycles/cycles-list.tsx @@ -1,7 +1,9 @@ import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // components import { CyclePeekOverview, CyclesListItem } from "components/cycles"; - // ui import { Loader } from "@plane/ui"; // types @@ -14,9 +16,11 @@ export interface ICyclesList { projectId: string; } -export const CyclesList: FC = (props) => { +export const CyclesList: FC = observer((props) => { const { cycles, filter, workspaceSlug, projectId } = props; + const { commandPalette: commandPaletteStore } = useMobxStore(); + return ( <> {cycles ? ( @@ -53,12 +57,7 @@ export const CyclesList: FC = (props) => { @@ -75,4 +74,4 @@ export const CyclesList: FC = (props) => { )} ); -}; +}); diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index f0640dec6..a4522f711 100644 --- a/web/components/cycles/cycles-view.tsx +++ b/web/components/cycles/cycles-view.tsx @@ -6,7 +6,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; // ui components -import { Loader } from "components/ui"; +import { Loader } from "@plane/ui"; // types import { TCycleLayout } from "types"; @@ -15,7 +15,7 @@ export interface ICyclesView { layout: TCycleLayout; workspaceSlug: string; projectId: string; - peekCycle: string; + peekCycle: string | undefined; } export const CyclesView: FC = observer((props) => { @@ -25,21 +25,28 @@ export const CyclesView: FC = observer((props) => { const { cycle: cycleStore } = useMobxStore(); // api call to fetch cycles list - const { isLoading } = useSWR( + useSWR( workspaceSlug && projectId && filter ? `CYCLES_LIST_${projectId}_${filter}` : null, workspaceSlug && projectId && filter ? () => cycleStore.fetchCycles(workspaceSlug, projectId, filter) : null ); - const cyclesList = cycleStore.cycles?.[projectId]; + const cyclesList = + filter === "completed" + ? cycleStore.projectCompletedCycles + : filter === "draft" + ? cycleStore.projectDraftCycles + : filter === "upcoming" + ? cycleStore.projectUpcomingCycles + : cycleStore.projectCycles; return ( <> {layout === "list" && ( <> - {!isLoading ? ( + {cyclesList ? ( ) : ( - + @@ -50,7 +57,7 @@ export const CyclesView: FC = observer((props) => { {layout === "board" && ( <> - {!isLoading ? ( + {cyclesList ? ( = observer((props) => { peekCycle={peekCycle} /> ) : ( - + @@ -70,7 +77,7 @@ export const CyclesView: FC = observer((props) => { {layout === "gantt" && ( <> - {!isLoading ? ( + {cyclesList ? ( ) : ( diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index 02d6126fe..f5024b3a2 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -3,7 +3,7 @@ import { Dialog, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; import { AlertTriangle } from "lucide-react"; // components -import { DangerButton, SecondaryButton } from "components/ui"; +import { Button } from "@plane/ui"; // hooks import useToast from "hooks/use-toast"; // types @@ -70,7 +70,7 @@ export const CycleDeleteModal: React.FC = observer((props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -84,7 +84,7 @@ export const CycleDeleteModal: React.FC = observer((props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -101,10 +101,13 @@ export const CycleDeleteModal: React.FC = observer((props) => {

    - Cancel - + + +
    diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index b262edb15..492337c4a 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -135,11 +135,11 @@ export const CycleForm: React.FC = (props) => {
    -
    - - -
    - - - - )} - - - ); -}; diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index f56ddcf18..3288dfe0b 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -15,8 +15,8 @@ import { SidebarProgressStats } from "components/core"; import ProgressChart from "components/core/sidebar/progress-chart"; import { CycleDeleteModal } from "components/cycles/delete-modal"; // ui -import { Avatar, CustomRangeDatePicker } from "components/ui"; -import { CustomMenu, Loader, LayersIcon } from "@plane/ui"; +import { CustomRangeDatePicker } from "components/ui"; +import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui"; // icons import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react"; // helpers @@ -137,7 +137,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - await submitChanges({ + submitChanges({ start_date: renderDateFormat(`${watch("start_date")}`), end_date: renderDateFormat(`${watch("end_date")}`), }); @@ -211,7 +211,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - await submitChanges({ + submitChanges({ start_date: renderDateFormat(`${watch("start_date")}`), end_date: renderDateFormat(`${watch("end_date")}`), }); @@ -305,25 +305,23 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { <>
    - {peekCycle && ( - - )} +
    {!isCompleted && ( - + setCycleDeleteModal(true)}> - - Delete + + Delete cycle @@ -349,79 +347,73 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { )}
    - {({}) => ( - <> - - {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} - + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} + - - - { - if (val) { - handleStartDateChange(val); - } - }} - startDate={watch("start_date") ? `${watch("start_date")}` : null} - endDate={watch("end_date") ? `${watch("end_date")}` : null} - maxDate={new Date(`${watch("end_date")}`)} - selectsStart - /> - - - - )} + + + { + if (val) { + handleStartDateChange(val); + } + }} + startDate={watch("start_date") ? `${watch("start_date")}` : null} + endDate={watch("end_date") ? `${watch("end_date")}` : null} + maxDate={new Date(`${watch("end_date")}`)} + selectsStart + /> + + - {({}) => ( - <> - - {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} - + <> + + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + - - - { - if (val) { - handleEndDateChange(val); - } - }} - startDate={watch("start_date") ? `${watch("start_date")}` : null} - endDate={watch("end_date") ? `${watch("end_date")}` : null} - minDate={new Date(`${watch("start_date")}`)} - selectsEnd - /> - - - - )} + + + { + if (val) { + handleEndDateChange(val); + } + }} + startDate={watch("start_date") ? `${watch("start_date")}` : null} + endDate={watch("end_date") ? `${watch("end_date")}` : null} + minDate={new Date(`${watch("start_date")}`)} + selectsEnd + /> + + +
    @@ -441,7 +433,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
    - + {cycleDetails.owned_by.display_name}
    @@ -463,7 +455,10 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { {({ open }) => (
    -
    +
    Progress
    @@ -477,12 +472,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { "" )} {isStartValid && isEndValid ? ( - - +
    -
    +
    {isStartValid && isEndValid ? ( -
    +
    @@ -512,7 +502,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
    = observer((props) => { ) : ( "" )} - {cycleDetails.total_issues > 0 && ( + {cycleDetails.total_issues > 0 && cycleDetails.distribution && (
    void; - handleDeleteCycle: () => void; - handleAddToFavorites: () => void; - handleRemoveFromFavorites: () => void; -}; - -const stateGroups = [ - { - key: "backlog_issues", - title: "Backlog", - color: "#dee2e6", - }, - { - key: "unstarted_issues", - title: "Unstarted", - color: "#26b5ce", - }, - { - key: "started_issues", - title: "Started", - color: "#f7ae59", - }, - { - key: "cancelled_issues", - title: "Cancelled", - color: "#d687ff", - }, - { - key: "completed_issues", - title: "Completed", - color: "#09a953", - }, -]; - -export const SingleCycleCard: React.FC = ({ - cycle, - handleEditCycle, - handleDeleteCycle, - handleAddToFavorites, - handleRemoveFromFavorites, -}) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { setToastAlert } = useToast(); - - const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); - const isCompleted = cycleStatus === "completed"; - const endDate = new Date(cycle.end_date ?? ""); - const startDate = new Date(cycle.start_date ?? ""); - - const handleCopyText = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Cycle link copied to clipboard.", - }); - }); - }; - - const progressIndicatorData = stateGroups.map((group, index) => ({ - id: index, - name: group.title, - value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0, - color: group.color, - })); - - const groupedIssues: any = { - backlog: cycle.backlog_issues, - unstarted: cycle.unstarted_issues, - started: cycle.started_issues, - completed: cycle.completed_issues, - cancelled: cycle.cancelled_issues, - }; - - return ( -
    -
    - - -
    -
    - - - - - -

    {truncateText(cycle.name, 15)}

    -
    -
    - - - {cycleStatus === "current" ? ( - - - {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left - - ) : cycleStatus === "upcoming" ? ( - - - {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left - - ) : cycleStatus === "completed" ? ( - - {cycle.total_issues - cycle.completed_issues > 0 && ( - - - - - - )}{" "} - Completed - - ) : ( - cycleStatus - )} - - {cycle.is_favorite ? ( - - ) : ( - - )} - -
    -
    - {cycleStatus !== "draft" && ( - <> -
    - - {renderShortDateWithYearFormat(startDate)} -
    - -
    - - {renderShortDateWithYearFormat(endDate)} -
    - - )} -
    - -
    -
    -
    -
    Creator:
    -
    - {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( - {cycle.owned_by.display_name} - ) : ( - - {cycle.owned_by.display_name.charAt(0)} - - )} - {cycle.owned_by.display_name} -
    -
    -
    -
    Members:
    - {cycle.assignees.length > 0 ? ( -
    - -
    - ) : ( - "No members" - )} -
    -
    - -
    - {!isCompleted && ( - - )} - - - {!isCompleted && ( - { - e.preventDefault(); - handleDeleteCycle(); - }} - > - - - Delete cycle - - - )} - { - e.preventDefault(); - handleCopyText(); - }} - > - - - Copy cycle link - - - -
    -
    -
    -
    - - -
    - - {({ open }) => ( -
    -
    - Progress - - {Object.keys(groupedIssues).map((group, index) => ( - - - {group} -
    - } - completed={groupedIssues[group]} - total={cycle.total_issues} - /> - ))} -
    - } - position="bottom" - > -
    - -
    - - - - - -
    - - -
    -
    -
    - {stateGroups.map((group) => ( -
    -
    - -
    {group.title}
    -
    -
    - - {cycle[group.key as keyof ICycle] as number}{" "} - - -{" "} - {cycle.total_issues > 0 - ? `${Math.round( - ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 - )}%` - : "0%"} - - -
    -
    - ))} -
    -
    -
    -
    -
    -
    - )} - -
    -
    -
    - ); -}; diff --git a/web/components/cycles/single-cycle-list.tsx b/web/components/cycles/single-cycle-list.tsx deleted file mode 100644 index 8e2ad9cf1..000000000 --- a/web/components/cycles/single-cycle-list.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import React, { useEffect, useState } from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -// hooks -import useToast from "hooks/use-toast"; -// ui -import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; -// icons -import { - AlarmClock, - AlertTriangle, - ArrowRight, - CalendarDays, - LinkIcon, - Pencil, - Star, - Target, - Trash2, -} from "lucide-react"; -// helpers -import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; -import { copyTextToClipboard, truncateText } from "helpers/string.helper"; -// types -import { ICycle } from "types"; - -type TSingleStatProps = { - cycle: ICycle; - handleEditCycle: () => void; - handleDeleteCycle: () => void; - handleAddToFavorites: () => void; - handleRemoveFromFavorites: () => void; -}; - -const stateGroups = [ - { - key: "backlog_issues", - title: "Backlog", - color: "#dee2e6", - }, - { - key: "unstarted_issues", - title: "Unstarted", - color: "#26b5ce", - }, - { - key: "started_issues", - title: "Started", - color: "#f7ae59", - }, - { - key: "cancelled_issues", - title: "Cancelled", - color: "#d687ff", - }, - { - key: "completed_issues", - title: "Completed", - color: "#09a953", - }, -]; - -type progress = { - progress: number; -}; - -function RadialProgressBar({ progress }: progress) { - const [circumference, setCircumference] = useState(0); - - useEffect(() => { - const radius = 40; - const circumference = 2 * Math.PI * radius; - setCircumference(circumference); - }, []); - - const progressOffset = ((100 - progress) / 100) * circumference; - - return ( -
    - - - - -
    - ); -} - -export const SingleCycleList: React.FC = ({ - cycle, - handleEditCycle, - handleDeleteCycle, - handleAddToFavorites, - handleRemoveFromFavorites, -}) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { setToastAlert } = useToast(); - - const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); - const isCompleted = cycleStatus === "completed"; - const endDate = new Date(cycle.end_date ?? ""); - const startDate = new Date(cycle.start_date ?? ""); - - const handleCopyText = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Cycle link copied to clipboard.", - }); - }); - }; - - const progressIndicatorData = stateGroups.map((group, index) => ({ - id: index, - name: group.title, - value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0, - color: group.color, - })); - - const completedIssues = cycle.completed_issues + cycle.cancelled_issues; - - const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0; - - return ( -
    -
    - - -
    -
    -
    - -
    - -

    {truncateText(cycle.name, 60)}

    -
    -

    {cycle.description}

    -
    -
    -
    - - {cycleStatus === "current" ? ( - - - {findHowManyDaysLeft(cycle.end_date ?? new Date())} days left - - ) : cycleStatus === "upcoming" ? ( - - - {findHowManyDaysLeft(cycle.start_date ?? new Date())} days left - - ) : cycleStatus === "completed" ? ( - - {cycle.total_issues - cycle.completed_issues > 0 && ( - - - - - - )}{" "} - Completed - - ) : ( - cycleStatus - )} - - - {cycleStatus !== "draft" && ( -
    -
    - - {renderShortDateWithYearFormat(startDate)} -
    - -
    - - {renderShortDateWithYearFormat(endDate)} -
    -
    - )} - -
    - {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( - {cycle.owned_by.display_name} - ) : ( - - {cycle.owned_by.display_name.charAt(0)} - - )} -
    - - Progress - -
    - } - > - - {cycleStatus === "current" ? ( - - {cycle.total_issues > 0 ? ( - <> - - {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % - - ) : ( - No issues present - )} - - ) : cycleStatus === "upcoming" ? ( - - Yet to start - - ) : cycleStatus === "completed" ? ( - - - {Math.round(percentage)} % - - ) : ( - - - {cycleStatus} - - )} - - - {cycle.is_favorite ? ( - - ) : ( - - )} -
    - - {!isCompleted && ( - { - e.preventDefault(); - handleEditCycle(); - }} - > - - - Edit Cycle - - - )} - {!isCompleted && ( - { - e.preventDefault(); - handleDeleteCycle(); - }} - > - - - Delete cycle - - - )} - { - e.preventDefault(); - handleCopyText(); - }} - > - - - Copy cycle link - - - -
    -
    -
    -
    - - -
    -
    - ); -}; diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index 28964551b..bcbd8efef 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -1,17 +1,16 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import useSWR from "swr"; import { Dialog, Transition } from "@headlessui/react"; // services import { CycleService } from "services/cycle.service"; // hooks import useToast from "hooks/use-toast"; -import useIssuesView from "hooks/use-issues-view"; //icons import { ContrastIcon, TransferIcon } from "@plane/ui"; import { AlertCircle, Search, X } from "lucide-react"; // fetch-key -import { CYCLE_ISSUES_WITH_PARAMS, INCOMPLETE_CYCLES_LIST } from "constants/fetch-keys"; +import { INCOMPLETE_CYCLES_LIST } from "constants/fetch-keys"; // types import { ICycle } from "types"; //helper @@ -30,15 +29,12 @@ export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; - const { params } = useIssuesView(); - const { setToastAlert } = useToast(); const transferIssue = async (payload: any) => { await cycleService .transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload) .then(() => { - mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); setToastAlert({ type: "success", title: "Issues transfered successfully", @@ -86,7 +82,7 @@ export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -100,7 +96,7 @@ export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    diff --git a/web/components/dnd/StrictModeDroppable.tsx b/web/components/dnd/StrictModeDroppable.tsx index 9ed01d3bf..9feba79b2 100644 --- a/web/components/dnd/StrictModeDroppable.tsx +++ b/web/components/dnd/StrictModeDroppable.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; // react beautiful dnd -import { Droppable, DroppableProps } from "react-beautiful-dnd"; +import { Droppable, DroppableProps } from "@hello-pangea/dnd"; const StrictModeDroppable = ({ children, ...props }: DroppableProps) => { const [enabled, setEnabled] = useState(false); diff --git a/web/components/estimates/create-update-estimate-modal.tsx b/web/components/estimates/create-update-estimate-modal.tsx index 46be6d5c9..b3116b767 100644 --- a/web/components/estimates/create-update-estimate-modal.tsx +++ b/web/components/estimates/create-update-estimate-modal.tsx @@ -1,15 +1,11 @@ import React, { useEffect } from "react"; - import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -// react-hook-form import { Controller, useForm } from "react-hook-form"; -// headless ui import { Dialog, Transition } from "@headlessui/react"; -// services -import { ProjectEstimateService } from "services/project"; + +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useToast from "hooks/use-toast"; // ui @@ -17,29 +13,15 @@ import { Button, Input, TextArea } from "@plane/ui"; // helpers import { checkDuplicates } from "helpers/array.helper"; // types -import { IUser, IEstimate, IEstimateFormData } from "types"; -// fetch-keys -import { ESTIMATES_LIST, ESTIMATE_DETAILS } from "constants/fetch-keys"; +import { IEstimate, IEstimateFormData } from "types"; type Props = { isOpen: boolean; handleClose: () => void; data?: IEstimate; - user: IUser | undefined; }; -type FormValues = { - name: string; - description: string; - value1: string; - value2: string; - value3: string; - value4: string; - value5: string; - value6: string; -}; - -const defaultValues: Partial = { +const defaultValues = { name: "", description: "", value1: "", @@ -50,10 +32,18 @@ const defaultValues: Partial = { value6: "", }; -// services -const projectEstimateService = new ProjectEstimateService(); +type FormValues = typeof defaultValues; + +export const CreateUpdateEstimateModal: React.FC = observer((props) => { + const { handleClose, data, isOpen } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { projectEstimates: projectEstimatesStore } = useMobxStore(); -export const CreateUpdateEstimateModal: React.FC = ({ handleClose, data, isOpen, user }) => { const { formState: { errors, isSubmitting }, handleSubmit, @@ -68,71 +58,47 @@ export const CreateUpdateEstimateModal: React.FC = ({ handleClose, data, reset(); }; - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - const { setToastAlert } = useToast(); const createEstimate = async (payload: IEstimateFormData) => { if (!workspaceSlug || !projectId) return; - await projectEstimateService - .createEstimate(workspaceSlug as string, projectId as string, payload, user) + await projectEstimatesStore + .createEstimate(workspaceSlug.toString(), projectId.toString(), payload) .then(() => { - mutate(ESTIMATES_LIST(projectId as string)); onClose(); }) .catch((err) => { - if (err.status === 400) - setToastAlert({ - type: "error", - title: "Error!", - message: "Estimate with that name already exists. Please try again with another name.", - }); - else - setToastAlert({ - type: "error", - title: "Error!", - message: "Estimate could not be created. Please try again.", - }); + const error = err?.error; + const errorString = Array.isArray(error) ? error[0] : error; + + setToastAlert({ + type: "error", + title: "Error!", + message: + errorString ?? err.status === 400 + ? "Estimate with that name already exists. Please try again with another name." + : "Estimate could not be created. Please try again.", + }); }); }; const updateEstimate = async (payload: IEstimateFormData) => { if (!workspaceSlug || !projectId || !data) return; - mutate( - ESTIMATES_LIST(projectId.toString()), - (prevData) => - prevData?.map((p) => { - if (p.id === data.id) - return { - ...p, - name: payload.estimate.name, - description: payload.estimate.description, - points: p.points.map((point, index) => ({ - ...point, - value: payload.estimate_points[index].value, - })), - }; - - return p; - }), - false - ); - - await projectEstimateService - .patchEstimate(workspaceSlug as string, projectId as string, data?.id as string, payload, user) + await projectEstimatesStore + .updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload) .then(() => { - mutate(ESTIMATES_LIST(projectId.toString())); - mutate(ESTIMATE_DETAILS(data.id)); handleClose(); }) - .catch(() => { + .catch((err) => { + const error = err?.error; + const errorString = Array.isArray(error) ? error[0] : error; + setToastAlert({ type: "error", title: "Error!", - message: "Estimate could not be updated. Please try again.", + message: errorString ?? "Estimate could not be updated. Please try again.", }); }); @@ -237,7 +203,7 @@ export const CreateUpdateEstimateModal: React.FC = ({ handleClose, data, leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -251,7 +217,7 @@ export const CreateUpdateEstimateModal: React.FC = ({ handleClose, data, leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    {data ? "Update" : "Create"} Estimate
    @@ -291,158 +257,45 @@ export const CreateUpdateEstimateModal: React.FC = ({ handleClose, data, )} />
    + + {/* list of all the points */} + {/* since they are all the same, we can use a loop to render them */}
    -
    - - 1 - - ( - ( +
    + + {i + 1} + + ( + + )} /> - )} - /> - - -
    -
    - - 2 - - ( - - )} - /> - - -
    -
    - - 3 - - ( - - )} - /> - - -
    -
    - - 4 - - ( - - )} - /> - - -
    -
    - - 5 - - ( - - )} - /> - - -
    -
    - - 6 - - ( - - )} - /> - - -
    +
    +
    +
    + ))}
    - -
    ); -}; +}); diff --git a/web/components/estimates/single-estimate.tsx b/web/components/estimates/estimate-list-item.tsx similarity index 59% rename from web/components/estimates/single-estimate.tsx rename to web/components/estimates/estimate-list-item.tsx index a4e8fde28..beaa942d3 100644 --- a/web/components/estimates/single-estimate.tsx +++ b/web/components/estimates/estimate-list-item.tsx @@ -1,14 +1,12 @@ -import React, { useState } from "react"; +import React from "react"; import { useRouter } from "next/router"; -// services -import { ProjectService } from "services/project"; +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useToast from "hooks/use-toast"; -import useProjectDetails from "hooks/use-project-details"; -// components -import { DeleteEstimateModal } from "components/estimates"; // ui import { Button, CustomMenu } from "@plane/ui"; //icons @@ -16,58 +14,52 @@ import { Pencil, Trash2 } from "lucide-react"; // helpers import { orderArrayBy } from "helpers/array.helper"; // types -import { IUser, IEstimate } from "types"; +import { IEstimate } from "types"; type Props = { - user: IUser | undefined; estimate: IEstimate; editEstimate: (estimate: IEstimate) => void; - handleEstimateDelete: (estimateId: string) => void; + deleteEstimate: (estimateId: string) => void; }; -// services -const projectService = new ProjectService(); - -export const SingleEstimate: React.FC = ({ user, estimate, editEstimate, handleEstimateDelete }) => { - const [isDeleteEstimateModalOpen, setIsDeleteEstimateModalOpen] = useState(false); - +export const EstimateListItem: React.FC = observer((props) => { + const { estimate, editEstimate, deleteEstimate } = props; + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; - + // store + const { project: projectStore } = useMobxStore(); + const { currentProjectDetails } = projectStore; + // hooks const { setToastAlert } = useToast(); - const { projectDetails, mutateProjectDetails } = useProjectDetails(); - const handleUseEstimate = async () => { if (!workspaceSlug || !projectId) return; - const payload = { - estimate: estimate.id, - }; + await projectStore + .updateProject(workspaceSlug.toString(), projectId.toString(), { + estimate: estimate.id, + }) + .catch((err) => { + const error = err?.error; + const errorString = Array.isArray(error) ? error[0] : error; - mutateProjectDetails((prevData: any) => { - if (!prevData) return prevData; - - return { ...prevData, estimate: estimate.id }; - }, false); - - await projectService.updateProject(workspaceSlug as string, projectId as string, payload, user).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Estimate points could not be used. Please try again.", + setToastAlert({ + type: "error", + title: "Error!", + message: errorString ?? "Estimate points could not be used. Please try again.", + }); }); - }); }; return ( <> -
    +
    {estimate.name} - {projectDetails?.estimate && projectDetails?.estimate === estimate.id && ( + {currentProjectDetails?.estimate && currentProjectDetails?.estimate === estimate.id && ( In use )}
    @@ -76,7 +68,7 @@ export const SingleEstimate: React.FC = ({ user, estimate, editEstimate,

    - {projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && ( + {currentProjectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && ( @@ -92,10 +84,10 @@ export const SingleEstimate: React.FC = ({ user, estimate, editEstimate, Edit estimate
    - {projectDetails?.estimate !== estimate.id && ( + {currentProjectDetails?.estimate !== estimate.id && ( { - setIsDeleteEstimateModalOpen(true); + deleteEstimate(estimate.id); }} >
    @@ -107,7 +99,7 @@ export const SingleEstimate: React.FC = ({ user, estimate, editEstimate,
    - {estimate.points.length > 0 ? ( + {estimate?.points?.length > 0 ? (
    Estimate points ( @@ -126,16 +118,6 @@ export const SingleEstimate: React.FC = ({ user, estimate, editEstimate,
    )}
    - - setIsDeleteEstimateModalOpen(false)} - data={estimate} - handleDelete={() => { - handleEstimateDelete(estimate.id); - setIsDeleteEstimateModalOpen(false); - }} - /> ); -}; +}); diff --git a/web/components/estimates/estimate-list.tsx b/web/components/estimates/estimate-list.tsx new file mode 100644 index 000000000..07770b183 --- /dev/null +++ b/web/components/estimates/estimate-list.tsx @@ -0,0 +1,135 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates"; +//hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Loader } from "@plane/ui"; +import { EmptyState } from "components/common"; +// icons +import { Plus } from "lucide-react"; +// images +import emptyEstimate from "public/empty-state/estimate.svg"; +// types +import { IEstimate } from "types"; + +export const EstimatesList: React.FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { project: projectStore } = useMobxStore(); + const { currentProjectDetails } = projectStore; + // states + const [estimateFormOpen, setEstimateFormOpen] = useState(false); + const [estimateToDelete, setEstimateToDelete] = useState(null); + const [estimateToUpdate, setEstimateToUpdate] = useState(); + // hooks + const { setToastAlert } = useToast(); + // derived values + const estimatesList = projectStore.projectEstimates; + + const editEstimate = (estimate: IEstimate) => { + setEstimateFormOpen(true); + setEstimateToUpdate(estimate); + }; + + const disableEstimates = () => { + if (!workspaceSlug || !projectId) return; + + projectStore.updateProject(workspaceSlug.toString(), projectId.toString(), { estimate: null }).catch((err) => { + const error = err?.error; + const errorString = Array.isArray(error) ? error[0] : error; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorString ?? "Estimate could not be disabled. Please try again", + }); + }); + }; + + return ( + <> + { + setEstimateFormOpen(false); + setEstimateToUpdate(undefined); + }} + /> + + setEstimateToDelete(null)} + data={projectStore.getProjectEstimateById(estimateToDelete!)} + /> + +
    +

    Estimates

    +
    +
    + + {currentProjectDetails?.estimate && ( + + )} +
    +
    +
    + + {estimatesList ? ( + estimatesList.length > 0 ? ( +
    + {estimatesList.map((estimate) => ( + editEstimate(estimate)} + deleteEstimate={(estimateId) => setEstimateToDelete(estimateId)} + /> + ))} +
    + ) : ( +
    + , + text: "Add Estimate", + onClick: () => { + setEstimateFormOpen(true); + setEstimateToUpdate(undefined); + }, + }} + /> +
    + ) + ) : ( + + + + + + + )} + + ); +}); diff --git a/web/components/estimates/estimate-select.tsx b/web/components/estimates/estimate-select.tsx index 5ac283b83..e02cfaf89 100644 --- a/web/components/estimates/estimate-select.tsx +++ b/web/components/estimates/estimate-select.tsx @@ -3,7 +3,7 @@ import { usePopper } from "react-popper"; import { Combobox } from "@headlessui/react"; import { Check, ChevronDown, Search, Triangle } from "lucide-react"; // types -import { Tooltip } from "components/ui"; +import { Tooltip } from "@plane/ui"; import { Placement } from "@popperjs/core"; // constants import { IEstimatePoint } from "types"; diff --git a/web/components/estimates/index.ts b/web/components/estimates/index.ts index b88ceaf03..e9a22a53d 100644 --- a/web/components/estimates/index.ts +++ b/web/components/estimates/index.ts @@ -1,4 +1,4 @@ export * from "./create-update-estimate-modal"; export * from "./delete-estimate-modal"; export * from "./estimate-select"; -export * from "./single-estimate"; +export * from "./estimate-list-item"; diff --git a/web/components/exporter/export-modal.tsx b/web/components/exporter/export-modal.tsx index fe73dd620..4cf99e0ce 100644 --- a/web/components/exporter/export-modal.tsx +++ b/web/components/exporter/export-modal.tsx @@ -99,7 +99,7 @@ export const Exporter: React.FC = observer((props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -113,7 +113,7 @@ export const Exporter: React.FC = observer((props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -128,7 +128,7 @@ export const Exporter: React.FC = observer((props) => { value={value ?? []} onChange={(val: string[]) => onChange(val)} options={options} - input={true} + input label={ value && value.length > 0 ? projects && @@ -150,11 +150,12 @@ export const Exporter: React.FC = observer((props) => {
    Export the data into separate files
    - + )} +
    +
    + +
    +
    + {duration} day{duration > 1 ? "s" : ""} +
    +
    +
    +
    + )} + + ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
    + )} + + + ); +}; diff --git a/web/components/gantt-chart/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx similarity index 90% rename from web/components/gantt-chart/sidebar.tsx rename to web/components/gantt-chart/sidebar/sidebar.tsx index 0e7dae048..72fbe1267 100644 --- a/web/components/gantt-chart/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -1,30 +1,29 @@ import { useRouter } from "next/router"; -import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; +import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { MoreVertical } from "lucide-react"; // hooks -import { useChart } from "./hooks"; +import { useChart } from "components/gantt-chart/hooks"; // ui import { Loader } from "@plane/ui"; // components -import { GanttInlineCreateIssueForm } from "components/issues"; +import { GanttInlineCreateIssueForm, IssueGanttSidebarBlock } from "components/issues"; // helpers import { findTotalDaysInRange } from "helpers/date-time.helper"; // types -import { IBlockUpdateData, IGanttBlock } from "./types"; +import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; type Props = { title: string; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blocks: IGanttBlock[] | null; - SidebarBlockRender: React.FC; enableReorder: boolean; enableQuickIssueCreate?: boolean; }; -export const GanttSidebar: React.FC = (props) => { +export const IssueGanttSidebar: React.FC = (props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder, enableQuickIssueCreate } = props; + const { title, blockUpdateHandler, blocks, enableReorder, enableQuickIssueCreate } = props; const router = useRouter(); const { cycleId } = router.query; @@ -131,7 +130,7 @@ export const GanttSidebar: React.FC = (props) => { )}
    - +
    {duration} day{duration > 1 ? "s" : ""} @@ -153,7 +152,7 @@ export const GanttSidebar: React.FC = (props) => { )} {droppableProvided.placeholder} - + {enableQuickIssueCreate && }
    )} diff --git a/web/components/gantt-chart/views/bi-week-view.ts b/web/components/gantt-chart/views/bi-week-view.ts index b8d8a0bfa..14c0aad15 100644 --- a/web/components/gantt-chart/views/bi-week-view.ts +++ b/web/components/gantt-chart/views/bi-week-view.ts @@ -3,12 +3,7 @@ import { ChartDataType } from "../types"; // data import { weeks, months } from "../data"; // helpers -import { - generateDate, - getWeekNumberByDate, - getNumberOfDaysInMonth, - getDatesBetweenTwoDates, -} from "./helpers"; +import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; type GetAllDaysInMonthInMonthViewType = { date: any; @@ -34,9 +29,7 @@ const getAllDaysInMonthInMonthView = (month: number, year: number) => { title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, active: false, today: - currentDate.getFullYear() === year && - currentDate.getMonth() === month && - currentDate.getDate() === _day + 1 + currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 ? true : false, }); @@ -72,16 +65,8 @@ export const generateBiWeekChart = (monthPayload: ChartDataType, side: null | "l if (side === null) { const currentDate = renderState.data.currentDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -96,16 +81,8 @@ export const generateBiWeekChart = (monthPayload: ChartDataType, side: null | "l } else if (side === "left") { const currentDate = renderState.data.startDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - 1, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -116,16 +93,8 @@ export const generateBiWeekChart = (monthPayload: ChartDataType, side: null | "l } else if (side === "right") { const currentDate = renderState.data.endDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + 1, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); diff --git a/web/components/gantt-chart/views/day-view.ts b/web/components/gantt-chart/views/day-view.ts index 246ecd8b8..0801b7bb1 100644 --- a/web/components/gantt-chart/views/day-view.ts +++ b/web/components/gantt-chart/views/day-view.ts @@ -10,8 +10,7 @@ export const getWeekNumberByDate = (date: Date) => { const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000; const weekStart = new Date(firstWeekStart); - const weekNumber = - Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; + const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; return weekNumber; }; @@ -25,8 +24,7 @@ export const getNumberOfDaysInMonth = (month: number, year: number) => { return date.getDate(); }; -export const generateDate = (day: number, month: number, year: number) => - new Date(year, month, day); +export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day); export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { const months = []; @@ -45,8 +43,7 @@ export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { months.push(new Date(currentYear, currentMonth)); currentDate.setMonth(currentDate.getMonth() + 1); } - if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) - months.push(endDate); + if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) months.push(endDate); return months; }; @@ -73,9 +70,7 @@ export const getAllDaysInMonth = (month: number, year: number) => { weekNumber: getWeekNumberByDate(date), title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, today: - currentDate.getFullYear() === year && - currentDate.getMonth() === month && - currentDate.getDate() === _day + 1 + currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 ? true : false, }); @@ -99,10 +94,7 @@ export const generateMonthDataByMonth = (month: number, year: number) => { return monthPayload; }; -export const generateMonthDataByYear = ( - monthPayload: ChartDataType, - side: null | "left" | "right" -) => { +export const generateMonthDataByYear = (monthPayload: ChartDataType, side: null | "left" | "right") => { let renderState = monthPayload; const renderPayload: any = []; @@ -114,16 +106,8 @@ export const generateMonthDataByYear = ( if (side === null) { const currentDate = renderState.data.currentDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -138,16 +122,8 @@ export const generateMonthDataByYear = ( } else if (side === "left") { const currentDate = renderState.data.startDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - 1, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -158,16 +134,8 @@ export const generateMonthDataByYear = ( } else if (side === "right") { const currentDate = renderState.data.endDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + 1, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); diff --git a/web/components/gantt-chart/views/helpers.ts b/web/components/gantt-chart/views/helpers.ts index bfea64297..94b614286 100644 --- a/web/components/gantt-chart/views/helpers.ts +++ b/web/components/gantt-chart/views/helpers.ts @@ -1,6 +1,5 @@ // Generating the date by using the year, month, and day -export const generateDate = (day: number, month: number, year: number) => - new Date(year, month, day); +export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day); // Getting the number of days in a month export const getNumberOfDaysInMonth = (month: number, year: number) => { @@ -20,8 +19,7 @@ export const getWeekNumberByDate = (date: Date) => { const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000; const weekStart = new Date(firstWeekStart); - const weekNumber = - Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; + const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; return weekNumber; }; @@ -86,8 +84,7 @@ export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { dates.push(new Date(currentYear, currentMonth)); currentDate.setMonth(currentDate.getMonth() + 1); } - if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) - dates.push(endDate); + if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) dates.push(endDate); return dates; }; diff --git a/web/components/gantt-chart/views/hours-view.ts b/web/components/gantt-chart/views/hours-view.ts index 246ecd8b8..0801b7bb1 100644 --- a/web/components/gantt-chart/views/hours-view.ts +++ b/web/components/gantt-chart/views/hours-view.ts @@ -10,8 +10,7 @@ export const getWeekNumberByDate = (date: Date) => { const firstWeekStart = firstDayOfYear.getTime() - daysOffset * 24 * 60 * 60 * 1000; const weekStart = new Date(firstWeekStart); - const weekNumber = - Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; + const weekNumber = Math.floor((date.getTime() - weekStart.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1; return weekNumber; }; @@ -25,8 +24,7 @@ export const getNumberOfDaysInMonth = (month: number, year: number) => { return date.getDate(); }; -export const generateDate = (day: number, month: number, year: number) => - new Date(year, month, day); +export const generateDate = (day: number, month: number, year: number) => new Date(year, month, day); export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { const months = []; @@ -45,8 +43,7 @@ export const getDatesBetweenTwoDates = (startDate: Date, endDate: Date) => { months.push(new Date(currentYear, currentMonth)); currentDate.setMonth(currentDate.getMonth() + 1); } - if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) - months.push(endDate); + if (endYear === currentDate.getFullYear() && endMonth === currentDate.getMonth()) months.push(endDate); return months; }; @@ -73,9 +70,7 @@ export const getAllDaysInMonth = (month: number, year: number) => { weekNumber: getWeekNumberByDate(date), title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, today: - currentDate.getFullYear() === year && - currentDate.getMonth() === month && - currentDate.getDate() === _day + 1 + currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 ? true : false, }); @@ -99,10 +94,7 @@ export const generateMonthDataByMonth = (month: number, year: number) => { return monthPayload; }; -export const generateMonthDataByYear = ( - monthPayload: ChartDataType, - side: null | "left" | "right" -) => { +export const generateMonthDataByYear = (monthPayload: ChartDataType, side: null | "left" | "right") => { let renderState = monthPayload; const renderPayload: any = []; @@ -114,16 +106,8 @@ export const generateMonthDataByYear = ( if (side === null) { const currentDate = renderState.data.currentDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -138,16 +122,8 @@ export const generateMonthDataByYear = ( } else if (side === "left") { const currentDate = renderState.data.startDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - 1, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -158,16 +134,8 @@ export const generateMonthDataByYear = ( } else if (side === "right") { const currentDate = renderState.data.endDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + 1, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); diff --git a/web/components/gantt-chart/views/month-view.ts b/web/components/gantt-chart/views/month-view.ts index db21e372b..fc145d69c 100644 --- a/web/components/gantt-chart/views/month-view.ts +++ b/web/components/gantt-chart/views/month-view.ts @@ -3,12 +3,7 @@ import { ChartDataType, IGanttBlock } from "../types"; // data import { weeks, months } from "../data"; // helpers -import { - generateDate, - getWeekNumberByDate, - getNumberOfDaysInMonth, - getDatesBetweenTwoDates, -} from "./helpers"; +import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; type GetAllDaysInMonthInMonthViewType = { date: any; @@ -62,9 +57,7 @@ const getAllDaysInMonthInMonthView = (month: number, year: number): IMonthChild[ title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, active: false, today: - currentDate.getFullYear() === year && - currentDate.getMonth() === month && - currentDate.getDate() === _day + 1 + currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 ? true : false, }); @@ -100,16 +93,8 @@ export const generateMonthChart = (monthPayload: ChartDataType, side: null | "le if (side === null) { const currentDate = renderState.data.currentDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -124,16 +109,8 @@ export const generateMonthChart = (monthPayload: ChartDataType, side: null | "le } else if (side === "left") { const currentDate = renderState.data.startDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - 1, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -144,16 +121,8 @@ export const generateMonthChart = (monthPayload: ChartDataType, side: null | "le } else if (side === "right") { const currentDate = renderState.data.endDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + 1, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -191,10 +160,7 @@ export const getNumberOfDaysBetweenTwoDatesInMonth = (startDate: Date, endDate: }; // calc item scroll position and width -export const getMonthChartItemPositionWidthInMonth = ( - chartData: ChartDataType, - itemData: IGanttBlock -) => { +export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, itemData: IGanttBlock) => { let scrollPosition: number = 0; let scrollWidth: number = 0; @@ -207,9 +173,7 @@ export const getMonthChartItemPositionWidthInMonth = ( // position code starts const positionTimeDifference: number = startDate.getTime() - itemStartDate.getTime(); - const positionDaysDifference: number = Math.abs( - Math.floor(positionTimeDifference / (1000 * 60 * 60 * 24)) - ); + const positionDaysDifference: number = Math.abs(Math.floor(positionTimeDifference / (1000 * 60 * 60 * 24))); scrollPosition = positionDaysDifference * chartData.data.width; var diffMonths = (itemStartDate.getFullYear() - startDate.getFullYear()) * 12; @@ -221,9 +185,7 @@ export const getMonthChartItemPositionWidthInMonth = ( // width code starts const widthTimeDifference: number = itemStartDate.getTime() - itemTargetDate.getTime(); - const widthDaysDifference: number = Math.abs( - Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24)) - ); + const widthDaysDifference: number = Math.abs(Math.floor(widthTimeDifference / (1000 * 60 * 60 * 24))); scrollWidth = (widthDaysDifference + 1) * chartData.data.width + 1; // width code ends diff --git a/web/components/gantt-chart/views/quater-view.ts b/web/components/gantt-chart/views/quater-view.ts index 0714cb28a..ed25974a3 100644 --- a/web/components/gantt-chart/views/quater-view.ts +++ b/web/components/gantt-chart/views/quater-view.ts @@ -36,10 +36,7 @@ const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) return weekPayload; }; -export const generateQuarterChart = ( - quarterPayload: ChartDataType, - side: null | "left" | "right" -) => { +export const generateQuarterChart = (quarterPayload: ChartDataType, side: null | "left" | "right") => { let renderState = quarterPayload; const renderPayload: any = []; diff --git a/web/components/gantt-chart/views/week-view.ts b/web/components/gantt-chart/views/week-view.ts index 024b8d4e1..a65eb70b9 100644 --- a/web/components/gantt-chart/views/week-view.ts +++ b/web/components/gantt-chart/views/week-view.ts @@ -3,12 +3,7 @@ import { ChartDataType } from "../types"; // data import { weeks, months } from "../data"; // helpers -import { - generateDate, - getWeekNumberByDate, - getNumberOfDaysInMonth, - getDatesBetweenTwoDates, -} from "./helpers"; +import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; type GetAllDaysInMonthInMonthViewType = { date: any; @@ -34,9 +29,7 @@ const getAllDaysInMonthInMonthView = (month: number, year: number) => { title: `${weeks[date.getDay()].shortTitle} ${_day + 1}`, active: false, today: - currentDate.getFullYear() === year && - currentDate.getMonth() === month && - currentDate.getDate() === _day + 1 + currentDate.getFullYear() === year && currentDate.getMonth() === month && currentDate.getDate() === _day + 1 ? true : false, }); @@ -72,16 +65,8 @@ export const generateWeekChart = (monthPayload: ChartDataType, side: null | "lef if (side === null) { const currentDate = renderState.data.currentDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -96,16 +81,8 @@ export const generateWeekChart = (monthPayload: ChartDataType, side: null | "lef } else if (side === "left") { const currentDate = renderState.data.startDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - range, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() - 1, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - range, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); @@ -116,16 +93,8 @@ export const generateWeekChart = (monthPayload: ChartDataType, side: null | "lef } else if (side === "right") { const currentDate = renderState.data.endDate; - minusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + 1, - currentDate.getDate() - ); - plusDate = new Date( - currentDate.getFullYear(), - currentDate.getMonth() + range, - currentDate.getDate() - ); + minusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, currentDate.getDate()); + plusDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + range, currentDate.getDate()); if (minusDate && plusDate) filteredDates = getDatesBetweenTwoDates(minusDate, plusDate); diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index ac6ed2e6b..3f177cded 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/router"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; @@ -10,11 +9,12 @@ import useLocalStorage from "hooks/use-local-storage"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { ProjectAnalyticsModal } from "components/analytics"; // ui -import { Breadcrumbs, Button, CustomMenu } from "@plane/ui"; +import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // icons -import { ArrowRight, ContrastIcon, Plus } from "lucide-react"; +import { ArrowRight, Plus } from "lucide-react"; // helpers import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types"; // constants @@ -31,7 +31,12 @@ export const CycleIssuesHeader: React.FC = observer(() => { cycle: cycleStore, cycleIssueFilter: cycleIssueFilterStore, project: projectStore, + projectMember: { projectMembers }, + projectState: projectStateStore, + commandPalette: commandPaletteStore, } = useMobxStore(); + const { currentProjectDetails } = projectStore; + const activeLayout = issueFilterStore.userDisplayFilters.layout; const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); @@ -98,7 +103,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { [issueFilterStore, projectId, workspaceSlug] ); - const cyclesList = projectId ? cycleStore.cycles[projectId.toString()] : undefined; + const cyclesList = cycleStore.projectCycles; const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined; return ( @@ -108,38 +113,57 @@ export const CycleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} /> -
    +
    - router.back()}> + - -

    {`${truncateText(cycleDetails?.project_detail.name ?? "", 32)} Cycles`}

    -
    - + type="text" + icon={ + currentProjectDetails?.emoji ? ( + renderEmoji(currentProjectDetails.emoji) + ) : currentProjectDetails?.icon_prop ? ( + renderEmoji(currentProjectDetails.icon_prop) + ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) + } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Cycles" + link={`/${workspaceSlug}/projects/${projectId}/cycles`} + /> + + + {cycleDetails?.name && truncateText(cycleDetails.name, 40)} + + } + className="ml-1.5 flex-shrink-0" + width="auto" + placement="bottom-start" + > + {cyclesList?.map((cycle) => ( + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)} + > + {truncateText(cycle.name, 40)} + + ))} + } />
    - - - {cycleDetails?.name && truncateText(cycleDetails.name, 40)} - - } - className="ml-1.5 flex-shrink-0" - width="auto" - > - {cyclesList?.map((cycle) => ( - router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)} - > - {truncateText(cycle.name, 40)} - - ))} -
    { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + { activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} - members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)} - states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} + members={projectMembers?.map((m) => m.member)} + states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + { -
    ); -}; +}); diff --git a/web/components/headers/global-issues.tsx b/web/components/headers/global-issues.tsx index b80dd45a1..52a60540f 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/components/headers/global-issues.tsx @@ -10,9 +10,9 @@ import { useMobxStore } from "lib/mobx/store-provider"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "components/issues"; import { CreateUpdateWorkspaceViewModal } from "components/workspace"; // ui -import { Button, Tooltip } from "@plane/ui"; +import { Breadcrumbs, Button, LayersIcon, PhotoFilterIcon, Tooltip } from "@plane/ui"; // icons -import { CheckCircle, List, PlusIcon, Sheet } from "lucide-react"; +import { List, PlusIcon, Sheet } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TStaticViewTypes } from "types"; // constants @@ -41,6 +41,7 @@ export const GlobalIssuesHeader: React.FC = observer((props) => { globalViewFilters: globalViewFiltersStore, workspaceFilter: workspaceFilterStore, workspace: workspaceStore, + workspaceMember: { workspaceMembers }, project: projectStore, } = useMobxStore(); @@ -98,10 +99,21 @@ export const GlobalIssuesHeader: React.FC = observer((props) => { return ( <> setCreateViewModal(false)} /> -
    -
    - {activeLayout === "spreadsheet" && } - Workspace {activeLayout === "spreadsheet" ? "Issues" : "Views"} +
    +
    + + + ) : ( + + ) + } + label={`Workspace ${activeLayout === "spreadsheet" ? "Issues" : "Views"}`} + /> +
    @@ -128,18 +140,19 @@ export const GlobalIssuesHeader: React.FC = observer((props) => { {activeLayout === "spreadsheet" && ( <> {!STATIC_VIEW_TYPES.some((word) => router.pathname.includes(word)) && ( - + m.member) ?? undefined} projects={workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined} /> )} - + = observer((props) => { )} -
    diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 08a30531c..cea8093bf 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/router"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; @@ -10,11 +9,12 @@ import useLocalStorage from "hooks/use-local-storage"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { ProjectAnalyticsModal } from "components/analytics"; // ui -import { Breadcrumbs, Button, CustomMenu } from "@plane/ui"; +import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui"; // icons import { ArrowRight, ContrastIcon, Plus } from "lucide-react"; // helpers import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types"; // constants @@ -31,8 +31,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => { module: moduleStore, moduleFilter: moduleFilterStore, project: projectStore, + projectMember: { projectMembers }, + projectState: projectStateStore, + commandPalette: commandPaletteStore, } = useMobxStore(); const activeLayout = issueFilterStore.userDisplayFilters.layout; + const { currentProjectDetails } = projectStore; const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); @@ -108,41 +112,57 @@ export const ModuleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} /> -
    +
    - router.back()}> + - -

    {`${truncateText( - moduleDetails?.project_detail.name ?? "", - 32 - )} Modules`}

    -
    - + type="text" + icon={ + currentProjectDetails?.emoji ? ( + renderEmoji(currentProjectDetails.emoji) + ) : currentProjectDetails?.icon_prop ? ( + renderEmoji(currentProjectDetails.icon_prop) + ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) + } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Modules" + link={`/${workspaceSlug}/projects/${projectId}/modules`} + /> + + + {moduleDetails?.name && truncateText(moduleDetails.name, 40)} + + } + className="ml-1.5 flex-shrink-0" + width="auto" + placement="bottom-start" + > + {modulesList?.map((module) => ( + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${module.id}`)} + > + {truncateText(module.name, 40)} + + ))} + } />
    - - - {moduleDetails?.name && truncateText(moduleDetails.name, 40)} - - } - className="ml-1.5 flex-shrink-0" - width="auto" - > - {modulesList?.map((module) => ( - router.push(`/${workspaceSlug}/projects/${projectId}/modules/${module.id}`)} - > - {truncateText(module.name, 40)} - - ))} -
    { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + { activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} - members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)} - states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} + members={projectMembers?.map((m) => m.member)} + states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + { - - - ))} +
    + {MODULE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
    diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx new file mode 100644 index 000000000..84681e768 --- /dev/null +++ b/web/components/headers/page-details.tsx @@ -0,0 +1,90 @@ +import { FC } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { FileText, Plus } from "lucide-react"; +// services +import { PageService } from "services/page.service"; + +// constants +import { PAGE_DETAILS } from "constants/fetch-keys"; + +// hooks +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Breadcrumbs, Button } from "@plane/ui"; +// helper +import { renderEmoji } from "helpers/emoji.helper"; + +import useSWR from "swr"; + +export interface IPagesHeaderProps { + showButton?: boolean; +} +const pageService = new PageService(); + +export const PageDetailsHeader: FC = observer((props) => { + const { showButton = false } = props; + + const router = useRouter(); + const { workspaceSlug, pageId } = router.query; + + const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore(); + const { currentProjectDetails } = projectStore; + + const { data: pageDetails } = useSWR( + workspaceSlug && currentProjectDetails?.id && pageId ? PAGE_DETAILS(pageId as string) : null, + workspaceSlug && currentProjectDetails?.id + ? () => pageService.getPageDetails(workspaceSlug as string, currentProjectDetails.id, pageId as string) + : null + ); + + return ( +
    +
    +
    + + + {currentProjectDetails?.name.charAt(0)} + + ) + } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Pages" + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages`} + /> + } + label={pageDetails?.name ?? "Page"} + /> + +
    +
    + {showButton && ( +
    + +
    + )} +
    + ); +}); diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index f30c03fbb..7762458f2 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -1,14 +1,13 @@ import { FC } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { Plus } from "lucide-react"; +import { FileText, Plus } from "lucide-react"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; // ui -import { Breadcrumbs, BreadcrumbItem, Button } from "@plane/ui"; +import { Breadcrumbs, Button } from "@plane/ui"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; export interface IPagesHeaderProps { showButton?: boolean; @@ -17,30 +16,37 @@ export interface IPagesHeaderProps { export const PagesHeader: FC = observer((props) => { const { showButton = false } = props; const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; - const { project: projectStore } = useMobxStore(); - - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore(); + const { currentProjectDetails } = projectStore; return ( -
    +
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Pages" /> -
    @@ -50,10 +56,7 @@ export const PagesHeader: FC = observer((props) => { variant="primary" prependIcon={} size="sm" - onClick={() => { - const e = new KeyboardEvent("keydown", { key: "d" }); - document.dispatchEvent(e); - }} + onClick={() => commandPaletteStore.toggleCreatePageModal(true)} > Create Page diff --git a/web/components/headers/profile-preferences.tsx b/web/components/headers/profile-preferences.tsx index ae5ad8811..78a651de5 100644 --- a/web/components/headers/profile-preferences.tsx +++ b/web/components/headers/profile-preferences.tsx @@ -1,21 +1,14 @@ -import { useRouter } from "next/router"; // components -import { BreadcrumbItem, Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs } from "@plane/ui"; -export const ProfilePreferencesHeader = () => { - const router = useRouter(); - - return ( -
    -
    -
    - router.back()}> - - -
    +export const ProfilePreferencesHeader = () => ( +
    +
    +
    + + +
    - ); -}; +
    +); diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/components/headers/project-archived-issue-details.tsx index 8c659431e..3056934dc 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/components/headers/project-archived-issue-details.tsx @@ -1,20 +1,19 @@ import { FC } from "react"; import useSWR from "swr"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; // ui -import { Breadcrumbs, BreadcrumbItem } from "@plane/ui"; -// helper -import { truncateText } from "helpers/string.helper"; +import { Breadcrumbs, LayersIcon } from "@plane/ui"; // types import { IIssue } from "types"; // constants import { ISSUE_DETAILS } from "constants/fetch-keys"; // services import { IssueArchiveService } from "services/issue"; +// helpers +import { renderEmoji } from "helpers/emoji.helper"; const issueArchiveService = new IssueArchiveService(); @@ -24,10 +23,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { const { project: projectStore } = useMobxStore(); - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { currentProjectDetails } = projectStore; const { data: issueDetails } = useSWR( workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId as string) : null, @@ -42,20 +38,38 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { ); return ( -
    +
    - router.back()}> - - -

    {`${truncateText( - issueDetails?.project_detail.name ?? "Project", - 32 - )} Issues`}

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) + } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects`} + /> - + } + label="Archived Issues" + link={`/${workspaceSlug}/projects/${projectId}/archived-issues`} + /> + +
    diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index a40ab9e60..4c2d9f354 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -1,40 +1,141 @@ import { FC } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; +// constants +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // ui -import { Breadcrumbs, BreadcrumbItem } from "@plane/ui"; +import { Breadcrumbs, LayersIcon } from "@plane/ui"; +// icons +import { ArrowLeft } from "lucide-react"; +// components +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; +// types +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "types"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; export const ProjectArchivedIssuesHeader: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { project: projectStore } = useMobxStore(); + const { + project: projectStore, + projectMember: { projectMembers }, + archivedIssueFilters: archivedIssueFiltersStore, + projectState: projectStateStore, + } = useMobxStore(); - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { currentProjectDetails } = projectStore; + + // for archived issues list layout is the only option + const activeLayout = "list"; + + const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + + const newValues = archivedIssueFiltersStore.userFilters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (archivedIssueFiltersStore.userFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), { + filters: { + [key]: newValues, + }, + }); + }; + + const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + + archivedIssueFiltersStore.updateUserFilters(workspaceSlug.toString(), projectId.toString(), { + display_filters: { + ...archivedIssueFiltersStore.userDisplayFilters, + ...updatedDisplayFilter, + }, + }); + }; + + const handleDisplayPropertiesUpdate = (property: Partial) => { + if (!workspaceSlug || !projectId) return; + + archivedIssueFiltersStore.updateDisplayProperties(workspaceSlug.toString(), projectId.toString(), property); + }; return ( -
    +
    +
    + +
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) + } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects`} + /> - + } + label="Archived Issues" + />
    + + {/* filter options */} +
    + + m.member)} + states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} + /> + + + + +
    ); }); diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 125dccadd..11c5d01ab 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -1,41 +1,47 @@ import { FC } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; // ui -import { Breadcrumbs, BreadcrumbItem } from "@plane/ui"; +import { Breadcrumbs, LayersIcon } from "@plane/ui"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; export const ProjectDraftIssueHeader: FC = observer(() => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const { project: projectStore } = useMobxStore(); - - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { currentProjectDetails } = projectStore; return ( -
    +
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects`} /> - + } + label="Draft Issues" + />
    diff --git a/web/components/headers/project-inbox.tsx b/web/components/headers/project-inbox.tsx index 20eb67211..15c067d66 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/components/headers/project-inbox.tsx @@ -1,44 +1,52 @@ import { FC, useState } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; // ui -import { Breadcrumbs, BreadcrumbItem, Button } from "@plane/ui"; +import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // components import { CreateInboxIssueModal } from "components/inbox"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; export const ProjectInboxHeader: FC = observer(() => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const [createIssueModal, setCreateIssueModal] = useState(false); const { project: projectStore } = useMobxStore(); - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { currentProjectDetails } = projectStore; return ( -
    +
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects`} + /> + + } + label="Inbox Issues" /> -
    diff --git a/web/components/headers/project-issue-details.tsx b/web/components/headers/project-issue-details.tsx index 2728919b2..6c89f7a09 100644 --- a/web/components/headers/project-issue-details.tsx +++ b/web/components/headers/project-issue-details.tsx @@ -1,17 +1,17 @@ import { FC } from "react"; import useSWR from "swr"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // ui -import { Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs, LayersIcon } from "@plane/ui"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // services import { IssueService } from "services/issue"; // constants import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { useMobxStore } from "lib/mobx/store-provider"; // services const issueService = new IssueService(); @@ -20,6 +20,10 @@ export const ProjectIssueDetailsHeader: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; + const { project: projectStore } = useMobxStore(); + + const { currentProjectDetails } = projectStore; + const { data: issueDetails } = useSWR( workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId @@ -28,27 +32,37 @@ export const ProjectIssueDetailsHeader: FC = observer(() => { ); return ( -
    +
    - router.back()}> + - -

    {`${truncateText( - issueDetails?.project_detail.name ?? "Project", - 32 - )} Issues`}

    -
    - + type="text" + icon={ + currentProjectDetails?.emoji ? ( + renderEmoji(currentProjectDetails.emoji) + ) : currentProjectDetails?.icon_prop ? ( + renderEmoji(currentProjectDetails.icon_prop) + ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects`} /> + } + label="Issues" + link={`/${workspaceSlug}/projects/${projectId}/issues`} + /> + +
    diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index f1cdef4c0..5b87b14c4 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -9,13 +9,13 @@ import { useMobxStore } from "lib/mobx/store-provider"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { ProjectAnalyticsModal } from "components/analytics"; // ui -import { Breadcrumbs, BreadcrumbItem, Button } from "@plane/ui"; +import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types"; // constants import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; export const ProjectIssuesHeader: React.FC = observer(() => { const [analyticsModal, setAnalyticsModal] = useState(false); @@ -23,7 +23,14 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { issueFilter: issueFilterStore, project: projectStore, inbox: inboxStore } = useMobxStore(); + const { + issueFilter: issueFilterStore, + project: projectStore, + projectMember: { projectMembers }, + projectState: projectStateStore, + inbox: inboxStore, + commandPalette: commandPaletteStore, + } = useMobxStore(); const activeLayout = issueFilterStore.userDisplayFilters.layout; @@ -85,11 +92,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { }, [issueFilterStore, projectId, workspaceSlug] ); - - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { currentProjectDetails } = projectStore; const inboxDetails = projectId ? inboxStore.inboxesList?.[projectId.toString()]?.[0] : undefined; @@ -100,9 +103,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { setAnalyticsModal(false)} - projectDetails={projectDetails ?? undefined} + projectDetails={currentProjectDetails ?? undefined} /> -
    +
    - router.back()}> - - -

    Projects

    -
    - + + + {renderEmoji(currentProjectDetails.emoji)} + + ) : currentProjectDetails?.icon_prop ? ( +
    + {renderEmoji(currentProjectDetails.icon_prop)} +
    + ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) } + label={currentProjectDetails?.name ?? "Project"} + link={`/${workspaceSlug}/projects`} + /> + + } + label="Issues" /> -
    - {projectDetails?.is_deployed && deployUrl && ( + {currentProjectDetails?.is_deployed && deployUrl && ( @@ -146,7 +165,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + { activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} - members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)} - states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} + members={projectMembers?.map((m) => m.member)} + states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + { @@ -184,16 +205,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { -
    diff --git a/web/components/headers/project-settings.tsx b/web/components/headers/project-settings.tsx index 44271a76e..3dfb9da07 100644 --- a/web/components/headers/project-settings.tsx +++ b/web/components/headers/project-settings.tsx @@ -1,11 +1,10 @@ import { FC } from "react"; import { useRouter } from "next/router"; -import Link from "next/link"; // ui -import { BreadcrumbItem, Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs } from "@plane/ui"; // helper -import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; import { observer } from "mobx-react-lite"; @@ -17,28 +16,33 @@ export interface IProjectSettingHeader { export const ProjectSettingHeader: FC = observer((props) => { const { title } = props; const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; // store const { project: projectStore } = useMobxStore(); - const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const { currentProjectDetails } = projectStore; return ( -
    +
    - router.back()}> - - -

    {`${truncateText(projectDetails?.name ?? "Project", 32)}`}

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} /> - +
    diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index 31326e826..7e899d851 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -1,5 +1,4 @@ import { useCallback } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // mobx store @@ -7,9 +6,10 @@ import { useMobxStore } from "lib/mobx/store-provider"; // components import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; // ui -import { BreadcrumbItem, Breadcrumbs, CustomMenu, PhotoFilterIcon } from "@plane/ui"; +import { Breadcrumbs, CustomMenu, PhotoFilterIcon } from "@plane/ui"; // helpers import { truncateText } from "helpers/string.helper"; +import { renderEmoji } from "helpers/emoji.helper"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types"; // constants @@ -23,9 +23,13 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { issueFilter: issueFilterStore, projectViewFilters: projectViewFiltersStore, project: projectStore, + projectMember: { projectMembers }, + projectState: projectStateStore, projectViews: projectViewsStore, } = useMobxStore(); + const { currentProjectDetails } = projectStore; + const storedFilters = viewId ? projectViewFiltersStore.storedFilters[viewId.toString()] : undefined; const activeLayout = issueFilterStore.userDisplayFilters.layout; @@ -87,47 +91,64 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { [issueFilterStore, projectId, workspaceSlug] ); - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; - const viewsList = projectId ? projectViewsStore.viewsList[projectId.toString()] : undefined; const viewDetails = viewId ? projectViewsStore.viewDetails[viewId.toString()] : undefined; return ( -
    +
    - router.back()}> - - -

    {`${projectDetails?.name ?? "Project"} Views`}

    -
    - + + + {renderEmoji(currentProjectDetails.emoji)} + + ) : currentProjectDetails?.icon_prop ? ( +
    + {renderEmoji(currentProjectDetails.icon_prop)} +
    + ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) + } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Views" + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/views`} + /> + + + {viewDetails?.name && truncateText(viewDetails.name, 40)} + + } + className="ml-1.5" + placement="bottom-start" + > + {viewsList?.map((view) => ( + router.push(`/${workspaceSlug}/projects/${projectId}/views/${view.id}`)} + > + {truncateText(view.name, 40)} + + ))} + } />
    - - - {viewDetails?.name && truncateText(viewDetails.name, 40)} - - } - className="ml-1.5" - placement="bottom-start" - > - {viewsList?.map((view) => ( - router.push(`/${workspaceSlug}/projects/${projectId}/views/${view.id}`)} - > - {truncateText(view.name, 40)} - - ))} -
    { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + { activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined} - members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)} - states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} + members={projectMembers?.map((m) => m.member)} + states={projectStateStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + { // router @@ -22,11 +19,7 @@ export const ProjectViewsHeader: React.FC = observer(() => { const [createViewModal, setCreateViewModal] = useState(false); const { project: projectStore } = useMobxStore(); - - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + const { currentProjectDetails } = projectStore; return ( <> @@ -38,31 +31,48 @@ export const ProjectViewsHeader: React.FC = observer(() => { projectId={projectId.toString()} /> )} -
    +
    - router.back()}> - - -

    Projects

    -
    - + + + {renderEmoji(currentProjectDetails.emoji)} + + ) : currentProjectDetails?.icon_prop ? ( +
    + {renderEmoji(currentProjectDetails.icon_prop)} +
    + ) : ( + + {currentProjectDetails?.name.charAt(0)} + + ) } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Views" /> -
    - setCreateViewModal(true)}> - +
    diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 30aba09ea..0702ae22e 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,52 +1,47 @@ import { useRouter } from "next/router"; -import { Search, Plus } from "lucide-react"; +import { Search, Plus, Briefcase } from "lucide-react"; // ui -import { BreadcrumbItem, Breadcrumbs, Button } from "@plane/ui"; -// helper -import { truncateText } from "helpers/string.helper"; +import { Breadcrumbs, Button } from "@plane/ui"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; import { observer } from "mobx-react-lite"; export const ProjectsHeader = observer(() => { const router = useRouter(); + const { workspaceSlug } = router.query; + // store - const { project: projectStore, workspace: workspaceStore } = useMobxStore(); - const currentWorkspace = workspaceStore.currentWorkspace; + const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore(); + + const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : []; return ( -
    +
    - router.back()}> - + } + label="Projects" />
    -
    - - projectStore.setSearchQuery(e.target.value)} - placeholder="Search" - /> -
    + {projectsList?.length > 0 && ( +
    + + projectStore.setSearchQuery(e.target.value)} + placeholder="Search" + /> +
    + )} -
    diff --git a/web/components/headers/user-profile.tsx b/web/components/headers/user-profile.tsx index 870aa1b54..1883e631f 100644 --- a/web/components/headers/user-profile.tsx +++ b/web/components/headers/user-profile.tsx @@ -1,22 +1,33 @@ import { FC } from "react"; - import { useRouter } from "next/router"; // ui -import { BreadcrumbItem, Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs } from "@plane/ui"; +import { UserCircle2 } from "lucide-react"; // hooks import { observer } from "mobx-react-lite"; -export const UserProfileHeader: FC = observer(() => { +export interface IUserProfileHeader { + title: string; +} + +export const UserProfileHeader: FC = observer((props) => { + const { title } = props; const router = useRouter(); + const { workspaceSlug } = router.query; + return ( -
    +
    - router.back()}> - + + } + link={`/${workspaceSlug}/me/profile`} + /> +
    diff --git a/web/components/headers/workspace-analytics.tsx b/web/components/headers/workspace-analytics.tsx index 5a9cf5d9a..e25fc6d5b 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/components/headers/workspace-analytics.tsx @@ -1,7 +1,7 @@ import { useRouter } from "next/router"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, BarChart2 } from "lucide-react"; // ui -import { BreadcrumbItem, Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs } from "@plane/ui"; export const WorkspaceAnalyticsHeader = () => { const router = useRouter(); @@ -9,7 +9,7 @@ export const WorkspaceAnalyticsHeader = () => { return ( <>
    @@ -22,8 +22,12 @@ export const WorkspaceAnalyticsHeader = () => {
    - router.back()}> - + + } + label="Analytics" + />
    diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index 7b9e5a1bf..76bd6bc30 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -7,6 +7,7 @@ import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; // components import { ProductUpdatesModal } from "components/common"; +import { Breadcrumbs } from "@plane/ui"; export const WorkspaceDashboardHeader = () => { const [isProductUpdatesModalOpen, setIsProductUpdatesModalOpen] = useState(false); @@ -16,23 +17,30 @@ export const WorkspaceDashboardHeader = () => { return ( <> -
    -
    - - Dashboard +
    +
    +
    + + } + label="Dashboard" + /> + +
    - + = observer((pro const { workspaceSlug } = router.query; - const { data: activeWorkspace } = useSWR(workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, () => - workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null - ); - return ( -
    +
    diff --git a/web/components/icons/completed-cycle-icon.tsx b/web/components/icons/completed-cycle-icon.tsx index 77d30b24b..d16009ad7 100644 --- a/web/components/icons/completed-cycle-icon.tsx +++ b/web/components/icons/completed-cycle-icon.tsx @@ -2,12 +2,7 @@ import React from "react"; import type { Props } from "./types"; -export const CompletedCycleIcon: React.FC = ({ - width = "24", - height = "24", - className, - color = "black", -}) => ( +export const CompletedCycleIcon: React.FC = ({ width = "24", height = "24", className, color = "black" }) => ( = ({ - width = "24", - height = "24", - className, - color = "black", -}) => ( +export const CurrentCycleIcon: React.FC = ({ width = "24", height = "24", className, color = "black" }) => ( = ({ - width = "24", - height = "24", - className, - color, -}) => ( +export const DocumentIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( = ({ - width = "24", - height = "24", - className, - color = "black", -}) => ( +export const ExternalLinkIcon: React.FC = ({ width = "24", height = "24", className, color = "black" }) => ( = ({ - width = "20", - height = "20", - className, -}) => ( +export const ModuleCancelledIcon: React.FC = ({ width = "20", height = "20", className }) => ( = ({ - width = "20", - height = "20", - className, -}) => ( +export const ModuleCompletedIcon: React.FC = ({ width = "20", height = "20", className }) => ( = ({ - width = "20", - height = "20", - className, -}) => ( +export const ModuleInProgressIcon: React.FC = ({ width = "20", height = "20", className }) => ( = ({ - + = ({ - status, - className, - height = "12px", - width = "12px", -}) => { - if (status === "backlog") - return ; - else if (status === "cancelled") - return ; - else if (status === "completed") - return ; +export const ModuleStatusIcon: React.FC = ({ status, className, height = "12px", width = "12px" }) => { + if (status === "backlog") return ; + else if (status === "cancelled") return ; + else if (status === "completed") return ; else if (status === "in-progress") return ; - else if (status === "paused") - return ; + else if (status === "paused") return ; else return ; }; diff --git a/web/components/icons/pencil-scribble-icon.tsx b/web/components/icons/pencil-scribble-icon.tsx index 561a5bcc3..4d7489049 100644 --- a/web/components/icons/pencil-scribble-icon.tsx +++ b/web/components/icons/pencil-scribble-icon.tsx @@ -2,19 +2,8 @@ import React from "react"; import type { Props } from "./types"; -export const PencilScribbleIcon: React.FC = ({ - width = "20", - height = "20", - className, - color = "#000000", -}) => ( - +export const PencilScribbleIcon: React.FC = ({ width = "20", height = "20", className, color = "#000000" }) => ( + = ({ - width = "24", - height = "24", - className, -}) => ( +export const QuestionMarkCircleIcon: React.FC = ({ width = "24", height = "24", className }) => ( = ({ - width = "24", - height = "24", - className, - color, -}) => ( +export const SingleCommentCard: React.FC = ({ width = "24", height = "24", className, color }) => ( = ({ - width = "24", - height = "24", - className, - color = "black", -}) => ( +export const TagIcon: React.FC = ({ width = "24", height = "24", className, color = "black" }) => ( = ({ - width = "24", - height = "24", - className, -}) => ( +export const TriangleExclamationIcon: React.FC = ({ width = "24", height = "24", className }) => ( = ({ - width = "24", - height = "24", - className, - color = "black", -}) => ( +export const UpcomingCycleIcon: React.FC = ({ width = "24", height = "24", className, color = "black" }) => ( = ({ - width = "24", - height = "24", - className, - color, -}) => ( +export const UserGroupIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( { const { inboxIssues: inboxIssuesStore, inboxIssueDetails: inboxIssueDetailsStore, user: userStore } = useMobxStore(); const user = userStore?.currentUser; + const userRole = userStore.currentProjectRole; const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : null; - const { memberRole } = useProjectMyMembership(); const { setToastAlert } = useToast(); const markInboxStatus = async (data: TInboxStatus) => { @@ -73,7 +71,7 @@ export const InboxActionsHeader = observer(() => { }, [issue]); const issueStatus = issue?.issue_inbox[0].status; - const isAllowed = memberRole.isMember || memberRole.isOwner; + const isAllowed = userRole === 15 || userRole === 20; const today = new Date(); const tomorrow = new Date(today); @@ -162,7 +160,7 @@ export const InboxActionsHeader = observer(() => { Snooze - + {({ close }) => (
    = (props) => {
    diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx index 6cc17abe6..02ead34cb 100644 --- a/web/components/inbox/main-content.tsx +++ b/web/components/inbox/main-content.tsx @@ -7,8 +7,6 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle // mobx store import { useMobxStore } from "lib/mobx/store-provider"; -// contexts -import { useProjectMyMembership } from "contexts/project-member.context"; // components import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction } from "components/issues"; import { InboxIssueActivity } from "components/inbox"; @@ -22,10 +20,10 @@ import { IInboxIssue, IIssue } from "types"; const defaultValues: Partial = { name: "", description_html: "", - assignees_list: [], + assignees: [], priority: "low", target_date: new Date().toString(), - labels_list: [], + labels: [], }; export const InboxMainContent: React.FC = observer(() => { @@ -35,8 +33,7 @@ export const InboxMainContent: React.FC = observer(() => { const { inboxIssues: inboxIssuesStore, inboxIssueDetails: inboxIssueDetailsStore, user: userStore } = useMobxStore(); const user = userStore.currentUser; - - const { memberRole } = useProjectMyMembership(); + const userRole = userStore.currentProjectRole; const { reset, control, watch } = useForm({ defaultValues, @@ -122,8 +119,8 @@ export const InboxMainContent: React.FC = observer(() => { reset({ ...issueDetails, - assignees_list: issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id), - labels_list: issueDetails.labels_list ?? issueDetails.labels, + assignees: issueDetails.assignees ?? (issueDetails.assignee_details ?? []).map((user) => user.id), + labels: issueDetails.labels ?? issueDetails.labels, }); }, [issueDetails, reset, inboxIssueId]); @@ -225,7 +222,7 @@ export const InboxMainContent: React.FC = observer(() => { description_html: issueDetails.description_html, }} handleFormSubmit={submitChanges} - isAllowed={memberRole.isMember || memberRole.isOwner || user?.id === issueDetails.created_by} + isAllowed={userRole === 15 || userRole === 20 || user?.id === issueDetails.created_by} />
    diff --git a/web/components/inbox/modals/accept-issue-modal.tsx b/web/components/inbox/modals/accept-issue-modal.tsx index 11ff416b2..376ccbfdd 100644 --- a/web/components/inbox/modals/accept-issue-modal.tsx +++ b/web/components/inbox/modals/accept-issue-modal.tsx @@ -41,7 +41,7 @@ export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSub leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -55,7 +55,7 @@ export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSub leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -75,10 +75,10 @@ export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSub

    - -
    diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index a468580f4..28bde21e4 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -15,6 +15,7 @@ import { IssuePrioritySelect } from "components/issues/select"; import { Button, Input, ToggleSwitch } from "@plane/ui"; // types import { IIssue } from "types"; +import useEditorSuggestions from "hooks/use-editor-suggestions"; type Props = { isOpen: boolean; @@ -40,6 +41,8 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const editorRef = useRef(null); + const editorSuggestion = useEditorSuggestions(); + const router = useRouter(); const { workspaceSlug, projectId, inboxId } = router.query; @@ -82,7 +85,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -96,7 +99,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +

    Create Inbox Issue

    @@ -134,6 +137,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { control={control} render={({ field: { value, onChange } }) => ( = observer((props) => { onChange={(description, description_html: string) => { onChange(description_html); }} + mentionSuggestions={editorSuggestion.mentionSuggestions} + mentionHighlights={editorSuggestion.mentionHighlights} /> )} /> @@ -169,10 +175,10 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { {}} size="md" />
    - -
    diff --git a/web/components/inbox/modals/decline-issue-modal.tsx b/web/components/inbox/modals/decline-issue-modal.tsx index 50fccdd74..5267f747b 100644 --- a/web/components/inbox/modals/decline-issue-modal.tsx +++ b/web/components/inbox/modals/decline-issue-modal.tsx @@ -41,7 +41,7 @@ export const DeclineIssueModal: React.FC = ({ isOpen, onClose, data, onSu leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -55,7 +55,7 @@ export const DeclineIssueModal: React.FC = ({ isOpen, onClose, data, onSu leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -75,10 +75,10 @@ export const DeclineIssueModal: React.FC = ({ isOpen, onClose, data, onSu

    - -
    diff --git a/web/components/inbox/modals/delete-issue-modal.tsx b/web/components/inbox/modals/delete-issue-modal.tsx index 29129cd70..eb135fb7a 100644 --- a/web/components/inbox/modals/delete-issue-modal.tsx +++ b/web/components/inbox/modals/delete-issue-modal.tsx @@ -78,7 +78,7 @@ export const DeleteInboxIssueModal: React.FC = observer(({ isOpen, onClos leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -92,7 +92,7 @@ export const DeleteInboxIssueModal: React.FC = observer(({ isOpen, onClos leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -112,10 +112,10 @@ export const DeleteInboxIssueModal: React.FC = observer(({ isOpen, onClos

    - -
    diff --git a/web/components/inbox/modals/select-duplicate.tsx b/web/components/inbox/modals/select-duplicate.tsx index ac0c63a95..e746d0df8 100644 --- a/web/components/inbox/modals/select-duplicate.tsx +++ b/web/components/inbox/modals/select-duplicate.tsx @@ -81,7 +81,7 @@ export const SelectDuplicateInboxIssueModal: React.FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -94,7 +94,7 @@ export const SelectDuplicateInboxIssueModal: React.FC = (props) => { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + { @@ -166,10 +166,10 @@ export const SelectDuplicateInboxIssueModal: React.FC = (props) => { {filteredIssues.length > 0 && (
    - -
    diff --git a/web/components/integration/delete-import-modal.tsx b/web/components/integration/delete-import-modal.tsx index 49f9e5c28..8d222c4db 100644 --- a/web/components/integration/delete-import-modal.tsx +++ b/web/components/integration/delete-import-modal.tsx @@ -78,7 +78,7 @@ export const DeleteImportModal: React.FC = ({ isOpen, handleClose, data, leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -92,7 +92,7 @@ export const DeleteImportModal: React.FC = ({ isOpen, handleClose, data, leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -127,11 +127,13 @@ export const DeleteImportModal: React.FC = ({ isOpen, handleClose, data, />
    -
    */} - {IMPORTERS_EXPORTERS_LIST.map((service) => ( + {IMPORTERS_LIST.map((service) => (
    @@ -100,7 +97,7 @@ const IntegrationGuide = () => {
    ))}
    -
    +

    Previous Imports

    -

    - {watch("data.users").filter((user) => user.import).length} -

    +

    {watch("data.users").filter((user) => user.import).length}

    User

    diff --git a/web/components/integration/jira/give-details.tsx b/web/components/integration/jira/give-details.tsx index 622517439..8a7c841de 100644 --- a/web/components/integration/jira/give-details.tsx +++ b/web/components/integration/jira/give-details.tsx @@ -16,15 +16,14 @@ export const JiraGetImportDetail: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug } = router.query; + const { project: projectStore, commandPalette: commandPaletteStore } = useMobxStore(); + const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined; + const { control, formState: { errors }, } = useFormContext(); - const { project: projectStore } = useMobxStore(); - - const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined; - return (
    @@ -190,10 +189,7 @@ export const JiraGetImportDetail: React.FC = observer(() => {
    - - ))} -
    - )} - /> -
    + ( + ( +

    " : commentValue} + customClassName="p-2 h-full" + editorContentCustomClassNames="min-h-[35px]" + debouncedUpdatesEnabled={false} + onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)} + commentAccessSpecifier={ + showAccessSpecifier + ? { accessValue, onAccessChange, showAccessSpecifier, commentAccess } + : undefined + } + mentionSuggestions={editorSuggestions.mentionSuggestions} + mentionHighlights={editorSuggestions.mentionHighlights} + submitButton={ + + } + /> + )} + /> )} - ( - ( -

    " : commentValue} - customClassName="p-3 min-h-[100px] shadow-sm" - debouncedUpdatesEnabled={false} - onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)} - commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }} - /> - )} - /> - )} - /> -
    - - + />
    diff --git a/web/components/issues/comment/comment-card.tsx b/web/components/issues/comment/comment-card.tsx index b38e41ae0..577050572 100644 --- a/web/components/issues/comment/comment-card.tsx +++ b/web/components/issues/comment/comment-card.tsx @@ -15,6 +15,7 @@ import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-te import { timeAgo } from "helpers/date-time.helper"; // types import type { IIssueComment } from "types"; +import useEditorSuggestions from "hooks/use-editor-suggestions"; // services const fileService = new FileService(); @@ -39,6 +40,8 @@ export const CommentCard: React.FC = ({ const editorRef = React.useRef(null); const showEditorRef = React.useRef(null); + const editorSuggestions = useEditorSuggestions(); + const [isEditing, setIsEditing] = useState(false); const { @@ -102,6 +105,7 @@ export const CommentCard: React.FC = ({
    = ({ setValue("comment_json", comment_json); setValue("comment_html", comment_html); }} + mentionSuggestions={editorSuggestions.mentionSuggestions} + mentionHighlights={editorSuggestions.mentionHighlights} />
    -
    - -
    diff --git a/web/components/issues/delete-archived-issue-modal.tsx b/web/components/issues/delete-archived-issue-modal.tsx new file mode 100644 index 000000000..14ecd7edd --- /dev/null +++ b/web/components/issues/delete-archived-issue-modal.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState, Fragment } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Dialog, Transition } from "@headlessui/react"; +import { AlertTriangle } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button } from "@plane/ui"; +// types +import type { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IIssue; + onSubmit?: () => Promise; +}; + +export const DeleteArchivedIssueModal: React.FC = observer((props) => { + const { data, isOpen, handleClose, onSubmit } = props; + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const { archivedIssueDetail: archivedIssueDetailStore } = useMobxStore(); + + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + useEffect(() => { + setIsDeleteLoading(false); + }, [isOpen]); + + const onClose = () => { + setIsDeleteLoading(false); + handleClose(); + }; + + const handleIssueDelete = async () => { + if (!workspaceSlug) return; + + setIsDeleteLoading(true); + + await archivedIssueDetailStore + .deleteArchivedIssue(workspaceSlug.toString(), data.project, data.id) + .then(() => { + if (onSubmit) onSubmit(); + }) + .catch((err) => { + const error = err?.detail; + const errorString = Array.isArray(error) ? error[0] : error; + + setToastAlert({ + title: "Error", + type: "error", + message: errorString || "Something went wrong.", + }); + }) + .finally(() => { + setIsDeleteLoading(false); + onClose(); + }); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    + + + +

    Delete Archived Issue

    +
    +
    + +

    + Are you sure you want to delete issue{" "} + + {data?.project_detail.identifier}-{data?.sequence_id} + + {""}? All of the data related to the archived issue will be permanently removed. This action + cannot be undone. +

    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}); diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx index 4a0b0fd9a..955d8ac78 100644 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ b/web/components/issues/delete-draft-issue-modal.tsx @@ -1,17 +1,12 @@ import React, { useEffect, useState } from "react"; - import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -import useUser from "hooks/use-user"; - -// headless ui +import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // services import { IssueDraftService } from "services/issue"; // hooks -import useIssuesView from "hooks/use-issues-view"; import useToast from "hooks/use-toast"; // icons import { AlertTriangle } from "lucide-react"; @@ -19,8 +14,6 @@ import { AlertTriangle } from "lucide-react"; import { Button } from "@plane/ui"; // types import type { IIssue } from "types"; -// fetch-keys -import { PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys"; type Props = { isOpen: boolean; @@ -31,20 +24,19 @@ type Props = { const issueDraftService = new IssueDraftService(); -export const DeleteDraftIssueModal: React.FC = (props) => { +export const DeleteDraftIssueModal: React.FC = observer((props) => { const { isOpen, handleClose, data, onSubmit } = props; const [isDeleteLoading, setIsDeleteLoading] = useState(false); - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { user: userStore } = useMobxStore(); + const user = userStore.currentUser; - const { params } = useIssuesView(); + const router = useRouter(); + const { workspaceSlug } = router.query; const { setToastAlert } = useToast(); - const { user } = useUser(); - useEffect(() => { setIsDeleteLoading(false); }, [isOpen]); @@ -64,7 +56,7 @@ export const DeleteDraftIssueModal: React.FC = (props) => { .then(() => { setIsDeleteLoading(false); handleClose(); - mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); + setToastAlert({ title: "Success", message: "Draft Issue deleted successfully", @@ -96,7 +88,7 @@ export const DeleteDraftIssueModal: React.FC = (props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -110,7 +102,7 @@ export const DeleteDraftIssueModal: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -131,10 +123,10 @@ export const DeleteDraftIssueModal: React.FC = (props) => {

    - -
    @@ -146,4 +138,4 @@ export const DeleteDraftIssueModal: React.FC = (props) => { ); -}; +}); diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index 41133111e..38d0a33d1 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -58,7 +58,7 @@ export const DeleteIssueModal: React.FC = observer((props) => { leaveFrom="opacity-100" leaveTo="opacity-0" > -
    +
    @@ -72,7 +72,7 @@ export const DeleteIssueModal: React.FC = observer((props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
    @@ -93,10 +93,16 @@ export const DeleteIssueModal: React.FC = observer((props) => {

    - -
    diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index feb63c671..8c6a75d30 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -10,6 +10,7 @@ import { RichTextEditor } from "@plane/rich-text-editor"; import { IIssue } from "types"; // services import { FileService } from "services/file.service"; +import useEditorSuggestions from "hooks/use-editor-suggestions"; export interface IssueDescriptionFormValues { name: string; @@ -20,6 +21,7 @@ export interface IssueDetailsProps { issue: { name: string; description_html: string; + project_id?: string; }; workspaceSlug: string; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise; @@ -36,6 +38,8 @@ export const IssueDescriptionForm: FC = (props) => { const { setShowAlert } = useReloadConfirmations(); + const editorSuggestion = useEditorSuggestions(); + const { handleSubmit, watch, @@ -49,6 +53,14 @@ export const IssueDescriptionForm: FC = (props) => { }, }); + const [localTitleValue, setLocalTitleValue] = useState(""); + const issueTitleCurrentValue = watch("name"); + useEffect(() => { + if (localTitleValue === "" && issueTitleCurrentValue !== "") { + setLocalTitleValue(issueTitleCurrentValue); + } + }, [issueTitleCurrentValue, localTitleValue]); + const handleDescriptionFormSubmit = useCallback( async (formData: Partial) => { if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; @@ -81,7 +93,7 @@ export const IssueDescriptionForm: FC = (props) => { }); }, [issue, reset]); - const debouncedTitleSave = useDebouncedCallback(async () => { + const debouncedFormSave = useDebouncedCallback(async () => { handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); }, 1500); @@ -92,20 +104,21 @@ export const IssueDescriptionForm: FC = (props) => { ( + render={({ field: { onChange } }) => (