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 7568602d3..dcb8b8671 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,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/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 0bfcfd701..5e60d87f8 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -67,3 +67,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 e43bd6219..9955b7aaf 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,4 +3,4 @@ set -e python manage.py wait_for_db python manage.py migrate -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/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..f061a0a19 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -8,8 +8,7 @@ from rest_framework import serializers from .base import BaseSerializer 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 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/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..e4f3718f5 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -1,7 +1,7 @@ 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 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/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/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..8f4b2fb9d 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,7 +51,6 @@ from .workspace import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, - WorkspaceMembersEndpoint, LeaveWorkspaceEndpoint, ) from .state import StateViewSet @@ -169,4 +166,4 @@ from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkA 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..a06a7f7fc 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,5 @@ 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) 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..02a583196 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, @@ -480,13 +479,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 +495,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 +513,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 +665,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/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..104bdafe2 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) @@ -443,9 +439,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 +617,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 +773,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 +1099,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 +1405,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 +1530,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 +1625,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 +1719,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 +2165,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 +2230,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..0e3454932 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -266,12 +266,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 +281,7 @@ class ModuleViewSet(BaseViewSet): current_instance=None, epoch=int(timezone.now().timestamp()), ) + module.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -297,12 +298,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 +441,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 04c83813a..f0ea9acc9 100644 --- a/apiserver/plane/api/views/oauth.py +++ b/apiserver/plane/api/views/oauth.py @@ -11,7 +11,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 @@ -113,7 +112,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 @@ -147,7 +146,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." @@ -158,7 +157,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: @@ -182,7 +180,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() @@ -233,7 +231,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/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 a041fd169..a80770c37 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -408,7 +408,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 93b15c425..9f9d06437 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -23,7 +23,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/export_task.py b/apiserver/plane/bgtasks/export_task.py index a45120eb5..1329697e9 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -4,7 +4,6 @@ import io import json import boto3 import zipfile -from urllib.parse import urlparse, urlunparse # Django imports from django.conf import settings diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 95828765c..de1390f01 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -8,8 +8,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 @@ -21,7 +19,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 20dc65e51..14bece21b 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -2,8 +2,6 @@ import json import requests import uuid -import jwt -from datetime import datetime # Django imports from django.conf import settings @@ -27,7 +25,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 @@ -58,7 +55,7 @@ def service_importer(service, importer_id): ignore_conflicts=True, ) - [ + _ = [ send_welcome_slack.delay( str(user.id), True, @@ -157,7 +154,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, @@ -179,7 +176,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 1dbaf8bc9..4776bceab 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -82,7 +82,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"]) @@ -131,7 +131,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, @@ -276,7 +276,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 @@ -334,9 +334,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 @@ -363,17 +361,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, + ) ) @@ -418,36 +418,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( @@ -523,8 +524,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, @@ -536,7 +537,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, @@ -690,6 +691,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( @@ -712,6 +717,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( @@ -746,22 +755,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, ) ) @@ -793,6 +807,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( @@ -815,6 +833,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"), @@ -848,22 +870,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, ) ) @@ -1451,15 +1478,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, @@ -1534,6 +1562,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 68c64403a..4d77eb124 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -59,7 +59,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: @@ -71,14 +71,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 91cc461bb..71f6db8da 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -17,7 +17,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..4380f4ee9 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -5,16 +5,107 @@ import json from django.utils import timezone # 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, +) # Third Party imports from celery import shared_task +from bs4 import BeautifulSoup + + +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=mention_id, + project=project_id, + ).exists(): + mentioned_user = User.objects.get(pk=mention_id) + + project = Project.objects.get(pk=project_id) + issue = Issue.objects.get(pk=issue_id) + + bulk_mention_subscribers.append(IssueSubscriber( + workspace=project.workspace, + project=project, + issue=issue, + subscriber=mentioned_user, + )) + 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 [] @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,14 +124,35 @@ 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) + + # 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) + 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 + [actor_id])) .values_list("subscriber", flat=True) ) issue_assignees = list( - IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id) + IssueAssignee.objects.filter( + project_id=project_id, issue_id=issue_id) .exclude(assignee_id=actor_id) .values_list("assignee", flat=True) ) @@ -62,6 +174,9 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi for subscriber in list(set(issue_subscribers)): 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, @@ -89,7 +204,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 +213,62 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi ) ) + # Add Mentioned as Issue Subscribers + IssueSubscriber.objects.bulk_create( + mention_subscribers, batch_size=100) + + for mention_id in new_mentions: + if (mention_id != actor_id): + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mention", + 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(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), + }, + }, + ) + ) + + # Create New Mentions Here + aggregated_issue_mentions = [] + + for mention_id in new_mentions: + mentioned_user = User.objects.get(pk=mention_id) + aggregated_issue_mentions.append( + IssueMention( + mention=mentioned_user, + issue=issue, + project=project, + workspace=project.workspace + ) + ) + + IssueMention.objects.bulk_create( + aggregated_issue_mentions, batch_size=100) + IssueMention.objects.filter( + issue=issue, mention__in=removed_mention).delete() + # 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 d84a0b414..94be6f879 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -11,7 +11,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/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 b3183b2ed..c4e7cec22 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("api/licenses/", include("plane.license.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 969ab3c89..249b29d48 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -33,4 +33,5 @@ django_celery_beat==2.5.0 psycopg-binary==3.1.10 psycopg-c==3.1.10 scout-apm==2.26.1 -openpyxl==3.1.2 \ No newline at end of file +openpyxl==3.1.2 +beautifulsoup4==4.12.2 \ No newline at end of file diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 945ebd3c7..3bb65f7f5 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 3ee92ae23..13bc0977f 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -59,3 +59,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/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/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts new file mode 100644 index 000000000..9c9ab7606 --- /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 \ No newline at end of file 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..d0531da01 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; @@ -10,10 +9,8 @@ interface EditorContentProps { } export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => ( -
- {/* @ts-ignore */} +
- {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..5e86475cf 100644 --- a/packages/editor/core/src/ui/extensions/image/image-resize.tsx +++ b/packages/editor/core/src/ui/extensions/image/image-resize.tsx @@ -23,8 +23,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 +33,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/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 62d53e0e1..a7621ab20 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -8,18 +8,21 @@ 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, ) => [ StarterKit.configure({ @@ -92,6 +95,7 @@ export const CoreEditorExtensions = ( }), Table, TableHeader, - CustomTableCell, + 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..b39fe7104 --- /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..ac43875da --- /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..57137dedd --- /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..712ca65f0 --- /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..9ecc2c0ae --- /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..e922e7fa1 --- /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..d3159d4aa --- /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..5dbd0f38a --- /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..f5ec958a4 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/table-controls.ts @@ -0,0 +1,117 @@ +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..6e3f9318e --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx @@ -0,0 +1,530 @@ +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", + 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", + 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("button", { + onClick: () => this.selectRow(), + }), + ); + + this.columnsControl = h( + "div", + { className: "columnsControl" }, + h("button", { + onClick: () => this.selectColumn(), + }), + ); + + this.controls = h( + "div", + { className: "tableControls", contentEditable: "false" }, + this.rowsControl, + this.columnsControl, + ); + + this.columnsToolbox = createToolbox({ + triggerButton: this.columnsControl.querySelector("button"), + 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..eab3cad92 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -0,0 +1,298 @@ +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..a3d7f2da8 --- /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..75bf7cb41 --- /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..dcb20b323 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts @@ -0,0 +1,39 @@ +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..293878cb0 --- /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..3c36bf055 --- /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..9fcf200fb 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,43 @@ interface CustomEditorProps { extensions?: any; editorProps?: EditorProps; forwardedRef?: any; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; } -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, + 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), ...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,15 +77,9 @@ 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; } return editor; -}; +}; \ No newline at end of file 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..9243c2f4e 100644 --- a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx @@ -1,27 +1,44 @@ 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 { 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 +51,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..c3bfa3703 --- /dev/null +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -0,0 +1,55 @@ +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..ba1a9ed0b --- /dev/null +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -0,0 +1,15 @@ +// @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..b4bbc53a6 --- /dev/null +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -0,0 +1,59 @@ +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/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/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx index ba21d686d..56284472b 100644 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ b/packages/editor/core/src/ui/plugins/delete-image.tsx @@ -16,7 +16,7 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => new Plugin({ key: deleteKey, appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); + const newImageSources = new Set(); newState.doc.descendants((node) => { if (node.type.name === IMAGE_NODE_TYPE) { newImageSources.add(node.attrs.src); diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 2246c64f9..8901d34c5 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -8,15 +8,19 @@ 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: { @@ -87,6 +91,7 @@ export const CoreReadOnlyEditorExtensions = [ }), Table, TableHeader, - CustomTableCell, + TableCell, TableRow, + Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true), ]; diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 47ef154c6..3b6cd720b 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", @@ -29,9 +30,6 @@ "dependencies": { "@plane/editor-core": "*", "@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 +44,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..392928ccf 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..ef321d511 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; @@ -38,6 +47,9 @@ interface ILiteTextEditor { }[]; }; onEnterKeyPress?: (e?: any) => void; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; + submitButton?: React.ReactNode; } interface LiteTextEditorProps extends ILiteTextEditor { @@ -49,22 +61,27 @@ 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, + debouncedUpdatesEnabled, + setIsSubmitting, + setShouldShowAlert, + editorContentCustomClassNames, + value, + uploadFile, + deleteFile, + noBorder, + borderOnFocus, + customClassName, + forwardedRef, + commentAccessSpecifier, + onEnterKeyPress, + mentionHighlights, + mentionSuggestions, + submitButton, + } = props; + const editor = useEditor({ onChange, debouncedUpdatesEnabled, @@ -75,6 +92,8 @@ const LiteTextEditor = ({ deleteFile, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), + mentionHighlights, + mentionSuggestions, }); const editorClassNames = getEditorClassNames({ @@ -98,6 +117,7 @@ const LiteTextEditor = ({ uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} commentAccessSpecifier={commentAccessSpecifier} + submitButton={submitButton} />
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..cf0d78688 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,7 +14,6 @@ import { TableItem, UnderLineItem, } from "@plane/editor-core"; -import { Icon } from "./icon"; import { Tooltip } from "../../tooltip"; import { UploadImage } from "../.."; @@ -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/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..e296a6171 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/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/read-only/index.tsx b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx index dc058cf89..46905f263 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 @@ -8,6 +8,7 @@ interface IRichTextReadOnlyEditor { noBorder?: boolean; borderOnFocus?: boolean; customClassName?: string; + mentionHighlights?: string[]; } interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor { @@ -26,10 +27,12 @@ const RichReadOnlyEditor = ({ customClassName, value, forwardedRef, + mentionHighlights, }: RichTextReadOnlyEditorProps) => { const editor = useReadOnlyEditor({ value, forwardedRef, + mentionHighlights, }); const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName }); 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..efc47b4c7 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: { 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..674d82a26 --- /dev/null +++ b/packages/ui/src/avatar/avatar.tsx @@ -0,0 +1,173 @@ +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/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/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 4c9a92d4d..eac53b6e6 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -1,3 +1,4 @@ +// FIXME: fix this!!! import { Placement } from "@blueprintjs/popover2"; export interface IDropdownProps { diff --git a/packages/ui/src/dropdowns/index.tsx b/packages/ui/src/dropdowns/index.ts similarity index 100% rename from packages/ui/src/dropdowns/index.tsx rename to packages/ui/src/dropdowns/index.ts diff --git a/packages/ui/src/form-fields/index.tsx b/packages/ui/src/form-fields/index.ts similarity index 100% rename from packages/ui/src/form-fields/index.tsx rename to packages/ui/src/form-fields/index.ts diff --git a/packages/ui/src/form-fields/input-color-picker.tsx b/packages/ui/src/form-fields/input-color-picker.tsx index 738d0e0c8..0a91a8838 100644 --- a/packages/ui/src/form-fields/input-color-picker.tsx +++ b/packages/ui/src/form-fields/input-color-picker.tsx @@ -11,12 +11,14 @@ export interface InputColorPickerProps { value: string | undefined; onChange: (value: string) => void; name: string; - className: string; + className?: string; + style?: React.CSSProperties; placeholder: string; } export const InputColorPicker: React.FC = (props) => { - const { value, hasError, onChange, name, className, placeholder } = props; + const { value, hasError, onChange, name, className, style, placeholder } = + props; const [referenceElement, setReferenceElement] = React.useState(null); @@ -32,12 +34,12 @@ export const InputColorPicker: React.FC = (props) => { onChange(hex); }; - const handleInputChange = (value: any) => { - onChange(value); + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e.target.value); }; return ( -
    +
    = (props) => { onChange={handleInputChange} hasError={hasError} placeholder={placeholder} - className={`border-none ${className}`} + className={`border-[0.5px] border-custom-border-200 ${className}`} + style={style} /> - + {({ open }) => { if (open) { } @@ -60,26 +66,26 @@ export const InputColorPicker: React.FC = (props) => { ref={setReferenceElement} variant="neutral-primary" size="sm" - className="border-none !p-1.5" + className="border-none !bg-transparent" > - {value && value !== "" ? ( - - ) : ( - - - - )} + + + + + + + = ({ +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 new file mode 100644 index 000000000..53ea05906 --- /dev/null +++ b/packages/ui/src/icons/cycle/circle-dot-full-icon.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { ISvgIcons } from "../type"; + +export const CircleDotFullIcon: React.FC = ({ + className = "text-current", + ...rest +}) => ( + + + + +); diff --git a/packages/ui/src/icons/contrast-icon.tsx b/packages/ui/src/icons/cycle/contrast-icon.tsx similarity index 95% rename from packages/ui/src/icons/contrast-icon.tsx rename to packages/ui/src/icons/cycle/contrast-icon.tsx index 99316dbe0..7b51fd1e7 100644 --- a/packages/ui/src/icons/contrast-icon.tsx +++ b/packages/ui/src/icons/cycle/contrast-icon.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { ISvgIcons } from "./type"; +import { ISvgIcons } from "../type"; export const ContrastIcon: React.FC = ({ className = "text-current", diff --git a/packages/ui/src/icons/cycle/cycle-group-icon.tsx b/packages/ui/src/icons/cycle/cycle-group-icon.tsx new file mode 100644 index 000000000..731d90702 --- /dev/null +++ b/packages/ui/src/icons/cycle/cycle-group-icon.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; + +import { ContrastIcon } from "./contrast-icon"; +import { CircleDotFullIcon } from "./circle-dot-full-icon"; +import { CircleDotDashed, Circle } from "lucide-react"; + +import { CYCLE_GROUP_COLORS, ICycleGroupIcon } from "./helper"; + +const iconComponents = { + current: ContrastIcon, + upcoming: CircleDotDashed, + completed: CircleDotFullIcon, + draft: Circle, +}; + +export const CycleGroupIcon: React.FC = ({ + className = "", + color, + cycleGroup, + height = "12px", + width = "12px", +}) => { + const CycleIconComponent = iconComponents[cycleGroup] || ContrastIcon; + + return ( + + ); +}; diff --git a/packages/ui/src/icons/double-circle-icon.tsx b/packages/ui/src/icons/cycle/double-circle-icon.tsx similarity index 91% rename from packages/ui/src/icons/double-circle-icon.tsx rename to packages/ui/src/icons/cycle/double-circle-icon.tsx index b5ced3f8a..a191b71a6 100644 --- a/packages/ui/src/icons/double-circle-icon.tsx +++ b/packages/ui/src/icons/cycle/double-circle-icon.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { ISvgIcons } from "./type"; +import { ISvgIcons } from "../type"; export const DoubleCircleIcon: React.FC = ({ className = "text-current", diff --git a/packages/ui/src/icons/cycle/helper.tsx b/packages/ui/src/icons/cycle/helper.tsx new file mode 100644 index 000000000..ec91cc2c2 --- /dev/null +++ b/packages/ui/src/icons/cycle/helper.tsx @@ -0,0 +1,18 @@ +export interface ICycleGroupIcon { + className?: string; + color?: string; + cycleGroup: TCycleGroups; + height?: string; + width?: string; +} + +export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; + +export const CYCLE_GROUP_COLORS: { + [key in TCycleGroups]: string; +} = { + current: "#F59E0B", + upcoming: "#3F76FF", + completed: "#16A34A", + draft: "#525252", +}; diff --git a/packages/ui/src/icons/cycle/index.ts b/packages/ui/src/icons/cycle/index.ts new file mode 100644 index 000000000..e74c8ff8c --- /dev/null +++ b/packages/ui/src/icons/cycle/index.ts @@ -0,0 +1,5 @@ +export * from "./double-circle-icon"; +export * from "./circle-dot-full-icon"; +export * from "./contrast-icon"; +export * from "./circle-dot-full-icon"; +export * from "./cycle-group-icon"; 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 80% rename from packages/ui/src/icons/index.tsx rename to packages/ui/src/icons/index.ts index 2e98e7138..4cb4e30f7 100644 --- a/packages/ui/src/icons/index.tsx +++ b/packages/ui/src/icons/index.ts @@ -1,5 +1,4 @@ export * from "./user-group-icon"; -export * from "./contrast-icon"; export * from "./dice-icon"; export * from "./layers-icon"; export * from "./photo-filter-icon"; @@ -7,19 +6,19 @@ export * from "./archive-icon"; export * from "./admin-profile-icon"; export * from "./create-icon"; export * from "./subscribe-icon"; -export * from "./double-circle-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"; export * from "./blocker-icon"; export * from "./related-icon"; export * from "./module"; +export * from "./cycle"; export * from "./github-icon"; export * from "./discord-icon"; export * from "./transfer-icon"; 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/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..f70a2c5aa 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, @@ -85,27 +84,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..29801c9e6 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); @@ -135,6 +138,7 @@ export const CommentCard: React.FC = 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..9b8634416 100644 --- a/space/components/issues/peek-overview/issue-details.tsx +++ b/space/components/issues/peek-overview/issue-details.tsx @@ -2,27 +2,33 @@ 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/store/mentions.store.ts b/space/store/mentions.store.ts new file mode 100644 index 000000000..ca4a1a3c1 --- /dev/null +++ b/space/store/mentions.store.ts @@ -0,0 +1,45 @@ +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] : [] + } +} \ No newline at end of file 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..ad88fd10e --- /dev/null +++ b/space/styles/table.css @@ -0,0 +1,194 @@ +.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 > button { + 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 > button { + 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 button { + 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/analytics/custom-analytics/graph/index.tsx b/web/components/analytics/custom-analytics/graph/index.tsx index 9c9fff9ad..181eec8bd 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,50 @@ 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 ? "-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/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..966892595 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -29,7 +29,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
    +
    @@ -38,7 +38,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.

    @@ -54,7 +54,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC {projectDetails?.archive_in !== 0 && (
    -
    +
    Auto-archive issues that are closed for
    = ({ projectDetails, handleCha handleChange={handleChange} /> -
    +
    @@ -82,7 +82,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.

    @@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha {projectDetails?.close_in !== 0 && (
    -
    +
    Auto-close issues that are inactive for
    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..2d65bd58a 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -144,7 +144,7 @@ export const CommandModal: React.FC = (props) => { } else { updatedAssignees.push(assignee); } - updateIssue({ assignees_list: updatedAssignees }); + updateIssue({ assignees: updatedAssignees }); }; const redirect = (path: string) => { diff --git a/web/components/command-palette/issue/change-issue-assignee.tsx b/web/components/command-palette/issue/change-issue-assignee.tsx index d7ed90bd6..2d655571c 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,23 @@ 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; + const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; - const { members } = useProjectMembers(workspaceSlug as string, projectId as string); + const { project: projectStore } = useMobxStore(); + const members = projectId ? projectStore.members?.[projectId.toString()] : undefined; const options = - members?.map(({ member }: any) => ({ + members?.map(({ member }) => ({ value: member.id, query: member.display_name, content: ( <>
    - + {member.display_name}
    {issue.assignees.includes(member.id) && ( @@ -79,7 +82,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 +90,7 @@ export const ChangeIssueAssignee: FC = ({ setIsPaletteOpen, issue, user } updatedAssignees.push(assignee); } - updateIssue({ assignees_list: updatedAssignees }); + updateIssue({ assignees: updatedAssignees }); setIsPaletteOpen(false); }; @@ -104,4 +107,4 @@ export const ChangeIssueAssignee: FC = ({ setIsPaletteOpen, issue, user } ))} ); -}; +}); diff --git a/web/components/common/empty-state.tsx b/web/components/common/empty-state.tsx index 149d76540..e749600ae 100644 --- a/web/components/common/empty-state.tsx +++ b/web/components/common/empty-state.tsx @@ -3,7 +3,7 @@ import React from "react"; import Image from "next/image"; // ui -import { PrimaryButton } from "components/ui"; +import { Button } from "@plane/ui"; type Props = { title: string; @@ -33,10 +33,14 @@ export const EmptyState: React.FC = ({ {description &&

    {description}

    }
    {primaryButton && ( - - {primaryButton.icon} + )} {secondaryButton}
    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/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..c2bc466d5 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(() => diff --git a/web/components/core/modals/existing-issues-list-modal.tsx b/web/components/core/modals/existing-issues-list-modal.tsx index 799968791..663dbfc83 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({ diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index ff8d30927..2437b31a3 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -15,6 +15,7 @@ type Props = { export const LinksList: React.FC = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + return ( <> {links.map((link) => ( diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index bb6690172..66a841205 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -5,15 +5,13 @@ import Image from "next/image"; import { Tab } from "@headlessui/react"; // hooks import useLocalStorage from "hooks/use-local-storage"; -import useIssuesView from "hooks/use-issues-view"; // images import emptyLabel from "public/empty-state/empty_label.svg"; import emptyMembers from "public/empty-state/empty_members.svg"; // components -import { StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "components/core"; // ui -import { Avatar } from "components/ui"; +import { Avatar, StateGroupIcon } from "@plane/ui"; // types import { IModule, @@ -36,7 +34,7 @@ type Props = { module?: IModule; roundedTab?: boolean; noBackground?: boolean; - isPeekModuleDetails?: boolean; + isPeekView?: boolean; }; export const SidebarProgressStats: React.FC = ({ @@ -46,10 +44,8 @@ export const SidebarProgressStats: React.FC = ({ module, roundedTab, noBackground, - isPeekModuleDetails = false, + isPeekView = false, }) => { - const { filters, setFilters } = useIssuesView(); - const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); const currentValue = (tab: string | null) => { @@ -138,34 +134,25 @@ export const SidebarProgressStats: React.FC = ({ key={assignee.assignee_id} title={
    - + {assignee.display_name}
    } completed={assignee.completed_issues} total={assignee.total_issues} - {...(!isPeekModuleDetails && { + {...(!isPeekView && { onClick: () => { - if (filters?.assignees?.includes(assignee.assignee_id ?? "")) - setFilters({ - assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), - }); - else - setFilters({ - assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], - }); + // TODO: set filters here + // if (filters?.assignees?.includes(assignee.assignee_id ?? "")) + // setFilters({ + // assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), + // }); + // else + // setFilters({ + // assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], + // }); }, - selected: filters?.assignees?.includes(assignee.assignee_id ?? ""), + // selected: filters?.assignees?.includes(assignee.assignee_id ?? ""), })} /> ); @@ -213,15 +200,16 @@ export const SidebarProgressStats: React.FC = ({ } completed={label.completed_issues} total={label.total_issues} - {...(!isPeekModuleDetails && { + {...(!isPeekView && { + // TODO: set filters here onClick: () => { - if (filters.labels?.includes(label.label_id ?? "")) - setFilters({ - labels: filters?.labels?.filter((l) => l !== label.label_id), - }); - else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); + // if (filters.labels?.includes(label.label_id ?? "")) + // setFilters({ + // labels: filters?.labels?.filter((l) => l !== label.label_id), + // }); + // else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); }, - selected: filters?.labels?.includes(label.label_id ?? ""), + // selected: filters?.labels?.includes(label.label_id ?? ""), })} /> )) diff --git a/web/components/core/theme/custom-theme-selector.tsx b/web/components/core/theme/custom-theme-selector.tsx index dea5557d5..4ed495c5b 100644 --- a/web/components/core/theme/custom-theme-selector.tsx +++ b/web/components/core/theme/custom-theme-selector.tsx @@ -1,27 +1,40 @@ -import { FC } from "react"; -import { useTheme } from "next-themes"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; +import { useTheme } from "next-themes"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // ui import { Button, InputColorPicker } from "@plane/ui"; // types import { IUserTheme } from "types"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -type Props = {}; +const inputRules = { + required: "Background color is required", + minLength: { + value: 7, + message: "Enter a valid hex code of 6 characters", + }, + maxLength: { + value: 7, + message: "Enter a valid hex code of 6 characters", + }, + pattern: { + value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, + message: "Enter a valid hex code of 6 characters", + }, +}; -export const CustomThemeSelector: FC = observer(() => { +export const CustomThemeSelector: React.FC = observer(() => { const { user: userStore } = useMobxStore(); const userTheme = userStore?.currentUser?.theme; // hooks const { setTheme } = useTheme(); const { + control, formState: { errors, isSubmitting }, handleSubmit, - control, + watch, } = useForm({ defaultValues: { background: userTheme?.background !== "" ? userTheme?.background : "#0d101b", @@ -51,100 +64,151 @@ export const CustomThemeSelector: FC = observer(() => { return userStore.updateCurrentUser({ theme: payload }); }; + const handleValueChange = (val: string | undefined, onChange: any) => { + let hex = val; + + // prepend a hashtag if it doesn't exist + if (val && val[0] !== "#") hex = `#${val}`; + + onChange(hex); + }; + return (

    Customize your theme

    -
    +

    Background color

    - ( - - )} - /> +
    + ( + handleValueChange(val, onChange)} + placeholder="#0d101b" + className="w-full" + style={{ + backgroundColor: value, + color: watch("text"), + }} + hasError={Boolean(errors?.background)} + /> + )} + /> + {errors.background &&

    {errors.background.message}

    } +

    Text color

    - ( - - )} - /> +
    + ( + handleValueChange(val, onChange)} + placeholder="#c5c5c5" + className="w-full" + style={{ + backgroundColor: watch("background"), + color: value, + }} + hasError={Boolean(errors?.text)} + /> + )} + /> + {errors.text &&

    {errors.text.message}

    } +

    Primary(Theme) color

    - ( - - )} - /> +
    + ( + handleValueChange(val, onChange)} + placeholder="#3f76ff" + className="w-full" + style={{ + backgroundColor: value, + color: watch("text"), + }} + hasError={Boolean(errors?.primary)} + /> + )} + /> + {errors.primary &&

    {errors.primary.message}

    } +

    Sidebar background color

    - ( - +
    + ( + handleValueChange(val, onChange)} + placeholder="#0d101b" + className="w-full" + style={{ + backgroundColor: value, + color: watch("sidebarText"), + }} + hasError={Boolean(errors?.sidebarBackground)} + /> + )} + /> + {errors.sidebarBackground && ( +

    {errors.sidebarBackground.message}

    )} - /> +

    Sidebar text color

    - ( - - )} - /> +
    + ( + handleValueChange(val, onChange)} + placeholder="#c5c5c5" + className="w-full" + style={{ + backgroundColor: watch("sidebarBackground"), + color: value, + }} + hasError={Boolean(errors?.sidebarText)} + /> + )} + /> + {errors.sidebarText &&

    {errors.sidebarText.message}

    } +
    diff --git a/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx b/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx deleted file mode 100644 index 785eb3c5a..000000000 --- a/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect } from "react"; - -// react hook form -import { useFormContext } from "react-hook-form"; - -// hooks -import useProjectDetails from "hooks/use-project-details"; - -// components -import { InlineCreateIssueFormWrapper } from "components/core"; - -// types -import { IIssue } from "types"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - onSuccess?: (data: IIssue) => Promise | void; - prePopulatedData?: Partial; -}; - -const InlineInput = () => { - const { projectDetails } = useProjectDetails(); - - const { register, setFocus } = useFormContext(); - - useEffect(() => { - setFocus("name"); - }, [setFocus]); - - return ( - <> -
    -

    {projectDetails?.identifier ?? "..."}

    - - - ); -}; - -export const GanttInlineCreateIssueForm: React.FC = (props) => ( - <> - - - - {props.isOpen && ( -

    - Press {"'"}Enter{"'"} to add another issue -

    - )} - -); diff --git a/web/components/core/views/index.ts b/web/components/core/views/index.ts deleted file mode 100644 index 3323690a1..000000000 --- a/web/components/core/views/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./inline-issue-create-wrapper"; diff --git a/web/components/core/views/inline-issue-create-wrapper.tsx b/web/components/core/views/inline-issue-create-wrapper.tsx deleted file mode 100644 index b474cb74c..000000000 --- a/web/components/core/views/inline-issue-create-wrapper.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useRouter } from "next/router"; -import { mutate } from "swr"; -import { useForm, FormProvider } from "react-hook-form"; -import { Transition } from "@headlessui/react"; -// services -import { ModuleService } from "services/module.service"; -import { IssueService, IssueDraftService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -import useUser from "hooks/use-user"; -import useKeypress from "hooks/use-keypress"; -import useIssuesView from "hooks/use-issues-view"; -import useMyIssues from "hooks/my-issues/use-my-issues"; -import useGanttChartIssues from "hooks/gantt-chart/issue-view"; -// import useCalendarIssuesView from "hooks/use-calendar-issues-view"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; -// helpers -import { getFetchKeysForIssueMutation } from "helpers/string.helper"; - -// fetch-keys -import { - USER_ISSUE, - SUB_ISSUES, - CYCLE_ISSUES_WITH_PARAMS, - MODULE_ISSUES_WITH_PARAMS, - CYCLE_DETAILS, - MODULE_DETAILS, - PROJECT_ISSUES_LIST_WITH_PARAMS, - PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, -} from "constants/fetch-keys"; - -// types -import { IIssue } from "types"; - -const defaultValues: Partial = { - name: "", -}; - -type Props = { - isOpen: boolean; - handleClose: () => void; - onSuccess?: (data: IIssue) => Promise | void; - prePopulatedData?: Partial; - className?: string; - children?: React.ReactNode; -}; - -const issueService = new IssueService(); -const issueDraftService = new IssueDraftService(); -const moduleService = new ModuleService(); - -export const addIssueToCycle = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - cycleId: string, - user: any, - params: any -) => { - if (!workspaceSlug || !projectId) return; - - await issueService - .addIssueToCycle( - workspaceSlug as string, - projectId.toString(), - cycleId, - { - issues: [issueId], - }, - user - ) - .then(() => { - if (cycleId) { - mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params)); - mutate(CYCLE_DETAILS(cycleId as string)); - } - }); -}; - -export const addIssueToModule = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - moduleId: string, - user: any, - params: any -) => { - await moduleService - .addIssuesToModule( - workspaceSlug as string, - projectId.toString(), - moduleId as string, - { - issues: [issueId], - }, - user - ) - .then(() => { - if (moduleId) { - mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); - mutate(MODULE_DETAILS(moduleId as string)); - } - }); -}; - -export const InlineCreateIssueFormWrapper: React.FC = (props) => { - const { isOpen, handleClose, onSuccess, prePopulatedData, children, className } = props; - - const ref = useRef(null); - - const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; - - const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; - - const { user } = useUser(); - - const { setToastAlert } = useToast(); - - const { displayFilters, params } = useIssuesView(); - // const { params: calendarParams } = useCalendarIssuesView(); - const { ...viewGanttParams } = params; - // const { params: spreadsheetParams } = useSpreadsheetIssuesView(); - const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); - const { params: ganttParams } = useGanttChartIssues(workspaceSlug?.toString(), projectId?.toString()); - - const method = useForm({ defaultValues }); - const { - reset, - handleSubmit, - getValues, - formState: { errors, isSubmitting }, - } = method; - - useOutsideClickDetector(ref, handleClose); - useKeypress("Escape", handleClose); - - useEffect(() => { - const values = getValues(); - - if (prePopulatedData) reset({ ...defaultValues, ...values, ...prePopulatedData }); - }, [reset, prePopulatedData, getValues]); - - useEffect(() => { - if (!isOpen) reset({ ...defaultValues }); - }, [isOpen, reset]); - - useEffect(() => { - if (!errors) return; - - Object.keys(errors).forEach((key) => { - const error = errors[key as keyof IIssue]; - - setToastAlert({ - type: "error", - title: "Error!", - message: error?.message?.toString() || "Some error occurred. Please try again.", - }); - }); - }, [errors, setToastAlert]); - - const { ganttFetchKey } = getFetchKeysForIssueMutation({ - cycleId: cycleId, - moduleId: moduleId, - viewId: viewId, - projectId: projectId?.toString() ?? "", - viewGanttParams, - ganttParams, - }); - - const onSubmitHandler = async (formData: IIssue) => { - if (!workspaceSlug || !projectId || !user || isSubmitting) return; - - reset({ ...defaultValues }); - - await (!isDraftIssues - ? issueService.createIssue(workspaceSlug.toString(), projectId.toString(), formData, user) - : issueDraftService.createDraftIssue(workspaceSlug.toString(), projectId.toString(), formData) - ) - .then(async (res) => { - await mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)); - if (formData.cycle && formData.cycle !== "") - await addIssueToCycle(workspaceSlug.toString(), projectId.toString(), res.id, formData.cycle, user, params); - if (formData.module && formData.module !== "") - await addIssueToModule(workspaceSlug.toString(), projectId.toString(), res.id, formData.module, user, params); - - if (isDraftIssues) await mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId.toString() ?? "", params)); - if (displayFilters.layout === "gantt_chart") await mutate(ganttFetchKey); - if (groupedIssues) await mutateMyIssues(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - - if (onSuccess) await onSuccess(res); - - if (formData.assignees_list?.some((assignee) => assignee === user?.id)) - mutate(USER_ISSUE(workspaceSlug as string)); - - if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent)); - }) - .catch((err) => { - Object.keys(err || {}).forEach((key) => { - const error = err?.[key]; - const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; - - setToastAlert({ - type: "error", - title: "Error!", - message: errorTitle || "Some error occurred. Please try again.", - }); - }); - }); - }; - - return ( - <> - - - - {children} - - - - - ); -}; diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 1ececf4dc..fc89ac3fb 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -1,16 +1,14 @@ import { MouseEvent } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; -// services -import { CycleService } from "services/cycle.service"; +import useSWR from "swr"; // hooks import useToast from "hooks/use-toast"; import { useMobxStore } from "lib/mobx/store-provider"; // ui -import { AssigneesList } from "components/ui/avatar"; import { SingleProgressStats } from "components/core"; import { + AvatarGroup, Loader, Tooltip, LinearProgressIndicator, @@ -19,6 +17,7 @@ import { LayersIcon, StateGroupIcon, PriorityIcon, + Avatar, } from "@plane/ui"; // components import ProgressChart from "components/core/sidebar/progress-chart"; @@ -31,9 +30,7 @@ import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } fro import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import { truncateText } from "helpers/string.helper"; // types -import { ICycle, IIssue } from "types"; -// fetch-keys -import { CURRENT_CYCLE_LIST, CYCLES_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; +import { ICycle } from "types"; const stateGroups = [ { @@ -69,9 +66,6 @@ interface IActiveCycleDetails { } export const ActiveCycleDetails: React.FC = (props) => { - // services - const cycleService = new CycleService(); - const router = useRouter(); const { workspaceSlug, projectId } = props; @@ -306,7 +300,11 @@ export const ActiveCycleDetails: React.FC = (props) => { {cycle.assignees.length > 0 && (
    - + + {cycle.assignees.map((assignee) => ( + + ))} +
    )}
    @@ -406,7 +404,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 +475,7 @@ export const ActiveCycleDetails: React.FC = (props) => {
    = ({ 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, workspaceSlug }) => { + const router = useRouter(); + const { peekCycle } = router.query; + + const ref = React.useRef(null); + + const { cycle: cycleStore } = useMobxStore(); + + const { fetchCycleWithId } = cycleStore; + + const handleClose = () => { + delete router.query.peekCycle; + router.push({ + pathname: router.pathname, + query: { ...router.query }, + }); + }; + + useEffect(() => { + if (!peekCycle) return; + fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString()); + }, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]); + + return ( + <> + {peekCycle && ( +
    + +
    + )} + + ); +}); diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index f2f921365..89e1bef89 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -1,64 +1,28 @@ import { FC, MouseEvent, useState } from "react"; -// next imports +import { useRouter } from "next/router"; import Link from "next/link"; -// headless ui -import { Disclosure, Transition } from "@headlessui/react"; // hooks import useToast from "hooks/use-toast"; // components -import { SingleProgressStats } from "components/core"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; // ui -import { AssigneesList } from "components/ui/avatar"; -import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; +import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui"; // icons -import { - AlarmClock, - AlertTriangle, - ArrowRight, - CalendarDays, - ChevronDown, - LinkIcon, - Pencil, - Star, - Target, - Trash2, -} from "lucide-react"; +import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // helpers -import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; -import { copyTextToClipboard, truncateText } from "helpers/string.helper"; +import { + getDateRangeStatus, + findHowManyDaysLeft, + renderShortDate, + renderShortMonthDate, +} from "helpers/date-time.helper"; +import { copyTextToClipboard } from "helpers/string.helper"; // types import { ICycle } from "types"; // store import { useMobxStore } from "lib/mobx/store-provider"; - -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", - }, -]; +// constants +import { CYCLE_STATUS } from "constants/cycle"; export interface ICyclesBoardCard { workspaceSlug: string; @@ -80,8 +44,34 @@ export const CyclesBoardCard: FC = (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 handleCopyText = () => { + const router = useRouter(); + + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const cycleTotalIssues = + cycle.backlog_issues + + cycle.unstarted_issues + + cycle.started_issues + + cycle.completed_issues + + cycle.cancelled_issues; + + const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100; + + const issueCount = cycle + ? cycleTotalIssues === 0 + ? "0 Issue" + : cycleTotalIssues === cycle.completed_issues + ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` + : `${cycle.completed_issues}/${cycleTotalIssues} Issues` + : "0 Issue"; + + const handleCopyText = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { @@ -93,21 +83,6 @@ export const CyclesBoardCard: FC = (props) => { }); }; - 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, - }; - const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -134,6 +109,29 @@ export const CyclesBoardCard: FC = (props) => { }); }; + const handleEditCycle = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setUpdateModal(true); + }; + + const handleDeleteCycle = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDeleteModal(true); + }; + + const openCycleOverview = (e: MouseEvent) => { + const { query } = router; + e.preventDefault(); + e.stopPropagation(); + + router.push({ + pathname: router.pathname, + query: { ...query, peekCycle: cycle.id }, + }); + }; + return (
    = (props) => { projectId={projectId} /> -
    - - -
    -
    - - - - - -

    {truncateText(cycle.name, 15)}

    -
    + +
    +
    +
    +
    + + - + + {cycle.name} + +
    +
    + {currentCycle && ( - {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 - )} + {currentCycle.value === "current" + ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}` + : `${currentCycle.label}`} - {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(); - setDeleteModal(true); - }} - > - - - 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%"} - - -
    -
    - ))} -
    -
    -
    -
    -
    +
    +
    +
    + + {issueCount}
    - )} - -
    -
    + {cycle.assignees.length > 0 && ( + +
    + + {cycle.assignees.map((assignee) => ( + + ))} + +
    +
    + )} +
    + + +
    +
    +
    +
    +
    + + +
    + {isDateValid ? ( + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + + ) : ( + No due date + )} +
    + {cycle.is_favorite ? ( + + ) : ( + + )} + + {!isCompleted && ( + <> + + + + Edit cycle + + + + + + Delete module + + + + )} + + + + Copy cycle link + + + +
    +
    +
    + +
    ); }; diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx index 3eca2e9b9..105d16128 100644 --- a/web/components/cycles/cycles-board.tsx +++ b/web/components/cycles/cycles-board.tsx @@ -2,26 +2,41 @@ import { FC } from "react"; // types import { ICycle } from "types"; // components -import { CyclesBoardCard } from "components/cycles"; +import { CyclePeekOverview, CyclesBoardCard } from "components/cycles"; export interface ICyclesBoard { cycles: ICycle[]; filter: string; workspaceSlug: string; projectId: string; + peekCycle: string; } export const CyclesBoard: FC = (props) => { - const { cycles, filter, workspaceSlug, projectId } = props; + const { cycles, filter, workspaceSlug, projectId, peekCycle } = props; return ( -
    + <> {cycles.length > 0 ? ( - <> - {cycles.map((cycle) => ( - - ))} - +
    +
    +
    + {cycles.map((cycle) => ( + + ))} +
    + +
    +
    ) : (
    @@ -50,6 +65,6 @@ export const CyclesBoard: FC = (props) => {
    )} -
    + ); }; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 9b381a03d..097a18070 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -1,29 +1,29 @@ import { FC, MouseEvent, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/router"; + +// stores +import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; // ui -import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; +import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar } from "@plane/ui"; // icons -import { - AlarmClock, - AlertTriangle, - ArrowRight, - CalendarDays, - LinkIcon, - Pencil, - Star, - Target, - Trash2, -} from "lucide-react"; +import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; // helpers -import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; +import { + getDateRangeStatus, + findHowManyDaysLeft, + renderShortDate, + renderShortMonthDate, +} from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; // types import { ICycle } from "types"; -import { useMobxStore } from "lib/mobx/store-provider"; +// constants +import { CYCLE_STATUS } from "constants/cycle"; type TCyclesListItem = { cycle: ICycle; @@ -35,34 +35,6 @@ type TCyclesListItem = { projectId: string; }; -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 CyclesListItem: FC = (props) => { const { cycle, workspaceSlug, projectId } = props; // store @@ -78,7 +50,28 @@ export const CyclesListItem: FC = (props) => { const endDate = new Date(cycle.end_date ?? ""); const startDate = new Date(cycle.start_date ?? ""); - const handleCopyText = () => { + const router = useRouter(); + + const cycleTotalIssues = + cycle.backlog_issues + + cycle.unstarted_issues + + cycle.started_issues + + cycle.completed_issues + + cycle.cancelled_issues; + + const renderDate = cycle.start_date || cycle.end_date; + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100; + + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + + const handleCopyText = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { @@ -90,13 +83,6 @@ export const CyclesListItem: FC = (props) => { }); }; - 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 handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -123,224 +109,31 @@ export const CyclesListItem: FC = (props) => { }); }; + const handleEditCycle = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setUpdateModal(true); + }; + + const handleDeleteCycle = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDeleteModal(true); + }; + + const openCycleOverview = (e: MouseEvent) => { + const { query } = router; + e.preventDefault(); + e.stopPropagation(); + + router.push({ + pathname: router.pathname, + query: { ...query, peekCycle: cycle.id }, + }); + }; + return ( <> -
    -
    - - - {/* left content */} -
    - {/* cycle state */} -
    - -
    - - {/* cycle title and description */} -
    - -
    - {cycle.name} -
    -
    - {cycle.description && ( -
    {cycle.description}
    - )} -
    -
    - - {/* right content */} -
    - {/* cycle status */} -
    - {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 start_date and target_date */} - {cycleStatus !== "draft" && ( -
    -
    - - {renderShortDateWithYearFormat(startDate)} -
    - - - -
    - - {renderShortDateWithYearFormat(endDate)} -
    -
    - )} - - {/* cycle created by */} -
    - {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( - {cycle.owned_by.display_name} - ) : ( - - {cycle.owned_by.display_name.charAt(0)} - - )} -
    - - {/* cycle progress */} - - 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" ? ( - - - {100} % - - ) : ( - - - {cycleStatus} - - )} - - - - {/* cycle favorite */} - {cycle.is_favorite ? ( - - ) : ( - - )} -
    - - -
    - -
    - - {!isCompleted && ( - setUpdateModal(true)}> - - - Edit Cycle - - - )} - - {!isCompleted && ( - setDeleteModal(true)}> - - - Delete cycle - - - )} - - - - - Copy cycle link - - - -
    -
    - = (props) => { workspaceSlug={workspaceSlug} projectId={projectId} /> - = (props) => { workspaceSlug={workspaceSlug} projectId={projectId} /> + + +
    +
    + + + {isCompleted ? ( + progress === 100 ? ( + + ) : ( + {`!`} + ) + ) : ( + {`${progress}%`} + )} + + + +
    + + + + + {cycle.name} + +
    +
    + +
    + +
    +
    + {currentCycle && ( + + {currentCycle.value === "current" + ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}` + : `${currentCycle.label}`} + + )} +
    + + {renderDate && ( + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} + {" - "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + + )} + + +
    + {cycle.assignees.length > 0 ? ( + + {cycle.assignees.map((assignee) => ( + + ))} + + ) : ( + + + + )} +
    +
    + + {cycle.is_favorite ? ( + + ) : ( + + )} + + + {!isCompleted && ( + <> + + + + Edit cycle + + + + + + Delete module + + + + )} + + + + Copy cycle link + + + +
    +
    + ); }; diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx index 947bd1fea..03698f1d8 100644 --- a/web/components/cycles/cycles-list.tsx +++ b/web/components/cycles/cycles-list.tsx @@ -1,6 +1,7 @@ import { FC } from "react"; // components -import { CyclesListItem } from "./cycles-list-item"; +import { CyclePeekOverview, CyclesListItem } from "components/cycles"; + // ui import { Loader } from "@plane/ui"; // types @@ -17,18 +18,22 @@ export const CyclesList: FC = (props) => { const { cycles, filter, workspaceSlug, projectId } = props; return ( -
    + <> {cycles ? ( <> {cycles.length > 0 ? ( -
    - {cycles.map((cycle) => ( -
    -
    +
    +
    +
    + {cycles.map((cycle) => ( -
    + ))}
    - ))} + +
    ) : (
    @@ -68,6 +73,6 @@ export const CyclesList: FC = (props) => { )} -
    + ); }; diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index 36955398e..4eea43e6d 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,16 +15,17 @@ export interface ICyclesView { layout: TCycleLayout; workspaceSlug: string; projectId: string; + peekCycle: string; } export const CyclesView: FC = observer((props) => { - const { filter, layout, workspaceSlug, projectId } = props; + const { filter, layout, workspaceSlug, projectId, peekCycle } = props; // store 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 ); @@ -35,10 +36,10 @@ export const CyclesView: FC = observer((props) => { <> {layout === "list" && ( <> - {!isLoading ? ( + {cyclesList ? ( ) : ( - + @@ -49,10 +50,16 @@ export const CyclesView: FC = observer((props) => { {layout === "board" && ( <> - {!isLoading ? ( - + {cyclesList ? ( + ) : ( - + @@ -63,7 +70,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..b7f25028d 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 @@ -101,10 +101,13 @@ export const CycleDeleteModal: React.FC = observer((props) => {

    - Cancel - + + +
    diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 49d461d1a..9526de59c 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -85,8 +85,8 @@ export const CyclesListGanttChartView: FC = ({ cycles, mutateCycles }) => loaderTitle="Cycles" blocks={cycles ? blockFormat(cycles) : null} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)} - SidebarBlockRender={CycleGanttSidebarBlock} - BlockRender={CycleGanttBlock} + blockToRender={(data: ICycle) => } + sidebarBlockToRender={(data: ICycle) => } enableBlockLeftResize={false} enableBlockRightResize={false} enableBlockMove={false} diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index 20bbfb627..ea9568478 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -7,8 +7,6 @@ export * from "./form"; export * from "./modal"; export * from "./select"; export * from "./sidebar"; -export * from "./single-cycle-card"; -export * from "./single-cycle-list"; export * from "./transfer-issues-modal"; export * from "./transfer-issues"; export * from "./cycles-list"; @@ -17,3 +15,5 @@ export * from "./cycles-board"; export * from "./cycles-board-card"; export * from "./cycles-gantt"; export * from "./delete-modal"; +export * from "./cycle-peek-overview"; +export * from "./cycles-list-item"; diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index d55a261eb..ea154d48b 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -16,35 +16,28 @@ import ProgressChart from "components/core/sidebar/progress-chart"; import { CycleDeleteModal } from "components/cycles/delete-modal"; // ui import { CustomRangeDatePicker } from "components/ui"; -import { CustomMenu, Loader, ProgressBar } from "@plane/ui"; +import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui"; // icons -import { - CalendarDays, - ChevronDown, - File, - MoveRight, - LinkIcon, - PieChart, - Trash2, - UserCircle2, - AlertCircle, -} from "lucide-react"; +import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react"; // helpers -import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; import { + findHowManyDaysLeft, getDateRangeStatus, isDateGreaterThanToday, renderDateFormat, - renderShortDateWithYearFormat, + renderShortDate, + renderShortMonthDate, } from "helpers/date-time.helper"; // types import { ICycle } from "types"; // fetch-keys import { CYCLE_DETAILS } from "constants/fetch-keys"; +import { CYCLE_STATUS } from "constants/cycle"; type Props = { - isOpen: boolean; cycleId: string; + handleClose: () => void; }; // services @@ -52,12 +45,12 @@ const cycleService = new CycleService(); // TODO: refactor the whole component export const CycleDetailsSidebar: React.FC = observer((props) => { - const { isOpen, cycleId } = props; + const { cycleId, handleClose } = props; const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, peekCycle } = router.query; const { user: userStore, cycle: cycleDetailsStore } = useMobxStore(); @@ -144,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")}`), }); @@ -218,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")}`), }); @@ -280,6 +273,22 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { if (!cycleDetails) return null; + const endDate = new Date(cycleDetails.end_date ?? ""); + const startDate = new Date(cycleDetails.start_date ?? ""); + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + + const issueCount = + cycleDetails.total_issues === 0 + ? "0 Issue" + : cycleDetails.total_issues === cycleDetails.completed_issues + ? cycleDetails.total_issues > 1 + ? `${cycleDetails.total_issues}` + : `${cycleDetails.total_issues}` + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + return ( <> {cycleDetails && workspaceSlug && projectId && ( @@ -291,327 +300,256 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { projectId={projectId.toString()} /> )} -
    - {cycleDetails ? ( - <> -
    -
    -
    - - {capitalizeFirstLetter(cycleStatus)} - -
    -
    - - {({}) => ( - <> - - - - {renderShortDateWithYearFormat( - new Date(`${watch("start_date") ? watch("start_date") : cycleDetails?.start_date}`), - "Start date" - )} - - - - - { - 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 - /> - - - - )} - - - - - - {({}) => ( - <> - - + {cycleDetails ? ( + <> +
    +
    + +
    +
    + + {!isCompleted && ( + + setCycleDeleteModal(true)}> + + + Delete + + + + )} +
    +
    - - {renderShortDateWithYearFormat( - new Date(`${watch("end_date") ? watch("end_date") : cycleDetails?.end_date}`), - "End date" - )} - -
    +
    +

    {cycleDetails.name}

    +
    + {currentCycle && ( + + {currentCycle.value === "current" + ? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}` + : `${currentCycle.label}`} + + )} +
    + + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} + - - - { - 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) { + 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, "_ _")} + -
    -
    -
    -
    -

    - {cycleDetails.name} -

    -
    - - {!isCompleted && ( - setCycleDeleteModal(true)}> - - - Delete - - - )} - - - - Copy link - - - -
    - - - {cycleDetails.description} - -
    - -
    -
    -
    - - Lead -
    - -
    - {cycleDetails.owned_by.avatar && cycleDetails.owned_by.avatar !== "" ? ( - {cycleDetails.owned_by.display_name} + + { + 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 /> - ) : ( - - {cycleDetails.owned_by.display_name.charAt(0)} - - )} - {cycleDetails.owned_by.display_name} -
    -
    + + + + +
    +
    +
    -
    -
    - - Progress -
    + {cycleDetails.description && ( + + {cycleDetails.description} + + )} -
    - - - - {cycleDetails.completed_issues}/{cycleDetails.total_issues} -
    -
    +
    +
    +
    + + Lead +
    +
    +
    + + {cycleDetails.owned_by.display_name}
    -
    - + +
    +
    + + Issues +
    +
    + {issueCount} +
    +
    +
    + +
    +
    + {({ open }) => (
    -
    +
    Progress - {!open && progressPercentage ? ( - +
    + +
    + {progressPercentage ? ( + {progressPercentage ? `${progressPercentage}%` : ""} ) : ( "" )} -
    - {isStartValid && isEndValid ? ( - + {isStartValid && isEndValid ? ( - ) : ( -
    - - - {cycleStatus === "upcoming" - ? "Cycle is yet to start." - : "Invalid date. Please enter valid date."} - -
    - )} -
    + ) : ( +
    + + + Invalid date. Please enter valid date. + +
    + )} +
    + - {isStartValid && isEndValid ? ( -
    -
    -
    - - - - - Pending Issues -{" "} - {cycleDetails.total_issues - - (cycleDetails.completed_issues + cycleDetails.cancelled_issues)} - +
    + {isStartValid && isEndValid ? ( +
    +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    - -
    -
    - - Ideal -
    -
    - - Current -
    +
    +
    -
    - 0 && ( +
    +
    -
    - ) : ( - "" - )} - - -
    - )} - -
    -
    - - {({ open }) => ( -
    -
    -
    - Other Information -
    - - {cycleDetails.total_issues > 0 ? ( - - - ) : ( -
    - - - No issues found. Please add issue. - + )}
    - )} -
    - - - {cycleDetails.total_issues > 0 ? ( -
    - -
    - ) : ( - "" - )}
    )}
    - - ) : ( - -
    - - -
    -
    - - - -
    -
    - )} -
    +
    + + ) : ( + +
    + + +
    +
    + + + +
    +
    + )} ); }); diff --git a/web/components/cycles/single-cycle-card.tsx b/web/components/cycles/single-cycle-card.tsx deleted file mode 100644 index a91a6a5f3..000000000 --- a/web/components/cycles/single-cycle-card.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import React from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -// headless ui -import { Disclosure, Transition } from "@headlessui/react"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { SingleProgressStats } from "components/core"; -// ui -import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; -import { AssigneesList } from "components/ui/avatar"; -// icons -import { - AlarmClock, - AlertTriangle, - ArrowRight, - CalendarDays, - ChevronDown, - 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", - }, -]; - -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..5ce1a47fd 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", 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..6c331f950 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.", }); }); @@ -291,151 +257,38 @@ 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 - - ( - - )} - /> - - -
    +
    +
    +
    + ))}
    @@ -461,4 +314,4 @@ export const CreateUpdateEstimateModal: React.FC = ({ handleClose, data, ); -}; +}); diff --git a/web/components/estimates/delete-estimate-modal.tsx b/web/components/estimates/delete-estimate-modal.tsx index 59f8e10be..0a96eda3d 100644 --- a/web/components/estimates/delete-estimate-modal.tsx +++ b/web/components/estimates/delete-estimate-modal.tsx @@ -1,10 +1,13 @@ import React, { useEffect, useState } from "react"; - -// headless ui +import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; // types import { IEstimate } from "types"; - // icons import { AlertTriangle } from "lucide-react"; // ui @@ -12,14 +15,43 @@ import { Button } from "@plane/ui"; type Props = { isOpen: boolean; + data: IEstimate | null; handleClose: () => void; - data: IEstimate; - handleDelete: () => void; }; -export const DeleteEstimateModal: React.FC = ({ isOpen, handleClose, data, handleDelete }) => { +export const DeleteEstimateModal: React.FC = observer((props) => { + const { isOpen, handleClose, data } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { projectEstimates: projectEstimatesStore } = useMobxStore(); + + // states const [isDeleteLoading, setIsDeleteLoading] = useState(false); + // hooks + const { setToastAlert } = useToast(); + + const handleEstimateDelete = () => { + if (!workspaceSlug || !projectId) return; + + const estimateId = data?.id!; + + projectEstimatesStore.deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId).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 deleted. Please try again", + }); + }); + }; + useEffect(() => { setIsDeleteLoading(false); }, [isOpen]); @@ -68,7 +100,7 @@ export const DeleteEstimateModal: React.FC = ({ isOpen, handleClose, data

    Are you sure you want to delete estimate-{" "} - {data.name} + {data?.name} {""}? All of the data related to the estiamte will be permanently removed. This action cannot be undone.

    @@ -81,7 +113,7 @@ export const DeleteEstimateModal: React.FC = ({ isOpen, handleClose, data variant="danger" onClick={() => { setIsDeleteLoading(true); - handleDelete(); + handleEstimateDelete(); }} loading={isDeleteLoading} > @@ -96,4 +128,4 @@ export const DeleteEstimateModal: React.FC = ({ isOpen, handleClose, data ); -}; +}); 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..825b64cd4 100644 --- a/web/components/exporter/export-modal.tsx +++ b/web/components/exporter/export-modal.tsx @@ -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 && diff --git a/web/components/exporter/guide.tsx b/web/components/exporter/guide.tsx index dee7d854f..2d7eb9d65 100644 --- a/web/components/exporter/guide.tsx +++ b/web/components/exporter/guide.tsx @@ -53,7 +53,7 @@ const IntegrationGuide = () => { {EXPORTERS_LIST.map((service) => (
    @@ -79,7 +79,7 @@ const IntegrationGuide = () => { ))}
    -
    +

    Previous Exports

    @@ -150,7 +150,7 @@ const IntegrationGuide = () => { {provider && ( handleCsvClose()} data={null} user={user} diff --git a/web/components/gantt-chart/blocks/blocks-display.tsx b/web/components/gantt-chart/blocks/blocks-display.tsx index f0e7279be..586b0d5cc 100644 --- a/web/components/gantt-chart/blocks/blocks-display.tsx +++ b/web/components/gantt-chart/blocks/blocks-display.tsx @@ -11,7 +11,7 @@ import { IBlockUpdateData, IGanttBlock } from "../types"; export const GanttChartBlocks: FC<{ itemsContainerWidth: number; blocks: IGanttBlock[] | null; - BlockRender: React.FC; + blockToRender: (data: any) => React.ReactNode; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; enableBlockLeftResize: boolean; enableBlockRightResize: boolean; @@ -19,7 +19,7 @@ export const GanttChartBlocks: FC<{ }> = ({ itemsContainerWidth, blocks, - BlockRender, + blockToRender, blockUpdateHandler, enableBlockLeftResize, enableBlockRightResize, @@ -49,11 +49,9 @@ export const GanttChartBlocks: FC<{ const updatedTargetDate = new Date(originalTargetDate); // update the start date on left resize - if (dragDirection === "left") - updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); + if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); // update the target date on right resize - else if (dragDirection === "right") - updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); + else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); // update both the dates on x-axis move else if (dragDirection === "move") { updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts); @@ -86,7 +84,7 @@ export const GanttChartBlocks: FC<{ > handleChartBlockPosition(block, ...args)} enableBlockLeftResize={enableBlockLeftResize} enableBlockRightResize={enableBlockRightResize} diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index 82a7ed614..219f11ebb 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -39,8 +39,8 @@ type ChartViewRootProps = { loaderTitle: string; blocks: IGanttBlock[] | null; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - SidebarBlockRender: React.FC; - BlockRender: React.FC; + blockToRender: (data: any) => React.ReactNode; + sidebarBlockToRender: (block: any) => React.ReactNode; enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; @@ -54,8 +54,8 @@ export const ChartViewRoot: FC = ({ blocks = null, loaderTitle, blockUpdateHandler, - SidebarBlockRender, - BlockRender, + sidebarBlockToRender, + blockToRender, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, @@ -165,6 +165,8 @@ export const ChartViewRoot: FC = ({ const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => { const scrollContainer = document.getElementById("scroll-container") as HTMLElement; + if (!scrollContainer) return; + const clientVisibleWidth: number = scrollContainer?.clientWidth; let scrollWidth: number = 0; let daysDifference: number = 0; @@ -193,6 +195,8 @@ export const ChartViewRoot: FC = ({ const onScroll = () => { const scrollContainer = document.getElementById("scroll-container") as HTMLElement; + if (!scrollContainer) return; + const scrollWidth: number = scrollContainer?.scrollWidth; const clientVisibleWidth: number = scrollContainer?.clientWidth; const currentScrollPosition: number = scrollContainer?.scrollLeft; @@ -285,7 +289,7 @@ export const ChartViewRoot: FC = ({ title={title} blockUpdateHandler={blockUpdateHandler} blocks={chartBlocks} - SidebarBlockRender={SidebarBlockRender} + sidebarBlockToRender={sidebarBlockToRender} enableReorder={enableReorder} />
    @@ -307,7 +311,7 @@ export const ChartViewRoot: FC = ({ ; + blockToRender: (data: any) => React.ReactNode; handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right" | "move") => void; enableBlockLeftResize: boolean; enableBlockRightResize: boolean; @@ -18,7 +18,7 @@ type Props = { export const ChartDraggable: React.FC = ({ block, - BlockRender, + blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, @@ -286,7 +286,7 @@ export const ChartDraggable: React.FC = ({ className={`relative z-[2] rounded h-8 w-full flex items-center ${isMoving ? "pointer-events-none" : ""}`} onMouseDown={handleBlockMove} > - + {blockToRender(block.data)}
    {/* right resize drag handle */} {enableBlockRightResize && ( diff --git a/web/components/gantt-chart/module-sidebar.tsx b/web/components/gantt-chart/module-sidebar.tsx index 4b0b654f2..5ecc1f3ba 100644 --- a/web/components/gantt-chart/module-sidebar.tsx +++ b/web/components/gantt-chart/module-sidebar.tsx @@ -1,5 +1,5 @@ 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 diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index 5acedd53e..7c1f243d6 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -12,8 +12,8 @@ type GanttChartRootProps = { loaderTitle: string; blocks: IGanttBlock[] | null; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - SidebarBlockRender: FC; - BlockRender: FC; + blockToRender: (data: any) => React.ReactNode; + sidebarBlockToRender: (block: any) => React.ReactNode; enableBlockLeftResize?: boolean; enableBlockRightResize?: boolean; enableBlockMove?: boolean; @@ -27,8 +27,8 @@ export const GanttChartRoot: FC = ({ blocks, loaderTitle = "blocks", blockUpdateHandler, - SidebarBlockRender, - BlockRender, + sidebarBlockToRender, + blockToRender, enableBlockLeftResize = true, enableBlockRightResize = true, enableBlockMove = true, @@ -42,8 +42,8 @@ export const GanttChartRoot: FC = ({ blocks={blocks} loaderTitle={loaderTitle} blockUpdateHandler={blockUpdateHandler} - SidebarBlockRender={SidebarBlockRender} - BlockRender={BlockRender} + sidebarBlockToRender={sidebarBlockToRender} + blockToRender={blockToRender} enableBlockLeftResize={enableBlockLeftResize} enableBlockRightResize={enableBlockRightResize} enableBlockMove={enableBlockMove} diff --git a/web/components/gantt-chart/sidebar.tsx b/web/components/gantt-chart/sidebar.tsx index 0e7dae048..f6c32e099 100644 --- a/web/components/gantt-chart/sidebar.tsx +++ b/web/components/gantt-chart/sidebar.tsx @@ -1,5 +1,5 @@ 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 @@ -17,14 +17,14 @@ type Props = { title: string; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blocks: IGanttBlock[] | null; - SidebarBlockRender: React.FC; + sidebarBlockToRender: (block: any) => React.ReactNode; enableReorder: boolean; enableQuickIssueCreate?: boolean; }; export const GanttSidebar: React.FC = (props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder, enableQuickIssueCreate } = props; + const { title, blockUpdateHandler, blocks, sidebarBlockToRender, enableReorder, enableQuickIssueCreate } = props; const router = useRouter(); const { cycleId } = router.query; @@ -130,9 +130,7 @@ export const GanttSidebar: React.FC = (props) => { )}
    -
    - -
    +
    {sidebarBlockToRender(block.data)}
    {duration} day{duration > 1 ? "s" : ""}
    diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index ac6ed2e6b..4106f443b 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 @@ -32,6 +32,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { cycleIssueFilter: cycleIssueFilterStore, project: projectStore, } = useMobxStore(); + const { currentProjectDetails } = projectStore; + const activeLayout = issueFilterStore.userDisplayFilters.layout; const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); @@ -110,36 +112,55 @@ export const CycleIssuesHeader: React.FC = observer(() => { />
    - 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" + > + {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} /> - + { states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + = (props) => { - const { name } = props; +export const CyclesHeader: FC = (props) => { + const {} = props; // router const router = useRouter(); const { workspaceSlug } = router.query; + // store + const { project: projectStore } = useMobxStore(); + const { currentProjectDetails } = projectStore; return (
    = (props) => { >
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Cycles" /> -
    diff --git a/web/components/headers/global-issues.tsx b/web/components/headers/global-issues.tsx index b80dd45a1..7c41ba327 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 @@ -99,9 +99,20 @@ export const GlobalIssuesHeader: React.FC = observer((props) => { <> setCreateViewModal(false)} />
    -
    - {activeLayout === "spreadsheet" && } - Workspace {activeLayout === "spreadsheet" ? "Issues" : "Views"} +
    + + + ) : ( + + ) + } + label={`Workspace ${activeLayout === "spreadsheet" ? "Issues" : "Views"}`} + /> +
    @@ -128,18 +139,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} /> )} - + { project: projectStore, } = useMobxStore(); const activeLayout = issueFilterStore.userDisplayFilters.layout; + const { currentProjectDetails } = projectStore; const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); @@ -110,39 +111,54 @@ export const ModuleIssuesHeader: React.FC = observer(() => { />
    - 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" + > + {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} /> - + { states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + { // router 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()] : undefined; + const { currentProjectDetails } = projectStore; const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid"); @@ -42,38 +28,52 @@ export const ModulesListHeader: React.FC = observer(() => { >
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Modules" /> -
    - {moduleViewOptions.map((option) => ( - {replaceUnderscoreIfSnakeCase(option.type)} Layout} - position="bottom" - > - - - ))} +
    + {MODULE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
    +
    + )} +
    + ); +}); diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index f30c03fbb..634dd0c38 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 { currentProjectDetails } = projectStore; return (
    - router.back()}> - - -

    Projects

    -
    - + + + {currentProjectDetails?.name.charAt(0)} + + ) } + link={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} + /> + } + label="Pages" /> -
    diff --git a/web/components/headers/profile-preferences.tsx b/web/components/headers/profile-preferences.tsx index ae5ad8811..cde226fd7 100644 --- a/web/components/headers/profile-preferences.tsx +++ b/web/components/headers/profile-preferences.tsx @@ -1,21 +1,16 @@ -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..be8b5cebe 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, @@ -45,17 +41,35 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
    - 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..e68f4ce84 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -1,40 +1,138 @@ 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"; +// constants +import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // helper import { truncateText } from "helpers/string.helper"; +// ui +import { Breadcrumbs, BreadcrumbItem, 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 { 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, archivedIssueFilters: archivedIssueFiltersStore } = 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={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} + /> + + + + +
    ); }); diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 125dccadd..0ab1603be 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..5098c69b9 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..9fce63575 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 @@ -31,24 +35,34 @@ export const ProjectIssueDetailsHeader: FC = observer(() => {
    - 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..f9bf6ec58 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); @@ -85,11 +85,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,7 +96,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { setAnalyticsModal(false)} - projectDetails={projectDetails ?? undefined} + projectDetails={currentProjectDetails ?? undefined} />
    @@ -114,23 +110,39 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
    - 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 +158,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + { states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined} /> - + { diff --git a/web/components/headers/project-settings.tsx b/web/components/headers/project-settings.tsx index 44271a76e..6c20c32df 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,10 +16,10 @@ 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 (
    = observer((props) >
    - 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..50d97505c 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 @@ -26,6 +26,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { projectViews: projectViewsStore, } = useMobxStore(); + const { currentProjectDetails } = projectStore; + const storedFilters = viewId ? projectViewFiltersStore.storedFilters[viewId.toString()] : undefined; const activeLayout = issueFilterStore.userDisplayFilters.layout; @@ -87,47 +89,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} /> - + { states={projectStore.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 ( <> @@ -43,26 +36,45 @@ export const ProjectViewsHeader: React.FC = observer(() => { >
    - 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..3c56f239e 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,18 +1,19 @@ 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 } = 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" + /> +
    + )}
    - router.back()}> - + + } + label="Analytics" + />
    diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index 7b9e5a1bf..73d9dacab 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -24,13 +24,15 @@ export const WorkspaceDashboardHeader = () => { 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/inbox/actions-header.tsx b/web/components/inbox/actions-header.tsx index a5e05edac..3c1fb758c 100644 --- a/web/components/inbox/actions-header.tsx +++ b/web/components/inbox/actions-header.tsx @@ -6,8 +6,6 @@ import { Popover } from "@headlessui/react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; -// contexts -import { useProjectMyMembership } from "contexts/project-member.context"; // hooks import useToast from "hooks/use-toast"; // components @@ -38,9 +36,9 @@ export const InboxActionsHeader = observer(() => { 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); 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/integration/github/auth.tsx b/web/components/integration/github/auth.tsx index c94bfacd5..9d5816f3b 100644 --- a/web/components/integration/github/auth.tsx +++ b/web/components/integration/github/auth.tsx @@ -4,14 +4,24 @@ import useIntegrationPopup from "hooks/use-integration-popup"; import { Button } from "@plane/ui"; // types import { IWorkspaceIntegration } from "types"; +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; type Props = { workspaceIntegration: false | IWorkspaceIntegration | undefined; provider: string | undefined; }; -export const GithubAuth: React.FC = ({ workspaceIntegration, provider }) => { - const { startAuth, isConnecting } = useIntegrationPopup(provider); +export const GithubAuth: React.FC = observer(({ workspaceIntegration, provider }) => { + const { + appConfig: { envConfig }, + } = useMobxStore(); + // hooks + const { startAuth, isConnecting } = useIntegrationPopup({ + provider, + github_app_name: envConfig?.github_app_name || "", + slack_client_id: envConfig?.slack_client_id || "", + }); return (
    @@ -26,4 +36,4 @@ export const GithubAuth: React.FC = ({ workspaceIntegration, provider }) )}
    ); -}; +}); diff --git a/web/components/integration/github/single-user-select.tsx b/web/components/integration/github/single-user-select.tsx index 2bc16d87d..0bba7b85d 100644 --- a/web/components/integration/github/single-user-select.tsx +++ b/web/components/integration/github/single-user-select.tsx @@ -1,12 +1,9 @@ import { useRouter } from "next/router"; - import useSWR from "swr"; - // services import { WorkspaceService } from "services/workspace.service"; // ui -import { Avatar } from "components/ui"; -import { CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; +import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; // types import { IGithubRepoCollaborator } from "types"; import { IUserDetails } from "./root"; @@ -44,7 +41,7 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, const { data: members } = useSWR( workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug.toString()) : null, - workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug.toString()) : null + workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null ); const options = members?.map((member) => ({ @@ -52,7 +49,7 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, query: member.member.display_name ?? "", content: (
    - + {member.member.display_name}
    ), diff --git a/web/components/integration/guide.tsx b/web/components/integration/guide.tsx index a0876e673..e521ca8f5 100644 --- a/web/components/integration/guide.tsx +++ b/web/components/integration/guide.tsx @@ -1,11 +1,8 @@ import { useState } from "react"; - import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; - import useSWR, { mutate } from "swr"; - // hooks import useUserAuth from "hooks/use-user-auth"; // services @@ -21,7 +18,7 @@ import { IImporterService } from "types"; // fetch-keys import { IMPORTER_SERVICES_LIST } from "constants/fetch-keys"; // constants -import { IMPORTERS_EXPORTERS_LIST } from "constants/workspace"; +import { IMPORTERS_LIST } from "constants/workspace"; // services const integrationService = new IntegrationService(); @@ -76,10 +73,10 @@ const IntegrationGuide = () => {
    */} - {IMPORTERS_EXPORTERS_LIST.map((service) => ( + {IMPORTERS_LIST.map((service) => (
    @@ -100,7 +97,7 @@ const IntegrationGuide = () => {
    ))}
    -
    +

    Previous Imports - - ))} -

    - )} - /> -
    + ( + ( +

    " : 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..d967fd357 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 { @@ -112,11 +115,14 @@ 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/confirm-issue-discard.tsx b/web/components/issues/confirm-issue-discard.tsx index 98b4eaf48..eca797e64 100644 --- a/web/components/issues/confirm-issue-discard.tsx +++ b/web/components/issues/confirm-issue-discard.tsx @@ -73,7 +73,7 @@ export const ConfirmIssueDiscard: React.FC = (props) => { Discard
    -
    +
    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..fd9c9b3fd --- /dev/null +++ b/web/components/issues/delete-archived-issue-modal.tsx @@ -0,0 +1,131 @@ +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..3b3efaea0 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", @@ -146,4 +138,4 @@ export const DeleteDraftIssueModal: React.FC = (props) => { ); -}; +}); diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index feb63c671..a3c4289f0 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 } }) => (