diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 5d19be11cc8..3adaa42309c 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -2,7 +2,7 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " labels: [🐛bug] -assignees: [srinivaspendem, pushya-plane] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: @@ -45,7 +45,7 @@ body: - Deploy preview validations: required: true - type: dropdown +- type: dropdown id: browser attributes: label: Browser diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index 941fbef87c5..ff9cdd23839 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -2,7 +2,7 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " labels: [✨feature] -assignees: [srinivaspendem, pushya-plane] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 0d3f970689f..44bae0efa60 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -23,6 +23,10 @@ jobs: gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }} + build_space: ${{ steps.changed_files.outputs.space_any_changed }} + build_backend: ${{ steps.changed_files.outputs.backend_any_changed }} + build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} steps: - id: set_env_variables @@ -41,7 +45,36 @@ jobs: fi echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - name: Get changed files + id: changed_files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + frontend: + - web/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + space: + - space/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + backend: + - apiserver/** + proxy: + - nginx/** + branch_build_push_frontend: + if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -55,9 +88,9 @@ jobs: - name: Set Frontend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest else TAG=${{ env.FRONTEND_TAG }} fi @@ -77,7 +110,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Frontend to Docker Container Registry uses: docker/build-push-action@v5.1.0 @@ -93,6 +126,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_space: + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -106,9 +140,9 @@ jobs: - name: Set Space Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest else TAG=${{ env.SPACE_TAG }} fi @@ -128,7 +162,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Space to Docker Hub uses: docker/build-push-action@v5.1.0 @@ -144,6 +178,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_backend: + if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -157,9 +192,9 @@ jobs: - name: Set Backend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest else TAG=${{ env.BACKEND_TAG }} fi @@ -179,7 +214,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Backend to Docker Hub uses: docker/build-push-action@v5.1.0 @@ -194,8 +229,8 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - branch_build_push_proxy: + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -209,9 +244,9 @@ jobs: - name: Set Proxy Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest else TAG=${{ env.PROXY_TAG }} fi @@ -231,7 +266,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Plane-Proxy to Docker Hub uses: docker/build-push-action@v5.1.0 diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 296e965d7f7..83ed41625df 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -21,7 +21,6 @@ jobs: uses: actions/setup-node@v2 with: node-version: 18.x - cache: "yarn" - name: Get changed files id: changed-files diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 47a85f3ba8c..8644f04f02e 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -2,7 +2,7 @@ name: Create Sync Action on: workflow_dispatch: - push: + push: branches: - preview @@ -17,7 +17,7 @@ jobs: contents: read steps: - name: Checkout Code - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.1 with: persist-credentials: false fetch-depth: 0 @@ -31,14 +31,25 @@ jobs: sudo apt update sudo apt install gh -y - - name: Push Changes to Target Repo + - name: Push Changes to Target Repo A env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | - TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" - TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" + TARGET_REPO="${{ secrets.TARGET_REPO_A }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH - git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH + git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH + + - name: Push Changes to Target Repo B + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ secrets.TARGET_REPO_B }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}" + SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" + + git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/README.md b/README.md index b509fd6f6a6..52ccda474f1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Plane

-

Flexible, extensible open-source project management

+

Open-source project management that unlocks customer value.

@@ -16,6 +16,13 @@ Commit activity per month

+

+ Website • + Releases • + Twitter • + Documentation +

+

-Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️. +Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️ > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). -## ⚡️ Contributors Quick Start -### Prerequisite +## ⚡ Installation -Development system must have docker engine installed and running. +The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users. -### Steps +If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). -Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute +| Installation Methods | Documentation Link | +|-----------------|----------------------------------------------------------------------------------------------------------| +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/docker-compose) | +| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) | + +`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature. + +## 🚀 Features + +- **Issues**: Quickly create issues and add details using a powerful, rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. + +- **Cycles** + Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features. + +- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily. + +- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks. + +- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue. + +- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work. -1. Clone the code locally using `git clone https://github.com/makeplane/plane.git` -1. Switch to the code folder `cd plane` -1. Create your feature or fix branch you plan to work on using `git checkout -b ` -1. Open terminal and run `./setup.sh` -1. Open the code on VSCode or similar equivalent IDE -1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system -1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d` +- **Drive** (*coming soon*): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. -You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload) + + +## 🛠️ Contributors Quick Start + +> Development system must have docker engine installed and running. + +Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute + +1. Clone the code locally using: + ``` + git clone https://github.com/makeplane/plane.git + ``` +2. Switch to the code folder: + ``` + cd plane + ``` +3. Create your feature or fix branch you plan to work on using: + ``` + git checkout -b + ``` +4. Open terminal and run: + ``` + ./setup.sh + ``` +5. Open the code on VSCode or similar equivalent IDE. +6. Review the `.env` files available in various folders. + Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system. +7. Run the docker command to initiate services: + ``` + docker compose -f docker-compose-local.yml up -d + ``` + +You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload). Thats it! -## 🍙 Self Hosting +## ❤️ Community -For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page +The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels. -## 🚀 Features +Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects. -- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking. -- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents. -- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you. -- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features. -- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress. -- **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks. -- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues. -- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location. -- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration. +### Repo Activity +![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") ## 📸 Screenshots

Plane Views @@ -91,8 +135,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Issue Details @@ -100,7 +143,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Cycles and Modules @@ -109,7 +152,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Analytics @@ -118,7 +161,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Pages @@ -128,7 +171,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Command Menu @@ -136,20 +179,22 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

-## 📚Documentation - -For full documentation, visit [docs.plane.so](https://docs.plane.so/) - -To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md). +## ⛓️ Security -## ❤️ Community +If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. -The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects. +Email squawk@plane.so to disclose any security vulnerabilities. -To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge). +## ❤️ Contribute -Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels. +There are many ways to contribute to Plane, including: +- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components. +- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features. +- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)! +- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support. -## ⛓️ Security +### We couldn't have done this without you. -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities. +
+ + \ No newline at end of file diff --git a/apiserver/package.json b/apiserver/package.json index fb4f8441d43..060944406ca 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.15.1" + "version": "0.16.0" } diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 6f66c373ec6..84931f46be9 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -45,7 +45,10 @@ def get_queryset(self): return ( Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("owned_by") @@ -390,7 +393,10 @@ def get_queryset(self): ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(cycle_id=self.kwargs.get("cycle_id")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index a759b15f6e0..bf3313779c2 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -352,7 +352,10 @@ def get_queryset(self): return ( Label.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("parent") @@ -481,7 +484,10 @@ def get_queryset(self): IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) @@ -607,11 +613,11 @@ def get_queryset(self): ) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .select_related("actor") + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("workspace", "project", "issue", "actor") .annotate( is_member=Exists( ProjectMember.objects.filter( @@ -647,6 +653,33 @@ def get(self, request, slug, project_id, issue_id, pk=None): ) def post(self, request, slug, project_id, issue_id): + + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and request.data.get("external_source") + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue_comment = IssueComment.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -680,6 +713,29 @@ def patch(self, request, slug, project_id, issue_id, pk): IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder, ) + + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and (issue_comment.external_id != str(request.data.get("external_id"))) + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", issue_comment.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentSerializer( issue_comment, data=request.data, partial=True ) @@ -734,6 +790,7 @@ def get(self, request, slug, project_id, issue_id, pk=None): .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index d509a53c79d..2e5bb85e2b7 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -273,7 +273,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("module") diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 0a262a071d4..ec10f9babe1 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -1,7 +1,5 @@ -# Python imports -from itertools import groupby - # Django imports +from django.db import IntegrityError from django.db.models import Q # Third party imports @@ -26,7 +24,10 @@ def get_queryset(self): return ( State.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") @@ -34,37 +35,51 @@ def get_queryset(self): ) def post(self, request, slug, project_id): - serializer = StateSerializer( - data=request.data, context={"project_id": project_id} - ) - if serializer.is_valid(): - if ( - request.data.get("external_id") - and request.data.get("external_source") - and State.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).exists() - ): - state = State.objects.filter( - workspace__slug=slug, - project_id=project_id, - external_id=request.data.get("external_id"), - external_source=request.data.get("external_source"), - ).first() - return Response( - { - "error": "State with the same external id and external source already exists", - "id": str(state.id), - }, - status=status.HTTP_409_CONFLICT, - ) + try: + serializer = StateSerializer( + data=request.data, context={"project_id": project_id} + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() + return Response( + { + "error": "State with the same name already exists in the project", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) def get(self, request, slug, project_id, state_id=None): if state_id: diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 28e88106031..9bdd4baaf9d 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -69,9 +69,13 @@ RelatedIssueSerializer, IssuePublicSerializer, IssueDetailSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) from .module import ( + ModuleDetailSerializer, ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer, diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 446fdb6d537..6693ba931ce 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -58,9 +58,12 @@ def _filter_fields(self, fields): IssueSerializer, LabelSerializer, CycleIssueSerializer, - IssueFlatSerializer, + IssueLiteSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer + InboxIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) # Expansion mapper @@ -79,12 +82,34 @@ def _filter_fields(self, fields): "assignees": UserLiteSerializer, "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, - "parent": IssueSerializer, + "parent": IssueLiteSerializer, "issue_relation": IssueRelationSerializer, - "issue_inbox" : InboxIssueLiteSerializer, + "issue_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_attachment": IssueAttachmentLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, } - - self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False) + + self.fields[field] = expansion[field]( + many=( + True + if field + in [ + "members", + "assignees", + "labels", + "issue_cycle", + "issue_relation", + "issue_inbox", + "issue_reactions", + "issue_attachment", + "issue_link", + "sub_issues", + ] + else False + ) + ) return self.fields @@ -105,7 +130,11 @@ def to_representation(self, instance): LabelSerializer, CycleIssueSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer + InboxIssueLiteSerializer, + IssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) # Expansion mapper @@ -124,9 +153,13 @@ def to_representation(self, instance): "assignees": UserLiteSerializer, "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, - "parent": IssueSerializer, + "parent": IssueLiteSerializer, "issue_relation": IssueRelationSerializer, - "issue_inbox" : InboxIssueLiteSerializer, + "issue_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_attachment": IssueAttachmentLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, } # Check if field in expansion then expand the field if expand in expansion: diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 77c3f16cc75..a273b349c3d 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -3,10 +3,7 @@ # Module imports from .base import BaseSerializer -from .user import UserLiteSerializer from .issue import IssueStateSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer from plane.db.models import ( Cycle, CycleIssue, @@ -14,7 +11,6 @@ CycleUserProperties, ) - class CycleWriteSerializer(BaseSerializer): def validate(self, data): if ( @@ -30,65 +26,57 @@ def validate(self, data): class Meta: model = Cycle fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "owned_by", + ] class CycleSerializer(BaseSerializer): + # favorite is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) + # state group wise distribution cancelled_issues = serializers.IntegerField(read_only=True) completed_issues = serializers.IntegerField(read_only=True) started_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) - assignees = serializers.SerializerMethodField(read_only=True) - total_estimates = serializers.IntegerField(read_only=True) - completed_estimates = serializers.IntegerField(read_only=True) - started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") - status = serializers.CharField(read_only=True) - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - and data.get("start_date", None) > data.get("end_date", None) - ): - raise serializers.ValidationError( - "Start date cannot exceed end date" - ) - return data - - def get_assignees(self, obj): - members = [ - { - "avatar": assignee.avatar, - "display_name": assignee.display_name, - "id": assignee.id, - } - for issue_cycle in obj.issue_cycle.prefetch_related( - "issue__assignees" - ).all() - for assignee in issue_cycle.issue.assignees.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in members} - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) - return unique_list class Meta: model = Cycle - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "owned_by", + fields = [ + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "status", ] + read_only_fields = fields class CycleIssueSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 90069bd41bd..411c5b73f88 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -444,6 +444,22 @@ def create(self, validated_data): return IssueLink.objects.create(**validated_data) +class IssueLinkLiteSerializer(BaseSerializer): + + class Meta: + model = IssueLink + fields = [ + "id", + "issue_id", + "title", + "url", + "metadata", + "created_by_id", + "created_at", + ] + read_only_fields = fields + + class IssueAttachmentSerializer(BaseSerializer): class Meta: model = IssueAttachment @@ -459,6 +475,21 @@ class Meta: ] +class IssueAttachmentLiteSerializer(DynamicBaseSerializer): + + class Meta: + model = IssueAttachment + fields = [ + "id", + "asset", + "attributes", + "issue_id", + "updated_at", + "updated_by_id", + ] + read_only_fields = fields + + class IssueReactionSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") @@ -473,6 +504,18 @@ class Meta: ] +class IssueReactionLiteSerializer(DynamicBaseSerializer): + + class Meta: + model = IssueReaction + fields = [ + "id", + "actor_id", + "issue_id", + "reaction", + ] + + class CommentReactionSerializer(BaseSerializer): class Meta: model = CommentReaction @@ -503,9 +546,7 @@ class IssueCommentSerializer(BaseSerializer): workspace_detail = WorkspaceLiteSerializer( read_only=True, source="workspace" ) - comment_reactions = CommentReactionSerializer( - read_only=True, many=True - ) + comment_reactions = CommentReactionSerializer(read_only=True, many=True) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -558,18 +599,17 @@ class Meta: class IssueSerializer(DynamicBaseSerializer): # ids - project_id = serializers.PrimaryKeyRelatedField(read_only=True) - state_id = serializers.PrimaryKeyRelatedField(read_only=True) - parent_id = serializers.PrimaryKeyRelatedField(read_only=True) cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_ids = serializers.SerializerMethodField() + module_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, + ) # Many to many - label_ids = serializers.PrimaryKeyRelatedField( - read_only=True, many=True, source="labels" + label_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, ) - assignee_ids = serializers.PrimaryKeyRelatedField( - read_only=True, many=True, source="assignees" + assignee_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, ) # Count items @@ -577,9 +617,6 @@ class IssueSerializer(DynamicBaseSerializer): attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) - # is_subscribed - is_subscribed = serializers.BooleanField(read_only=True) - class Meta: model = Issue fields = [ @@ -606,57 +643,45 @@ class Meta: "updated_by", "attachment_count", "link_count", - "is_subscribed", "is_draft", "archived_at", ] read_only_fields = fields - def get_module_ids(self, obj): - # Access the prefetched modules and extract module IDs - return [module for module in obj.issue_module.values_list("module_id", flat=True)] - class IssueDetailSerializer(IssueSerializer): - description_html = serializers.CharField() + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): - fields = IssueSerializer.Meta.fields + ['description_html'] + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + ] class IssueLiteSerializer(DynamicBaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") - label_details = LabelLiteSerializer( - read_only=True, source="labels", many=True - ) - assignee_details = UserLiteSerializer( - read_only=True, source="assignees", many=True - ) - sub_issues_count = serializers.IntegerField(read_only=True) - cycle_id = serializers.UUIDField(read_only=True) - module_id = serializers.UUIDField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) class Meta: model = Issue - fields = "__all__" - read_only_fields = [ - "start_date", - "target_date", - "completed_at", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", + fields = [ + "id", + "sequence_id", + "project_id", ] + read_only_fields = fields + + +class IssueDetailSerializer(IssueSerializer): + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField() + + class Meta(IssueSerializer.Meta): + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + ] + read_only_fields = fields class IssuePublicSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index e9419567182..4aabfc50efd 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -5,7 +5,6 @@ from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, @@ -19,17 +18,18 @@ class ModuleWriteSerializer(BaseSerializer): - members = serializers.ListField( + lead_id = serializers.PrimaryKeyRelatedField( + source="lead", + queryset=User.objects.all(), + required=False, + allow_null=True, + ) + member_ids = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) - class Meta: model = Module fields = "__all__" @@ -44,7 +44,9 @@ class Meta: def to_representation(self, instance): data = super().to_representation(instance) - data["members"] = [str(member.id) for member in instance.members.all()] + data["member_ids"] = [ + str(member.id) for member in instance.members.all() + ] return data def validate(self, data): @@ -59,12 +61,10 @@ def validate(self, data): return data def create(self, validated_data): - members = validated_data.pop("members", None) - + members = validated_data.pop("member_ids", None) project = self.context["project"] module = Module.objects.create(**validated_data, project=project) - if members is not None: ModuleMember.objects.bulk_create( [ @@ -85,7 +85,7 @@ def create(self, validated_data): return module def update(self, instance, validated_data): - members = validated_data.pop("members", None) + members = validated_data.pop("member_ids", None) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -142,7 +142,6 @@ class Meta: class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") class Meta: model = ModuleLink @@ -170,12 +169,9 @@ def create(self, validated_data): class ModuleSerializer(DynamicBaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - lead_detail = UserLiteSerializer(read_only=True, source="lead") - members_detail = UserLiteSerializer( - read_only=True, many=True, source="members" + member_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, allow_null=True ) - link_module = ModuleLinkSerializer(read_only=True, many=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) @@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer): class Meta: model = Module - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", + fields = [ + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", "created_at", "updated_at", ] + read_only_fields = fields + + + +class ModuleDetailSerializer(ModuleSerializer): + + link_module = ModuleLinkSerializer(read_only=True, many=True) + + class Meta(ModuleSerializer.Meta): + fields = ModuleSerializer.Meta.fields + ['link_module'] class ModuleFavoriteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 234c2824dd7..4ee70450b37 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -2,6 +2,7 @@ from plane.app.views import ( + IssueListEndpoint, IssueViewSet, LabelViewSet, BulkCreateIssueLabelsEndpoint, @@ -25,6 +26,11 @@ urlpatterns = [ + path( + "workspaces//projects//issues/list/", + IssueListEndpoint.as_view(), + name="project-issue", + ), path( "workspaces//projects//issues/", IssueViewSet.as_view( @@ -84,11 +90,13 @@ BulkImportIssuesEndpoint.as_view(), name="project-issues-bulk", ), + # deprecated endpoint TODO: remove once confirmed path( "workspaces//my-issues/", UserWorkSpaceIssues.as_view(), name="workspace-issues", ), + ## path( "workspaces//projects//issues//sub-issues/", SubIssuesEndpoint.as_view(), @@ -251,23 +259,15 @@ name="project-issue-archive", ), path( - "workspaces//projects//archived-issues//", + "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( { "get": "retrieve", - "delete": "destroy", - } - ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", + "post": "archive", + "delete": "unarchive", } ), - name="project-issue-archive", + name="project-issue-archive-unarchive", ), ## End Issue Archives ## Issue Relation diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 7e64e586aaf..a70ff18e535 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -22,6 +22,8 @@ WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) @@ -219,4 +221,14 @@ WorkspaceEstimatesEndpoint.as_view(), name="workspace-estimate", ), + path( + "workspaces//modules/", + WorkspaceModulesEndpoint.as_view(), + name="workspace-modules", + ), + path( + "workspaces//cycles/", + WorkspaceCyclesEndpoint.as_view(), + name="workspace-cycles", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0a959a667b7..d4a13e49749 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -49,6 +49,8 @@ WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) from .state import StateViewSet from .view import ( @@ -67,6 +69,7 @@ ) from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue import ( + IssueListEndpoint, IssueViewSet, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, diff --git a/apiserver/plane/app/views/analytic.py b/apiserver/plane/app/views/analytic.py index 04a77f789e3..6eb914b236e 100644 --- a/apiserver/plane/app/views/analytic.py +++ b/apiserver/plane/app/views/analytic.py @@ -1,6 +1,7 @@ # Django imports from django.db.models import Count, Sum, F, Q from django.db.models.functions import ExtractMonth +from django.utils import timezone # Third party imports from rest_framework import status @@ -331,8 +332,9 @@ def get(self, request, slug): .order_by("state_group") ) + current_year = timezone.now().year issue_completed_month_wise = ( - base_issues.filter(completed_at__isnull=False) + base_issues.filter(completed_at__year=current_year) .annotate(month=ExtractMonth("completed_at")) .values("month") .annotate(count=Count("*")) diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index 29b4bbf8bb5..b2a27252cba 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -66,15 +66,15 @@ def get(self, request): }, { "key": "SLACK_CLIENT_ID", - "default": os.environ.get("SLACK_CLIENT_ID", "1"), + "default": os.environ.get("SLACK_CLIENT_ID", None), }, { "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), + "default": os.environ.get("POSTHOG_API_KEY", None), }, { "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), + "default": os.environ.get("POSTHOG_HOST", None), }, { "key": "UNSPLASH_ACCESS_KEY", @@ -181,11 +181,11 @@ def get(self, request): }, { "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), + "default": os.environ.get("POSTHOG_API_KEY", None), }, { "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), + "default": os.environ.get("POSTHOG_HOST", None), }, { "key": "UNSPLASH_ACCESS_KEY", diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 63d8d28aea6..85e1e9f2e84 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,7 +20,10 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework.response import Response @@ -33,7 +36,6 @@ CycleIssueSerializer, CycleFavoriteSerializer, IssueSerializer, - IssueStateSerializer, CycleWriteSerializer, CycleUserPropertiesSerializer, ) @@ -51,7 +53,6 @@ IssueAttachment, Label, CycleUserProperties, - IssueSubscriber, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters @@ -73,7 +74,7 @@ def perform_create(self, serializer): ) def get_queryset(self): - subquery = CycleFavorite.objects.filter( + favorite_subquery = CycleFavorite.objects.filter( user=self.request.user, cycle_id=OuterRef("pk"), project_id=self.kwargs.get("project_id"), @@ -84,11 +85,28 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate(is_favorite=Exists(subquery)) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only( + "avatar", "first_name", "id" + ).distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only( + "name", "color", "id" + ).distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) .annotate( total_issues=Count( "issue_cycle", @@ -148,29 +166,6 @@ def get_queryset(self): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) .annotate( status=Case( When( @@ -190,20 +185,16 @@ def get_queryset(self): output_field=CharField(), ) ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar", "first_name", "id" - ).distinct(), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__labels", - queryset=Label.objects.only( - "name", "color", "id" - ).distinct(), + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=True, + filter=~Q( + issue_cycle__issue__assignees__id__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), ) ) .order_by("-is_favorite", "name") @@ -213,12 +204,8 @@ def get_queryset(self): def list(self, request, slug, project_id): queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + # Update the order by queryset = queryset.order_by("-is_favorite", "-created_at") # Current Cycle @@ -228,9 +215,35 @@ def list(self, request, slug, project_id): end_date__gte=timezone.now(), ) - data = CycleSerializer(queryset, many=True).data + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) - if len(data): + if data: assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=data[0]["id"], @@ -315,19 +328,45 @@ def list(self, request, slug, project_id): } if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"][ - "completion_chart" - ] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - cycle_id=data[0]["id"], + data[0]["distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) ) return Response(data, status=status.HTTP_200_OK) - cycles = CycleSerializer(queryset, many=True).data - return Response(cycles, status=status.HTTP_200_OK) + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + return Response(data, status=status.HTTP_200_OK) def create(self, request, slug, project_id): if ( @@ -337,7 +376,7 @@ def create(self, request, slug, project_id): request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleSerializer(data=request.data) + serializer = CycleWriteSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, @@ -346,12 +385,36 @@ def create(self, request, slug, project_id): cycle = ( self.get_queryset() .filter(pk=serializer.data["id"]) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) .first() ) - serializer = CycleSerializer(cycle) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) + return Response(cycle, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) @@ -364,10 +427,11 @@ def create(self, request, slug, project_id): ) def partial_update(self, request, slug, project_id, pk): - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk + queryset = ( + self.get_queryset() + .filter(workspace__slug=slug, project_id=project_id, pk=pk) ) - + cycle = queryset.first() request_data = request.data if ( @@ -375,7 +439,7 @@ def partial_update(self, request, slug, project_id, pk): and cycle.end_date < timezone.now().date() ): if "sort_order" in request_data: - # Can only change sort order + # Can only change sort order for a completed cycle`` request_data = { "sort_order": request_data.get( "sort_order", cycle.sort_order @@ -394,12 +458,71 @@ def partial_update(self, request, slug, project_id, pk): ) if serializer.is_valid(): serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + cycle = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ).first() + return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - + queryset = self.get_queryset().filter(pk=pk) + data = ( + self.get_queryset() + .filter(pk=pk) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + queryset = queryset.first() # Assignee Distribution assignee_distribution = ( Issue.objects.filter( @@ -488,7 +611,6 @@ def retrieve(self, request, slug, project_id, pk): .order_by("label_name") ) - data = CycleSerializer(queryset).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -570,7 +692,10 @@ def get_queryset(self): ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(cycle_id=self.kwargs.get("cycle_id")) .select_related("project") .select_related("workspace") @@ -589,20 +714,18 @@ def list(self, request, slug, project_id, cycle_id): ] order_by = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") - issues = ( + queryset = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .filter(project_id=project_id) .filter(workspace__slug=slug) + .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + "assignees", + "labels", + "issue_module__module", + "issue_cycle__cycle", + ) .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) @@ -621,22 +744,79 @@ def list(self, request, slug, project_id, cycle_id): .values("count") ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by(order_by) ) - serializer = IssueSerializer( - issues, many=True, fields=fields if fields else None - ) - return Response(serializer.data, status=status.HTTP_200_OK) + if self.fields: + issues = IssueSerializer( + queryset, many=True, fields=fields if fields else None + ).data + else: + issues = queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -658,52 +838,52 @@ def create(self, request, slug, project_id, cycle_id): ) # Get all CycleIssues already created - cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) - update_cycle_issue_activity = [] - record_to_create = [] - records_to_update = [] - - for issue in issues: - cycle_issue = [ - cycle_issue - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues - ] - # Update only when cycle changes - if len(cycle_issue): - if cycle_issue[0].cycle_id != cycle_id: - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_issue[0].cycle_id), - "new_cycle_id": str(cycle_id), - "issue_id": str(cycle_issue[0].issue_id), - } - ) - cycle_issue[0].cycle_id = cycle_id - records_to_update.append(cycle_issue[0]) - else: - record_to_create.append( - CycleIssue( - project_id=project_id, - workspace=cycle.workspace, - created_by=request.user, - updated_by=request.user, - cycle=cycle, - issue_id=issue, - ) - ) - - CycleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, + cycle_issues = list( + CycleIssue.objects.filter( + ~Q(cycle_id=cycle_id), issue_id__in=issues + ) ) - CycleIssue.objects.bulk_update( - records_to_update, - ["cycle"], + existing_issues = [ + str(cycle_issue.issue_id) for cycle_issue in cycle_issues + ] + new_issues = list(set(issues) - set(existing_issues)) + + # New issues to create + created_records = CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace_id=cycle.workspace_id, + created_by_id=request.user.id, + updated_by_id=request.user.id, + cycle_id=cycle_id, + issue_id=issue, + ) + for issue in new_issues + ], batch_size=10, ) + # Updated Issues + updated_records = [] + update_cycle_issue_activity = [] + # Iterate over each cycle_issue in cycle_issues + for cycle_issue in cycle_issues: + # Update the cycle_issue's cycle_id + cycle_issue.cycle_id = cycle_id + # Add the modified cycle_issue to the records_to_update list + updated_records.append(cycle_issue) + # Record the update activity + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_issue.cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Update the cycle issues + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", @@ -715,7 +895,7 @@ def create(self, request, slug, project_id, cycle_id): { "updated_cycle_issues": update_cycle_issue_activity, "created_cycle_issues": serializers.serialize( - "json", record_to_create + "json", created_records ), } ), @@ -723,16 +903,7 @@ def create(self, request, slug, project_id, cycle_id): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - - # Return all Cycle Issues - issues = self.get_queryset().values_list("issue_id", flat=True) - - return Response( - IssueSerializer( - Issue.objects.filter(pk__in=issues), many=True - ).data, - status=status.HTTP_200_OK, - ) + return Response({"message": "success"}, status=status.HTTP_201_CREATED) def destroy(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( @@ -776,6 +947,7 @@ def post(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) + # Check if any cycle intersects in the given interval cycles = Cycle.objects.filter( Q(workspace__slug=slug) & Q(project_id=project_id) @@ -785,7 +957,6 @@ def post(self, request, slug, project_id): | Q(start_date__gte=start_date, end_date__lte=end_date) ) ).exclude(pk=cycle_id) - if cycles.exists(): return Response( { @@ -909,29 +1080,6 @@ def post(self, request, slug, project_id, cycle_id): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) ) # Pass the new_cycle queryset to burndown_plot @@ -942,6 +1090,7 @@ def post(self, request, slug, project_id, cycle_id): cycle_id=cycle_id, ) + # Get the assignee distribution assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -980,7 +1129,22 @@ def post(self, request, slug, project_id, cycle_id): ) .order_by("display_name") ) + # assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + # Get the label distribution label_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -1023,7 +1187,9 @@ def post(self, request, slug, project_id, cycle_id): assignee_distribution_data = [ { "display_name": item["display_name"], - "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), "avatar": item["avatar"], "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], @@ -1032,11 +1198,14 @@ def post(self, request, slug, project_id, cycle_id): for item in assignee_distribution ] + # Label distribution serilization label_distribution_data = [ { "label_name": item["label_name"], "color": item["color"], - "label_id": str(item["label_id"]) if item["label_id"] else None, + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -1055,10 +1224,7 @@ def post(self, request, slug, project_id, cycle_id): "started_issues": old_cycle.first().started_issues, "unstarted_issues": old_cycle.first().unstarted_issues, "backlog_issues": old_cycle.first().backlog_issues, - "total_estimates": old_cycle.first().total_estimates, - "completed_estimates": old_cycle.first().completed_estimates, - "started_estimates": old_cycle.first().started_estimates, - "distribution":{ + "distribution": { "labels": label_distribution_data, "assignees": assignee_distribution_data, "completion_chart": completion_chart, diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index 1366a2886a9..62ce0d910fe 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -15,6 +15,10 @@ Func, Prefetch, ) +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce from django.utils import timezone # Third Party imports @@ -54,6 +58,7 @@ def dashboard_overview_stats(self, request, slug): pending_issues_count = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), + target_date__lt=timezone.now().date(), project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, workspace__slug=slug, @@ -130,7 +135,32 @@ def dashboard_assigned_issues(self, request, slug): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .order_by("created_at") + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ) # Priority Ordering @@ -259,6 +289,32 @@ def dashboard_created_issues(self, request, slug): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .order_by("created_at") ) diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index f76c74d9c1d..ed32a14febf 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -3,8 +3,12 @@ # Django import from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework import status @@ -21,12 +25,14 @@ IssueLink, IssueAttachment, ProjectMember, + IssueReaction, + IssueSubscriber, ) from plane.app.serializers import ( + IssueCreateSerializer, IssueSerializer, InboxSerializer, InboxIssueSerializer, - IssueCreateSerializer, IssueDetailSerializer, ) from plane.utils.issue_filters import issue_filters @@ -92,7 +98,7 @@ def get_queryset(self): Issue.objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_inbox__inbox_id=self.kwargs.get("inbox_id") + issue_inbox__inbox_id=self.kwargs.get("inbox_id"), ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") @@ -127,14 +133,75 @@ def get_queryset(self): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() def list(self, request, slug, project_id, inbox_id): filters = issue_filters(request.query_params, "GET") - issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status") - issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + ) + if self.expand: + issues = IssueSerializer( + issue_queryset, expand=self.expand, many=True + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response( - issues_data, + issues, status=status.HTTP_200_OK, ) @@ -199,8 +266,8 @@ def create(self, request, slug, project_id, inbox_id): source=request.data.get("source", "in-app"), ) - issue = (self.get_queryset().filter(pk=issue.id).first()) - serializer = IssueSerializer(issue ,expand=self.expand) + issue = self.get_queryset().filter(pk=issue.id).first() + serializer = IssueSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, inbox_id, issue_id): @@ -230,11 +297,7 @@ def partial_update(self, request, slug, project_id, inbox_id, issue_id): issue_data = request.data.pop("issue", False) if bool(issue_data): - issue = Issue.objects.get( - pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, - ) + issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first() # Only allow guests and viewers to edit name and description if project_member.role <= 10: # viewers and guests since only viewers and guests @@ -320,20 +383,54 @@ def partial_update(self, request, slug, project_id, inbox_id, issue_id): if state is not None: issue.state = state issue.save() - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) else: - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue ,expand=self.expand) + issue = self.get_queryset().filter(pk=issue_id).first() + serializer = IssueSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, inbox_id, issue_id): - issue = self.get_queryset().filter(pk=issue_id).first() - serializer = IssueDetailSerializer(issue, expand=self.expand,) + issue = ( + self.get_queryset() + .filter(pk=issue_id) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if issue is None: + return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND) + + serializer = IssueDetailSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, inbox_id, issue_id): diff --git a/apiserver/plane/app/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py index 410e6b332c3..c22ee3e52bd 100644 --- a/apiserver/plane/app/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -36,7 +36,10 @@ def get_queryset(self): workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) ) def create(self, request, slug, project_id, workspace_integration_id): diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index edefade16e4..14e0b6a9aa9 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -4,7 +4,6 @@ from itertools import chain # Django imports -from django.db import models from django.utils import timezone from django.db.models import ( Prefetch, @@ -12,19 +11,21 @@ Func, F, Q, - Count, Case, Value, CharField, When, Exists, Max, - IntegerField, ) from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third Party imports from rest_framework.response import Response @@ -67,15 +68,11 @@ Label, IssueLink, IssueAttachment, - State, IssueSubscriber, ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, - IssueVote, IssueRelation, - ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -83,6 +80,192 @@ from collections import defaultdict +class IssueListEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id): + issue_ids = request.GET.get("issues", False) + + if not issue_ids: + return Response( + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] + + queryset = ( + Issue.issue_objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = queryset.filter(**filters) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" + if order_by_param.startswith("-") + else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + if self.fields or self.expand: + issues = IssueSerializer( + queryset, many=True, fields=self.fields, expand=self.expand + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) + + class IssueViewSet(WebhookMixin, BaseViewSet): def get_serializer_class(self): return ( @@ -115,12 +298,6 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -144,12 +321,40 @@ def get_queryset(self): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = self.get_queryset().filter(**filters) # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] state_order = [ @@ -160,10 +365,6 @@ def list(self, request, slug, project_id): "cancelled", ] - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = self.get_queryset().filter(**filters) - # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( @@ -224,9 +425,42 @@ def list(self, request, slug, project_id): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueSerializer( - issue_queryset, many=True, fields=self.fields, expand=self.expand - ).data + # Only use serializer when expand or fields else return by values + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + expand=self.expand, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -259,28 +493,97 @@ def create(self, request, slug, project_id): origin=request.META.get("HTTP_ORIGIN"), ) issue = ( - self.get_queryset().filter(pk=serializer.data["id"]).first() + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + .first() ) - serializer = IssueSerializer(issue) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueDetailSerializer( - issue, fields=self.fields, expand=self.expand - ).data, - status=status.HTTP_200_OK, - ) + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueCreateSerializer( issue, data=request.data, partial=True @@ -299,18 +602,13 @@ def partial_update(self, request, slug, project_id, pk=None): origin=request.META.get("HTTP_ORIGIN"), ) issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueSerializer(issue).data, status=status.HTTP_200_OK - ) + return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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(issue).data, cls=DjangoJSONEncoder - ) issue.delete() issue_activity.delay( type="issue.activity.deleted", @@ -318,7 +616,7 @@ def destroy(self, request, slug, project_id, pk=None): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), - current_instance=current_instance, + current_instance={}, epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), @@ -326,6 +624,7 @@ def destroy(self, request, slug, project_id, pk=None): return Response(status=status.HTTP_204_NO_CONTENT) +# TODO: deprecated remove once confirmed class UserWorkSpaceIssues(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug): @@ -380,12 +679,6 @@ def get(self, request, slug): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) .filter(**filters) ).distinct() @@ -470,6 +763,7 @@ def get(self, request, slug): return Response(issues, status=status.HTTP_200_OK) +# TODO: deprecated remove once confirmed class WorkSpaceIssuesEndpoint(BaseAPIView): permission_classes = [ WorkSpaceAdminPermission, @@ -479,7 +773,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): def get(self, request, slug): issues = ( Issue.issue_objects.filter(workspace__slug=slug) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") ) serializer = IssueSerializer(issues, many=True) @@ -502,6 +799,7 @@ def get(self, request, slug, project_id, issue_id): .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) .filter(**filters) @@ -511,6 +809,7 @@ def get(self, request, slug, project_id, issue_id): IssueComment.objects.filter(issue_id=issue_id) .filter( project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) .filter(**filters) @@ -562,7 +861,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("issue") @@ -724,7 +1026,10 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("parent") @@ -772,39 +1077,56 @@ def get(self, request, slug, project_id, issue_id): Issue.issue_objects.filter( parent_id=issue_id, workspace__slug=slug ) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) + link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) .annotate(state_group=F("state__group")) ) @@ -814,13 +1136,36 @@ def get(self, request, slug, project_id, issue_id): for sub_issue in sub_issues: result[sub_issue.state_group].append(str(sub_issue.id)) - serializer = IssueSerializer( - sub_issues, - many=True, + sub_issues = sub_issues.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", ) return Response( { - "sub_issues": serializer.data, + "sub_issues": sub_issues, "state_distribution": result, }, status=status.HTTP_200_OK, @@ -897,7 +1242,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1085,7 +1433,7 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1108,15 +1456,36 @@ def get_queryset(self): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ) @method_decorator(gzip_page) def list(self, request, slug, project_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") @@ -1132,10 +1501,7 @@ def list(self, request, slug, project_id): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - ) + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -1202,20 +1568,114 @@ def list(self, request, slug, project_id): if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) ) - - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response(issues, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk=None): - issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueDetailSerializer( - issue, fields=self.fields, expand=self.expand - ).data, - status=status.HTTP_200_OK, + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def archive(self, request, slug, project_id, pk=None): + issue = Issue.issue_objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, + ) + if issue.state.group not in ["completed", "cancelled"]: + return Response( + {"error": "Can only archive completed or cancelled state group issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) + issue.archived_at = timezone.now().date() + issue.save() + + return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK) + def unarchive(self, request, slug, project_id, pk=None): issue = Issue.objects.get( @@ -1240,7 +1700,7 @@ def unarchive(self, request, slug, project_id, pk=None): issue.archived_at = None issue.save() - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) class IssueSubscriberViewSet(BaseViewSet): @@ -1276,7 +1736,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1360,7 +1823,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1429,7 +1895,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(comment_id=self.kwargs.get("comment_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1499,7 +1968,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("issue") @@ -1580,15 +2052,17 @@ def create(self, request, slug, project_id, issue_id): issue_relation = IssueRelation.objects.bulk_create( [ IssueRelation( - issue_id=issue - if relation_type == "blocking" - else issue_id, - related_issue_id=issue_id - if relation_type == "blocking" - else issue, - relation_type="blocked_by" - if relation_type == "blocking" - else relation_type, + issue_id=( + issue if relation_type == "blocking" else issue_id + ), + related_issue_id=( + issue_id if relation_type == "blocking" else issue + ), + relation_type=( + "blocked_by" + if relation_type == "blocking" + else relation_type + ), project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, @@ -1669,19 +2143,11 @@ class IssueDraftViewSet(BaseViewSet): def get_queryset(self): return ( - Issue.objects.filter( - project_id=self.kwargs.get("project_id") - ) + Issue.objects.filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .filter(is_draft=True) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -1705,6 +2171,32 @@ def get_queryset(self): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() @method_decorator(gzip_page) @@ -1728,10 +2220,7 @@ def list(self, request, slug, project_id): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - ) + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -1793,9 +2282,42 @@ def list(self, request, slug, project_id): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data + # Only use serializer when expand else return by values + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + expand=self.expand, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -1830,24 +2352,24 @@ def create(self, request, slug, project_id): issue = ( self.get_queryset().filter(pk=serializer.data["id"]).first() ) - return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) + return Response( + IssueSerializer(issue).data, status=status.HTTP_201_CREATED + ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - serializer = IssueSerializer(issue, data=request.data, partial=True) + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueCreateSerializer(issue, data=request.data, partial=True) if serializer.is_valid(): - if request.data.get( - "is_draft" - ) is not None and not request.data.get("is_draft"): - serializer.save( - created_at=timezone.now(), updated_at=timezone.now() - ) - else: - serializer.save() + serializer.save() issue_activity.delay( type="issue_draft.activity.updated", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), @@ -1862,25 +2384,57 @@ def partial_update(self, request, slug, project_id, pk): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueSerializer( - issue, fields=self.fields, expand=self.expand - ).data, - status=status.HTTP_200_OK, - ) + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) 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(issue).data, cls=DjangoJSONEncoder - ) issue.delete() issue_activity.delay( type="issue_draft.activity.deleted", @@ -1888,7 +2442,7 @@ def destroy(self, request, slug, project_id, pk=None): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), - current_instance=current_instance, + current_instance={}, epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 4792a1f7996..3b52db64f9e 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -4,11 +4,12 @@ # Django Imports from django.utils import timezone from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q -from django.core import serializers from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.core.serializers.json import DjangoJSONEncoder - +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework.response import Response @@ -24,6 +25,7 @@ ModuleFavoriteSerializer, IssueSerializer, ModuleUserPropertiesSerializer, + ModuleDetailSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -38,11 +40,9 @@ ModuleFavorite, IssueLink, IssueAttachment, - IssueSubscriber, ModuleUserProperties, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot @@ -62,7 +62,7 @@ def get_serializer_class(self): ) def get_queryset(self): - subquery = ModuleFavorite.objects.filter( + favorite_subquery = ModuleFavorite.objects.filter( user=self.request.user, module_id=OuterRef("pk"), project_id=self.kwargs.get("project_id"), @@ -73,7 +73,7 @@ def get_queryset(self): .get_queryset() .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .annotate(is_favorite=Exists(subquery)) + .annotate(is_favorite=Exists(favorite_subquery)) .select_related("project") .select_related("workspace") .select_related("lead") @@ -145,6 +145,16 @@ def get_queryset(self): ), ) ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=~Q(members__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) .order_by("-is_favorite", "-created_at") ) @@ -157,25 +167,84 @@ def create(self, request, slug, project_id): if serializer.is_valid(): serializer.save() - module = Module.objects.get(pk=serializer.data["id"]) - serializer = ModuleSerializer(module) - return Response(serializer.data, status=status.HTTP_201_CREATED) + module = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) + ).first() + return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request, slug, project_id): queryset = self.get_queryset() - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - modules = ModuleSerializer( - queryset, many=True, fields=fields if fields else None - ).data + if self.fields: + modules = ModuleSerializer( + queryset, + many=True, + fields=self.fields, + ).data + else: + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) + queryset = self.get_queryset().filter(pk=pk) assignee_distribution = ( Issue.objects.filter( @@ -269,16 +338,16 @@ def retrieve(self, request, slug, project_id, pk): .order_by("label_name") ) - data = ModuleSerializer(queryset).data + data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, "completion_chart": {}, } - if queryset.start_date and queryset.target_date: + if queryset.first().start_date and queryset.first().target_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, + queryset=queryset.first(), slug=slug, project_id=project_id, module_id=pk, @@ -289,6 +358,47 @@ def retrieve(self, request, slug, project_id, pk): status=status.HTTP_200_OK, ) + def partial_update(self, request, slug, project_id, pk): + queryset = self.get_queryset().filter(pk=pk) + serializer = ModuleWriteSerializer( + queryset.first(), data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + module = queryset.values( + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ).first() + return Response(module, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def destroy(self, request, slug, project_id, pk): module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=pk @@ -331,17 +441,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ProjectEntityPermission, ] - def get_queryset(self): return ( Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_module__module_id=self.kwargs.get("module_id") + issue_module__module_id=self.kwargs.get("module_id"), ) .select_related("workspace", "project", "state", "parent") - .prefetch_related("labels", "assignees") - .prefetch_related('issue_module__module') + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -365,6 +473,32 @@ def get_queryset(self): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() @method_decorator(gzip_page) @@ -376,15 +510,44 @@ def list(self, request, slug, project_id, module_id): ] filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) - serializer = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ) - return Response(serializer.data, status=status.HTTP_200_OK) + if self.fields or self.expand: + issues = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -420,15 +583,12 @@ def create_module_issues(self, request, slug, project_id, module_id): ) for issue in issues ] - issues = (self.get_queryset().filter(pk__in=issues)) - serializer = IssueSerializer(issues , many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - + return Response({"message": "success"}, status=status.HTTP_201_CREATED) # create multiple module inside an issue def create_issue_modules(self, request, slug, project_id, issue_id): modules = request.data.get("modules", []) - if not len(modules): + if not modules: return Response( {"error": "Modules are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -466,10 +626,7 @@ def create_issue_modules(self, request, slug, project_id, issue_id): for module in modules ] - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue) - return Response(serializer.data, status=status.HTTP_201_CREATED) - + return Response({"message": "success"}, status=status.HTTP_201_CREATED) def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( @@ -484,7 +641,9 @@ def destroy(self, request, slug, project_id, module_id, issue_id): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=json.dumps({"module_name": module_issue.module.name}), + current_instance=json.dumps( + {"module_name": module_issue.module.name} + ), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), @@ -514,7 +673,10 @@ def get_queryset(self): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1d8ff1fbb15..7ecf22fa847 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -60,7 +60,10 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) .select_related("project") diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index 5d2f9567305..6f9b2618e19 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ] def get_queryset(self): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") return self.filter_queryset( super() .get_queryset() @@ -147,6 +153,7 @@ def get_queryset(self): ) ) ) + .annotate(sort_order=Subquery(sort_order)) .prefetch_related( Prefetch( "project_projectmember", @@ -166,16 +173,8 @@ def list(self, request, slug): for field in request.GET.get("fields", "").split(",") if field ] - - sort_order_query = ProjectMember.objects.filter( - member=request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ).values("sort_order") projects = ( self.get_queryset() - .annotate(sort_order=Subquery(sort_order_query)) .order_by("sort_order", "name") ) if request.GET.get("per_page", False) and request.GET.get( @@ -204,7 +203,7 @@ def create(self, request, slug): serializer.save() # Add the user as Administrator to the project - project_member = ProjectMember.objects.create( + _ = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20, diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index ccef3d18f12..a2ed1c015ad 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -48,8 +48,8 @@ def filter_projects(self, query, slug, project_id, workspace_search): return ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, workspace__slug=slug, ) .distinct() @@ -71,6 +71,7 @@ def filter_issues(self, query, slug, project_id, workspace_search): issues = Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -95,6 +96,7 @@ def filter_cycles(self, query, slug, project_id, workspace_search): cycles = Cycle.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -118,6 +120,7 @@ def filter_modules(self, query, slug, project_id, workspace_search): modules = Module.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -141,6 +144,7 @@ def filter_pages(self, query, slug, project_id, workspace_search): pages = Page.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -164,6 +168,7 @@ def filter_views(self, query, slug, project_id, workspace_search): issue_views = IssueView.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -236,6 +241,7 @@ def get(self, request, slug, project_id): issues = Issue.issue_objects.filter( workspace__slug=slug, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) if workspace_search == "false": diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index 242061e1878..34b3d1dcc01 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -31,7 +31,10 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index 27f31f7a9ba..ade445fae80 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -1,6 +1,6 @@ # Django imports from django.db.models import ( - Prefetch, + Q, OuterRef, Func, F, @@ -13,16 +13,21 @@ ) from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.db.models import Prefetch, OuterRef, Exists +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField # Third party imports from rest_framework.response import Response from rest_framework import status # Module imports -from . import BaseViewSet, BaseAPIView +from . import BaseViewSet from plane.app.serializers import ( - GlobalViewSerializer, IssueViewSerializer, IssueSerializer, IssueViewFavoriteSerializer, @@ -30,22 +35,16 @@ from plane.app.permissions import ( WorkspaceEntityPermission, ProjectEntityPermission, - WorkspaceViewerPermission, - ProjectLitePermission, ) from plane.db.models import ( Workspace, - GlobalView, IssueView, Issue, IssueViewFavorite, - IssueReaction, IssueLink, IssueAttachment, - IssueSubscriber, ) from plane.utils.issue_filters import issue_filters -from plane.utils.grouper import group_results class GlobalViewViewSet(BaseViewSet): @@ -87,13 +86,60 @@ def get_queryset(self): .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) ) @@ -121,30 +167,7 @@ def list(self, request, slug): issue_queryset = ( self.get_queryset() .filter(**filters) - .filter(project__project_projectmember__member=self.request.user) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # Priority Ordering @@ -207,10 +230,39 @@ def list(self, request, slug): else: issue_queryset = issue_queryset.order_by(order_by_param) - serializer = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ) - return Response(serializer.data, status=status.HTTP_200_OK) + if self.fields: + issues = IssueSerializer( + issue_queryset, many=True, fields=self.fields + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): @@ -235,7 +287,10 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .annotate(is_favorite=Exists(subquery)) diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index f4d3dbbb5e0..47de86a1c17 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -22,9 +22,14 @@ When, Max, IntegerField, + Sum, ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party modules from rest_framework import status @@ -73,6 +78,9 @@ WorkspaceUserProperties, Estimate, EstimatePoint, + Module, + ModuleLink, + Cycle, ) from plane.app.permissions import ( WorkSpaceBasePermission, @@ -85,6 +93,12 @@ from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.utils.issue_filters import issue_filters from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.app.serializers.module import ( + ModuleSerializer, +) +from plane.app.serializers.cycle import ( + CycleSerializer, +) class WorkSpaceViewSet(BaseViewSet): @@ -546,7 +560,6 @@ def get_queryset(self): .get_queryset() .filter( workspace__slug=self.kwargs.get("slug"), - member__is_bot=False, is_active=True, ) .select_related("workspace", "workspace__owner") @@ -754,7 +767,6 @@ def get(self, request, slug): project_ids = ( ProjectMember.objects.filter( member=request.user, - member__is_bot=False, is_active=True, ) .values_list("project_id", flat=True) @@ -764,7 +776,6 @@ def get(self, request, slug): # Get all the project members in which the user is involved project_members = ProjectMember.objects.filter( workspace__slug=slug, - member__is_bot=False, project_id__in=project_ids, is_active=True, ).select_related("project", "member", "workspace") @@ -1075,6 +1086,7 @@ def get(self, request, slug, user_id): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .annotate(state_group=F("state__group")) @@ -1090,6 +1102,7 @@ def get(self, request, slug, user_id): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .values("priority") @@ -1112,6 +1125,7 @@ def get(self, request, slug, user_id): Issue.issue_objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, created_by_id=user_id, ) .filter(**filters) @@ -1123,6 +1137,7 @@ def get(self, request, slug, user_id): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .filter(**filters) .count() @@ -1134,6 +1149,7 @@ def get(self, request, slug, user_id): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .filter(**filters) .count() @@ -1145,6 +1161,7 @@ def get(self, request, slug, user_id): assignees__in=[user_id], state__group="completed", project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .count() @@ -1155,6 +1172,7 @@ def get(self, request, slug, user_id): workspace__slug=slug, subscriber_id=user_id, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .count() @@ -1204,6 +1222,7 @@ def get(self, request, slug, user_id): ~Q(field__in=["comment", "vote", "reaction", "draft"]), workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, actor=user_id, ).select_related("actor", "workspace", "issue", "project") @@ -1234,6 +1253,7 @@ def get(self, request, slug, user_id): Project.objects.filter( workspace__slug=slug, project_projectmember__member=request.user, + project_projectmember__is_active=True, ) .annotate( created_issues=Count( @@ -1343,6 +1363,7 @@ def get(self, request, slug, user_id): | Q(issue_subscribers__subscriber_id=user_id), workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .select_related("workspace", "project", "state", "parent") @@ -1370,6 +1391,32 @@ def get(self, request, slug, user_id): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .order_by("created_at") ).distinct() @@ -1448,6 +1495,7 @@ def get(self, request, slug): labels = Label.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) serializer = LabelSerializer(labels, many=True).data return Response(serializer, status=status.HTTP_200_OK) @@ -1462,6 +1510,7 @@ def get(self, request, slug): states = State.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) serializer = StateSerializer(states, many=True).data return Response(serializer, status=status.HTTP_200_OK) @@ -1490,6 +1539,192 @@ def get(self, request, slug): return Response(serializer.data, status=status.HTTP_200_OK) +class WorkspaceModulesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + modules = ( + Module.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + serializer = ModuleSerializer(modules, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + +class WorkspaceCyclesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + cycles = ( + Cycle.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + serializer = CycleSerializer(cycles, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + class WorkspaceUserPropertiesEndpoint(BaseAPIView): permission_classes = [ WorkspaceViewerPermission, diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 9e9b348e197..2a98c6b3324 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,21 +1,33 @@ from datetime import datetime from bs4 import BeautifulSoup - # Third party imports from celery import shared_task +from sentry_sdk import capture_exception # Django imports from django.utils import timezone from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Module imports from plane.db.models import EmailNotificationLog, User, Issue from plane.license.utils.instance_value import get_email_configuration from plane.settings.redis import redis_instance +# acquire and delete redis lock +def acquire_lock(lock_id, expire_time=300): + redis_client = redis_instance() + """Attempt to acquire a lock with a specified expiration time.""" + return redis_client.set(lock_id, 'true', nx=True, ex=expire_time) + +def release_lock(lock_id): + """Release a lock.""" + redis_client = redis_instance() + redis_client.delete(lock_id) + @shared_task def stack_email_notification(): # get all email notifications @@ -142,135 +154,155 @@ def process_html_content(content): processed_content_list.append(processed_content) return processed_content_list + @shared_task def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): + # Convert UUIDs to a sorted, concatenated string + sorted_ids = sorted(email_notification_ids) + ids_str = "_".join(str(id) for id in sorted_ids) + lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}" + + # acquire the lock for sending emails try: - ri = redis_instance() - base_api = (ri.get(str(issue_id)).decode()) - data = create_payload(notification_data=notification_data) + if acquire_lock(lock_id=lock_id): + # get the redis instance + ri = redis_instance() + base_api = (ri.get(str(issue_id)).decode()) + data = create_payload(notification_data=notification_data) - # Get email configurations - ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_FROM, - ) = get_email_configuration() + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() - receiver = User.objects.get(pk=receiver_id) - issue = Issue.objects.get(pk=issue_id) - template_data = [] - total_changes = 0 - comments = [] - actors_involved = [] - for actor_id, changes in data.items(): - actor = User.objects.get(pk=actor_id) - total_changes = total_changes + len(changes) - comment = changes.pop("comment", False) - mention = changes.pop("mention", False) - actors_involved.append(actor_id) - if comment: - comments.append( - { - "actor_comments": comment, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } - ) - if mention: - mention["new_value"] = process_html_content(mention.get("new_value")) - mention["old_value"] = process_html_content(mention.get("old_value")) - comments.append( - { - "actor_comments": mention, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } - ) - activity_time = changes.pop("activity_time") - # Parse the input string into a datetime object - formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + actors_involved = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + mention = changes.pop("mention", False) + actors_involved.append(actor_id) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + if mention: + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) + comments.append( + { + "actor_comments": mention, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + # Parse the input string into a datetime object + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") - if changes: - template_data.append( - { - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - "changes": changes, - "issue_details": { - "name": issue.name, - "identifier": f"{issue.project.identifier}-{issue.sequence_id}", - }, - "activity_time": str(formatted_time), - } - ) + if changes: + template_data.append( + { + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(formatted_time), + } + ) - summary = "Updates were made to the issue by" + summary = "Updates were made to the issue by" - # Send the mail - subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" - context = { - "data": template_data, - "summary": summary, - "actors_involved": len(set(actors_involved)), - "issue": { - "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", - "name": issue.name, + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" + context = { + "data": template_data, + "summary": summary, + "actors_involved": len(set(actors_involved)), + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + }, + "receiver": { + "email": receiver.email, + }, "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - }, - "receiver": { - "email": receiver.email, - }, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", - "workspace":str(issue.project.workspace.slug), - "project": str(issue.project.name), - "user_preference": f"{base_api}/profile/preferences/email", - "comments": comments, - } - html_content = render_to_string( - "emails/notifications/issue-updates.html", context - ) - text_content = strip_tags(html_content) - - try: - connection = get_connection( - host=EMAIL_HOST, - port=int(EMAIL_PORT), - username=EMAIL_HOST_USER, - password=EMAIL_HOST_PASSWORD, - use_tls=EMAIL_USE_TLS == "1", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace":str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + html_content = render_to_string( + "emails/notifications/issue-updates.html", context ) + text_content = strip_tags(html_content) - msg = EmailMultiAlternatives( - subject=subject, - body=text_content, - from_email=EMAIL_FROM, - to=[receiver.email], - connection=connection, - ) - msg.attach_alternative(html_content, "text/html") - msg.send() + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) - EmailNotificationLog.objects.filter( - pk__in=email_notification_ids - ).update(sent_at=timezone.now()) + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + EmailNotificationLog.objects.filter( + pk__in=email_notification_ids + ).update(sent_at=timezone.now()) + + # release the lock + release_lock(lock_id=lock_id) + return + except Exception as e: + capture_exception(e) + # release the lock + release_lock(lock_id=lock_id) + return + else: + print("Duplicate task recived. Skipping...") return - except Exception as e: + except (Issue.DoesNotExist, User.DoesNotExist) as e: + if settings.DEBUG: print(e) - return - except Issue.DoesNotExist: + release_lock(lock_id=lock_id) return diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index b99e4b1d944..d8522e7697f 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -292,6 +292,7 @@ def issue_export_task( workspace__id=workspace_id, project_id__in=project_ids, project__project_projectmember__member=exporter_instance.initiated_by_id, + project__project_projectmember__is_active=True ) .select_related( "project", "workspace", "state", "parent", "created_by" diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 42152136358..7a1dc4fc6d2 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -60,15 +60,6 @@ def service_importer(service, importer_id): batch_size=100, ) - _ = [ - send_welcome_slack.delay( - str(user.id), - True, - f"{user.email} was imported to Plane from {service}", - ) - for user in new_users - ] - workspace_users = User.objects.filter( email__in=[ user.get("email").strip().lower() diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b86ab5e783e..2a16ee911a8 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -483,17 +483,23 @@ def track_archive_at( ) ) else: + if requested_data.get("automation"): + comment = "Plane has archived the issue" + new_value = "archive" + else: + comment = "Actor has archived the issue" + new_value = "manual_archive" issue_activities.append( IssueActivity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment="Plane has archived the issue", + comment=comment, verb="updated", actor_id=actor_id, field="archived_at", old_value=None, - new_value="archive", + new_value=new_value, epoch=epoch, ) ) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 974a545fcdd..c6c4d75158c 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -79,7 +79,7 @@ def archive_old_issues(): issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps( - {"archived_at": str(archive_at)} + {"archived_at": str(archive_at), "automation": True} ), actor_id=str(project.created_by_id), issue_id=issue.id, diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index f254a3cb734..0377ccb8be0 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -12,15 +12,9 @@ PermissionsMixin, ) from django.db.models.signals import post_save -from django.conf import settings from django.dispatch import receiver from django.utils import timezone -# Third party imports -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - def get_default_onboarding(): return { @@ -144,25 +138,6 @@ def save(self, *args, **kwargs): super(User, self).save(*args, **kwargs) -@receiver(post_save, sender=User) -def send_welcome_slack(sender, instance, created, **kwargs): - try: - if created and not instance.is_bot: - # Send message on slack as well - if settings.SLACK_BOT_TOKEN: - client = WebClient(token=settings.SLACK_BOT_TOKEN) - try: - _ = client.chat_postMessage( - channel="#trackers", - text=f"New user {instance.email} has signed up and begun the onboarding journey.", - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return - except Exception as e: - capture_exception(e) - return - @receiver(post_save, sender=User) def create_user_notification(sender, instance, created, **kwargs): diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index f032092504c..5c8947e73be 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -1,4 +1,5 @@ """Global Settings""" + # Python imports import os import ssl @@ -307,7 +308,9 @@ traces_sample_rate=1, send_default_pii=True, environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), - profiles_sample_rate=1.0, + profiles_sample_rate=float( + os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5) + ), ) diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index d38b1f4c32a..3b6dea332ec 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -9,11 +9,11 @@ def search_issues(query, queryset): - fields = ["name", "sequence_id"] + fields = ["name", "sequence_id", "project__identifier"] q = Q() for field in fields: if field == "sequence_id" and len(query) <= 20: - sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query) + sequences = re.findall(r"\b\d+\b", query) for sequence_id in sequences: q |= Q(**{"sequence_id": sequence_id}) else: diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 194bf8d903a..eb0f542012b 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -30,7 +30,7 @@ openpyxl==3.1.2 beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 -cryptography==42.0.0 +cryptography==42.0.4 lxml==4.9.3 boto3==1.28.40 diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index d45f665dee8..424240cc059 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.7 \ No newline at end of file +python-3.11.8 \ No newline at end of file diff --git a/deploy/1-click/README.md b/deploy/1-click/README.md new file mode 100644 index 00000000000..88ea66c4c8e --- /dev/null +++ b/deploy/1-click/README.md @@ -0,0 +1,78 @@ +# 1-Click Self-Hosting + +In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization. + +Let's get started! + +## Installing Plane + +Installing Plane is a very easy and minimal step process. + +### Prerequisite + +- Operating System (latest): Debian / Ubuntu / Centos +- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64 + +### Downloading Latest Stable Release + +``` +curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh - + +``` + +
+ Downloading Preview Release + +``` +export BRANCH=preview + +curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh - + +``` + +NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture +
+ +-- + + +Expect this after a successful install + +![Install Output](images/install.png) + +Access the application on a browser via http://server-ip-address + +--- + +### Get Control of your Plane Server Setup + +Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane + +![Plane Help](images/help.png) + +Basic Operations: +1. Start Server using `plane-app start` +1. Stop Server using `plane-app stop` +1. Restart Server using `plane-app restart` + +Advanced Operations: +1. Configure Plane using `plane-app --configure`. This will give you options to modify + - NGINX Port (default 80) + - Domain Name (default is the local server public IP address) + - File Upload Size (default 5MB) + - External Postgres DB Url (optional - default empty) + - External Redis URL (optional - default empty) + - AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket) + +1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images) + +1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility. + +1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio. + +1. Plane App can be reinstalled using `plane-app --install`. + +Application Data is stored in the mentioned folders: +1. DB Data: /opt/plane/data/postgres +1. Redis Data: /opt/plane/data/redis +1. Minio Data: /opt/plane/data/minio \ No newline at end of file diff --git a/deploy/1-click/images/help.png b/deploy/1-click/images/help.png new file mode 100644 index 00000000000..c14603a4b1e Binary files /dev/null and b/deploy/1-click/images/help.png differ diff --git a/deploy/1-click/images/install.png b/deploy/1-click/images/install.png new file mode 100644 index 00000000000..c8ba1e5f829 Binary files /dev/null and b/deploy/1-click/images/install.png differ diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh index 917d08fdf88..9a0eac90267 100644 --- a/deploy/1-click/install.sh +++ b/deploy/1-click/install.sh @@ -1,17 +1,20 @@ #!/bin/bash +export GIT_REPO=makeplane/plane + # Check if the user has sudo access if command -v curl &> /dev/null; then sudo curl -sSL \ -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) + https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) else sudo wget -q \ -O /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) + https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) fi sudo chmod +x /usr/local/bin/plane-app -sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app +sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app +sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app -plane-app --help +plane-app -i #--help diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index 2d6ef0a6f1b..e6bd24b9ec7 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -90,9 +90,9 @@ function prepare_environment() { show_message "- Updating OS with required tools ✋" >&2 sudo "$PACKAGE_MANAGER" update -y - sudo "$PACKAGE_MANAGER" upgrade -y + # sudo "$PACKAGE_MANAGER" upgrade -y - local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap") + local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq") for tool in "${required_tools[@]}"; do if ! command -v $tool &> /dev/null; then @@ -150,11 +150,11 @@ function download_plane() { show_message "Downloading Plane Setup Files ✋" >&2 sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ - https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) + https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ - https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) + https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) # if .env does not exists rename variables-upgrade.env to .env if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then @@ -202,7 +202,7 @@ function printUsageInstructions() { } function build_local_image() { show_message "- Downloading Plane Source Code ✋" >&2 - REPO=https://github.com/makeplane/plane.git + REPO=https://github.com/$CODE_REPO.git CURR_DIR=$PWD PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null @@ -290,40 +290,40 @@ function configure_plane() { fi - smtp_host=$(read_env "EMAIL_HOST") - smtp_user=$(read_env "EMAIL_HOST_USER") - smtp_password=$(read_env "EMAIL_HOST_PASSWORD") - smtp_port=$(read_env "EMAIL_PORT") - smtp_from=$(read_env "EMAIL_FROM") - smtp_tls=$(read_env "EMAIL_USE_TLS") - smtp_ssl=$(read_env "EMAIL_USE_SSL") - - SMTP_SETTINGS=$(dialog \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --backtitle "Plane Configuration" \ - --title "SMTP Settings" \ - --form "" \ - 0 0 0 \ - "Host:" 1 1 "$smtp_host" 1 10 80 0 \ - "User:" 2 1 "$smtp_user" 2 10 80 0 \ - "Password:" 3 1 "$smtp_password" 3 10 80 0 \ - "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ - "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ - "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ - "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ - 2>&1 1>&3) - - save_smtp_settings=0 - if [ $? -eq 0 ]; then - save_smtp_settings=1 - smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) - smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) - smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) - smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) - smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) - smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) - fi + # smtp_host=$(read_env "EMAIL_HOST") + # smtp_user=$(read_env "EMAIL_HOST_USER") + # smtp_password=$(read_env "EMAIL_HOST_PASSWORD") + # smtp_port=$(read_env "EMAIL_PORT") + # smtp_from=$(read_env "EMAIL_FROM") + # smtp_tls=$(read_env "EMAIL_USE_TLS") + # smtp_ssl=$(read_env "EMAIL_USE_SSL") + + # SMTP_SETTINGS=$(dialog \ + # --ok-label "Next" \ + # --cancel-label "Skip" \ + # --backtitle "Plane Configuration" \ + # --title "SMTP Settings" \ + # --form "" \ + # 0 0 0 \ + # "Host:" 1 1 "$smtp_host" 1 10 80 0 \ + # "User:" 2 1 "$smtp_user" 2 10 80 0 \ + # "Password:" 3 1 "$smtp_password" 3 10 80 0 \ + # "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ + # "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ + # "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ + # "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ + # 2>&1 1>&3) + + # save_smtp_settings=0 + # if [ $? -eq 0 ]; then + # save_smtp_settings=1 + # smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) + # smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) + # smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) + # smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) + # smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) + # smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) + # fi external_pgdb_url=$(dialog \ --backtitle "Plane Configuration" \ --title "Using External Postgres Database ?" \ @@ -383,15 +383,6 @@ function configure_plane() { domain_name: $domain_name upload_limit: $upload_limit - save_smtp_settings: $save_smtp_settings - smtp_host: $smtp_host - smtp_user: $smtp_user - smtp_password: $smtp_password - smtp_port: $smtp_port - smtp_from: $smtp_from - smtp_tls: $smtp_tls - smtp_ssl: $smtp_ssl - save_aws_settings: $save_aws_settings aws_region: $aws_region aws_access_key: $aws_access_key @@ -413,15 +404,15 @@ function configure_plane() { fi # check enable smpt settings value - if [ $save_smtp_settings == 1 ]; then - update_env "EMAIL_HOST" "$smtp_host" - update_env "EMAIL_HOST_USER" "$smtp_user" - update_env "EMAIL_HOST_PASSWORD" "$smtp_password" - update_env "EMAIL_PORT" "$smtp_port" - update_env "EMAIL_FROM" "$smtp_from" - update_env "EMAIL_USE_TLS" "$smtp_tls" - update_env "EMAIL_USE_SSL" "$smtp_ssl" - fi + # if [ $save_smtp_settings == 1 ]; then + # update_env "EMAIL_HOST" "$smtp_host" + # update_env "EMAIL_HOST_USER" "$smtp_user" + # update_env "EMAIL_HOST_PASSWORD" "$smtp_password" + # update_env "EMAIL_PORT" "$smtp_port" + # update_env "EMAIL_FROM" "$smtp_from" + # update_env "EMAIL_USE_TLS" "$smtp_tls" + # update_env "EMAIL_USE_SSL" "$smtp_ssl" + # fi # check enable aws settings value if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then @@ -493,13 +484,24 @@ function install() { check_for_docker_images last_installed_on=$(read_config "INSTALLATION_DATE") - if [ "$last_installed_on" == "" ]; then - configure_plane - fi - printUsageInstructions + # if [ "$last_installed_on" == "" ]; then + # configure_plane + # fi + + update_env "NGINX_PORT" "80" + update_env "DOMAIN_NAME" "$MY_IP" + update_env "WEB_URL" "http://$MY_IP" + update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP" - update_config "INSTALLATION_DATE" "$(date)" + update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')" + if command -v crontab &> /dev/null; then + sudo touch /etc/cron.daily/makeplane + sudo chmod +x /etc/cron.daily/makeplane + sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane + sudo crontab /etc/cron.daily/makeplane + fi + show_message "Plane Installed Successfully ✅" show_message "" else @@ -539,12 +541,15 @@ function upgrade() { prepare_environment if [ $? -eq 0 ]; then + stop_server download_plane if [ $? -eq 0 ]; then check_for_docker_images upgrade_configuration update_config "UPGRADE_DATE" "$(date)" - + + start_server + show_message "" show_message "Plane Upgraded Successfully ✅" show_message "" @@ -601,6 +606,11 @@ function uninstall() { sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null + + if command -v crontab &> /dev/null; then + sudo crontab -r &> /dev/null + sudo rm /etc/cron.daily/makeplane &> /dev/null + fi # rm -rf $PLANE_INSTALL_DIR &> /dev/null show_message "- Configuration Cleaned ✅" @@ -642,7 +652,39 @@ function start_server() { while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do sleep 1 done + # wait for migrator container to exit with status 0 before starting the application + migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator") + + # if migrator container is running, wait for it to exit + if [ -n "$migrator_container_id" ]; then + while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do + show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2 + sleep 1 + done + fi + + # if migrator exit status is not 0, show error message and exit + if [ -n "$migrator_container_id" ]; then + migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) + if [ $migrator_exit_code -ne 0 ]; then + # show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2 + show_message "Plane Server failed to start ❌" "replace_last_line" >&2 + stop_server + exit 1 + fi + fi + + api_container_id=$(sudo docker container ls -q -f "name=plane-api") + while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete"; + do + show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2 + sleep 1 + done show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 + show_message "---------------------------------------------------------------" >&2 + show_message "Access the Plane application at http://$MY_IP" >&2 + show_message "---------------------------------------------------------------" >&2 + else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -694,7 +736,7 @@ function update_installer() { show_message "Updating Plane Installer ✋" >&2 sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) + https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null show_message "Plane Installer Updated ✅" "replace_last_line" >&2 @@ -711,12 +753,14 @@ fi PLANE_INSTALL_DIR=/opt/plane DATA_DIR=$PLANE_INSTALL_DIR/data -LOG_DIR=$PLANE_INSTALL_DIR/log +LOG_DIR=$PLANE_INSTALL_DIR/logs +CODE_REPO=${GIT_REPO:-makeplane/plane} OS_SUPPORTED=false CPU_ARCH=$(uname -m) PROGRESS_MSG="" USE_GLOBAL_IMAGES=0 PACKAGE_MANAGER="" +MY_IP=$(curl -s ifconfig.me) if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then USE_GLOBAL_IMAGES=1 @@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then restart_server elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then install + start_server + show_message "" >&2 + show_message "To view help, use plane-app --help " >&2 elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then configure_plane printUsageInstructions diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 60861878cb5..07e5ea9f623 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -56,8 +56,6 @@ x-app-env : &app-env - BUCKET_NAME=${BUCKET_NAME:-uploads} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - - services: web: <<: *app-env @@ -138,7 +136,6 @@ services: command: postgres -c 'max_connections=1000' volumes: - pgdata:/var/lib/postgresql/data - plane-redis: <<: *app-env image: redis:6.2.7-alpine diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 30f2d15d72c..16b6ea7c39c 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -13,6 +13,23 @@ YELLOW='\033[1;33m' GREEN='\033[0;32m' NC='\033[0m' # No Color +function print_header() { +clear + +cat <<"EOF" +--------------------------------------- + ____ _ +| _ \| | __ _ _ __ ___ +| |_) | |/ _` | '_ \ / _ \ +| __/| | (_| | | | | __/ +|_| |_|\__,_|_| |_|\___| + +--------------------------------------- +Project management tool from the future +--------------------------------------- +EOF +} + function buildLocalImage() { if [ "$1" == "--force-build" ]; then DO_BUILD="1" @@ -110,7 +127,7 @@ function download() { exit 0 fi else - docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull fi echo "" @@ -121,19 +138,48 @@ function download() { } function startServices() { - cd $PLANE_INSTALL_DIR - docker compose up -d --quiet-pull - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull + + local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator") + if [ -n "$migrator_container_id" ]; then + local idx=0 + while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do + local message=">>> Waiting for Data Migration to finish" + local dots=$(printf '%*s' $idx | tr ' ' '.') + echo -ne "\r$message$dots" + ((idx++)) + sleep 1 + done + fi + printf "\r\033[K" + + # if migrator exit status is not 0, show error message and exit + if [ -n "$migrator_container_id" ]; then + local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) + if [ $migrator_exit_code -ne 0 ]; then + echo "Plane Server failed to start ❌" + stopServices + exit 1 + fi + fi + + local api_container_id=$(docker container ls -q -f "name=plane-app-api") + local idx2=0 + while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q "."; + do + local message=">>> Waiting for API Service to Start" + local dots=$(printf '%*s' $idx2 | tr ' ' '.') + echo -ne "\r$message$dots" + ((idx2++)) + sleep 1 + done + printf "\r\033[K" } function stopServices() { - cd $PLANE_INSTALL_DIR - docker compose down - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down } function restartServices() { - cd $PLANE_INSTALL_DIR - docker compose restart - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart } function upgrade() { echo "***** STOPPING SERVICES ****" @@ -144,47 +190,137 @@ function upgrade() { download echo "***** PLEASE VALIDATE AND START SERVICES ****" +} +function viewSpecificLogs(){ + local SERVICE_NAME=$1 + if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then + echo "Service '$SERVICE_NAME' is running." + else + echo "Service '$SERVICE_NAME' is not running." + fi + + docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME +} +function viewLogs(){ + + ARG_SERVICE_NAME=$2 + + if [ -z "$ARG_SERVICE_NAME" ]; + then + echo + echo "Select a Service you want to view the logs for:" + echo " 1) Web" + echo " 2) Space" + echo " 3) API" + echo " 4) Worker" + echo " 5) Beat-Worker" + echo " 6) Migrator" + echo " 7) Proxy" + echo " 8) Redis" + echo " 9) Postgres" + echo " 10) Minio" + echo " 0) Back to Main Menu" + echo + read -p "Service: " DOCKER_SERVICE_NAME + + until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do + echo "Invalid selection. Please enter a number between 1 and 11." + read -p "Service: " DOCKER_SERVICE_NAME + done + + if [ -z "$DOCKER_SERVICE_NAME" ]; + then + echo "INVALID SERVICE NAME SUPPLIED" + else + case $DOCKER_SERVICE_NAME in + 1) viewSpecificLogs "web";; + 2) viewSpecificLogs "space";; + 3) viewSpecificLogs "api";; + 4) viewSpecificLogs "worker";; + 5) viewSpecificLogs "beat-worker";; + 6) viewSpecificLogs "migrator";; + 7) viewSpecificLogs "proxy";; + 8) viewSpecificLogs "plane-redis";; + 9) viewSpecificLogs "plane-db";; + 10) viewSpecificLogs "plane-minio";; + 0) askForAction;; + *) echo "INVALID SERVICE NAME SUPPLIED";; + esac + fi + elif [ -n "$ARG_SERVICE_NAME" ]; + then + ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]') + case $ARG_SERVICE_NAME in + web) viewSpecificLogs "web";; + space) viewSpecificLogs "space";; + api) viewSpecificLogs "api";; + worker) viewSpecificLogs "worker";; + beat-worker) viewSpecificLogs "beat-worker";; + migrator) viewSpecificLogs "migrator";; + proxy) viewSpecificLogs "proxy";; + redis) viewSpecificLogs "plane-redis";; + postgres) viewSpecificLogs "plane-db";; + minio) viewSpecificLogs "plane-minio";; + *) echo "INVALID SERVICE NAME SUPPLIED";; + esac + else + echo "INVALID SERVICE NAME SUPPLIED" + fi } function askForAction() { - echo - echo "Select a Action you want to perform:" - echo " 1) Install (${CPU_ARCH})" - echo " 2) Start" - echo " 3) Stop" - echo " 4) Restart" - echo " 5) Upgrade" - echo " 6) Exit" - echo - read -p "Action [2]: " ACTION - until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do - echo "$ACTION: invalid selection." + local DEFAULT_ACTION=$1 + + if [ -z "$DEFAULT_ACTION" ]; + then + echo + echo "Select a Action you want to perform:" + echo " 1) Install (${CPU_ARCH})" + echo " 2) Start" + echo " 3) Stop" + echo " 4) Restart" + echo " 5) Upgrade" + echo " 6) View Logs" + echo " 7) Exit" + echo read -p "Action [2]: " ACTION - done - echo + until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do + echo "$ACTION: invalid selection." + read -p "Action [2]: " ACTION + done + if [ -z "$ACTION" ]; + then + ACTION=2 + fi + echo + fi - if [ "$ACTION" == "1" ] + if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ] then install askForAction - elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ] + elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ] then startServices askForAction - elif [ "$ACTION" == "3" ] + elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ] then stopServices askForAction - elif [ "$ACTION" == "4" ] + elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ] then restartServices askForAction - elif [ "$ACTION" == "5" ] + elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ] then upgrade askForAction - elif [ "$ACTION" == "6" ] + elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ] + then + viewLogs $@ + askForAction + elif [ "$ACTION" == "7" ] then exit 0 else @@ -217,4 +353,8 @@ then fi mkdir -p $PLANE_INSTALL_DIR/archive -askForAction +DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml +DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env + +print_header +askForAction $@ diff --git a/package.json b/package.json index 762ce322a0d..9239a9b41fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.15.1", + "version": "0.16.0", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 7f7f4831a03..fcb6b57bbb2 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.15.1", + "version": "0.16.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 4a56f07c2dc..6524d1ff58a 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => { } } } - if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); - else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run(); + else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); }; export const unsetLinkEditor = (editor: Editor) => { diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index b0d2a10213a..dbbea671eba 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { } } -#editor-container { - table { - border-collapse: collapse; - table-layout: fixed; - margin: 0.5em 0 0.5em 0; - - border: 1px solid rgb(var(--color-border-200)); - width: 100%; - - td, - th { - min-width: 1em; - border: 1px solid rgb(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; - } - } - - th { - font-weight: bold; - text-align: left; - background-color: rgb(var(--color-primary-100)); - } - - td:hover { - background-color: rgba(var(--color-primary-300), 0.1); - } - - .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; - } - - .column-resize-handle { - position: absolute; - right: -2px; - top: 0; - bottom: -2px; - width: 2px; - background-color: rgb(var(--color-primary-400)); - pointer-events: none; - } - } -} - -.tableWrapper { - overflow-x: auto; -} - .resize-cursor { cursor: ew-resize; cursor: col-resize; diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/core/src/styles/table.css index 8a47a8c59fd..ca384d34fc6 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/core/src/styles/table.css @@ -9,15 +9,15 @@ border-collapse: collapse; table-layout: fixed; margin: 0; - margin-bottom: 3rem; - border: 1px solid rgba(var(--color-border-200)); + margin-bottom: 1rem; + border: 2px solid rgba(var(--color-border-300)); width: 100%; } .tableWrapper table td, .tableWrapper table th { min-width: 1em; - border: 1px solid rgba(var(--color-border-200)); + border: 1px solid rgba(var(--color-border-300)); padding: 10px 15px; vertical-align: top; box-sizing: border-box; @@ -43,7 +43,8 @@ .tableWrapper table th { font-weight: bold; text-align: left; - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; + color: #171717; } .tableWrapper table th * { @@ -62,6 +63,35 @@ pointer-events: none; } +.colorPicker { + display: grid; + padding: 8px 8px; + grid-template-columns: repeat(6, 1fr); + gap: 5px; +} + +.colorPickerLabel { + font-size: 0.85rem; + color: #6b7280; + padding: 8px 8px; + padding-bottom: 0px; +} + +.colorPickerItem { + margin: 2px 0px; + width: 24px; + height: 24px; + border-radius: 4px; + border: none; + cursor: pointer; +} + +.divider { + background-color: #e5e7eb; + height: 1px; + margin: 3px 0; +} + .tableWrapper table .column-resize-handle { position: absolute; right: -2px; @@ -69,7 +99,7 @@ bottom: -2px; width: 4px; z-index: 99; - background-color: rgba(var(--color-primary-400)); + background-color: #d9e4ff; pointer-events: none; } @@ -112,7 +142,7 @@ } .tableWrapper .tableControls .rowsControlDiv { - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; border: 1px solid rgba(var(--color-border-200)); border-radius: 2px; background-size: 1.25rem; @@ -127,7 +157,7 @@ } .tableWrapper .tableControls .columnsControlDiv { - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; border: 1px solid rgba(var(--color-border-200)); border-radius: 2px; background-size: 1.25rem; @@ -144,10 +174,12 @@ .tableWrapper .tableControls .tableColorPickerToolbox { border: 1px solid rgba(var(--color-border-300)); background-color: rgba(var(--color-background-100)); + border-radius: 5px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); padding: 0.25rem; display: flex; flex-direction: column; - width: 200px; + width: max-content; gap: 0.25rem; } @@ -158,7 +190,7 @@ align-items: center; gap: 0.5rem; border: none; - padding: 0.1rem; + padding: 0.3rem 0.5rem 0.1rem 0.1rem; border-radius: 4px; cursor: pointer; transition: all 0.2s; @@ -173,9 +205,7 @@ .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; + padding: 4px 0px; display: flex; align-items: center; justify-content: center; @@ -187,8 +217,8 @@ .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { - width: 2rem; - height: 2rem; + width: 1rem; + height: 1rem; } .tableToolbox { diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 5bfba3b0f55..190731fe0b6 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -25,7 +25,8 @@ import { DeleteImage } from "src/types/delete-image"; import { IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; -import { CustomCodeInlineExtension } from "./code-inline"; +import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; +import { CustomTypographyExtension } from "src/ui/extensions/typography"; export const CoreEditorExtensions = ( mentionConfig: { @@ -79,6 +80,7 @@ export const CoreEditorExtensions = ( "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), + CustomTypographyExtension, ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", 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 index aedb59411a7..403bd3f02c7 100644 --- 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 @@ -13,7 +13,7 @@ export const TableCell = Node.create({ }; }, - content: "paragraph+", + content: "block+", addAttributes() { return { @@ -33,7 +33,10 @@ export const TableCell = Node.create({ }, }, background: { - default: "none", + default: null, + }, + textColor: { + default: null, }, }; }, @@ -50,7 +53,7 @@ export const TableCell = Node.create({ return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}`, + style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`, }), 0, ]; 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 index c0decdbf803..bd994f467d5 100644 --- 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 @@ -33,7 +33,7 @@ export const TableHeader = Node.create({ }, }, background: { - default: "rgb(var(--color-primary-100))", + default: "none", }, }; }, 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 index 28c9a9a48e8..f961c058246 100644 --- 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 @@ -13,6 +13,17 @@ export const TableRow = Node.create({ }; }, + addAttributes() { + return { + background: { + default: null, + }, + textColor: { + default: null, + }, + }; + }, + content: "(tableCell | tableHeader)*", tableRole: "row", @@ -22,6 +33,12 @@ export const TableRow = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + const style = HTMLAttributes.background + ? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}` + : ""; + + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style }); + + return ["tr", attributes, 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 index c08710ec328..f73c55c09f4 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,7 +1,7 @@ export const icons = { colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` `, + toggleColumnHeader: ``, + toggleRowHeader: ``, insertBottomTableIcon: ` = { placement: "right", }; -function setCellsBackgroundColor(editor: Editor, backgroundColor: string) { +function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { return editor .chain() .focus() .updateAttributes("tableCell", { - background: backgroundColor, - }) - .updateAttributes("tableHeader", { - background: backgroundColor, + background: color.backgroundColor, + textColor: color.textColor, }) .run(); } +function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { + const { state, dispatch } = editor.view; + const { selection } = state; + if (!(selection instanceof CellSelection)) { + return false; + } + + // Get the position of the hovered cell in the selection to determine the row. + const hoveredCell = selection.$headCell || selection.$anchorCell; + + // Find the depth of the table row node + let rowDepth = hoveredCell.depth; + while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") { + rowDepth--; + } + + // If we couldn't find a tableRow node, we can't set the background color + if (hoveredCell.node(rowDepth).type.name !== "tableRow") { + return false; + } + + // Get the position where the table row starts + const rowStartPos = hoveredCell.start(rowDepth); + + // Create a transaction that sets the background color on the tableRow node. + const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, { + ...hoveredCell.node(rowDepth).attrs, + background: color.backgroundColor, + textColor: color.textColor, + }); + + dispatch(tr); + return true; +} + const columnsToolboxItems: ToolboxItem[] = [ { - label: "Add Column Before", + label: "Toggle column header", + icon: icons.toggleColumnHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(), + }, + { + label: "Add column before", icon: icons.insertLeftTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(), }, { - label: "Add Column After", + 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: Element; - }) => { - createColorPickerToolbox({ - triggerButton, - tippyOptions: { - appendTo: controlsContainer, - }, - onSelectColor: (color) => setCellsBackgroundColor(editor, color), - }); - }, + label: "Pick color", + icon: "", // No icon needed for color picker + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { - label: "Delete Column", + label: "Delete column", icon: icons.deleteColumn, action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(), }, @@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [ const rowsToolboxItems: ToolboxItem[] = [ { - label: "Add Row Above", + label: "Toggle row header", + icon: icons.toggleRowHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(), + }, + { + label: "Add row above", icon: icons.insertTopTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(), }, { - label: "Add Row Below", + 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: "Pick color", + icon: "", + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { label: "Delete Row", @@ -176,107 +187,62 @@ function createToolbox({ triggerButton, items, tippyOptions, + onSelectColor, onClickItem, + colors, }: { triggerButton: Element | null; items: ToolboxItem[]; tippyOptions: any; onClickItem: (item: ToolboxItem) => void; + onSelectColor: (color: { backgroundColor: string; textColor: string }) => void; + colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } }; }): Instance { // @ts-expect-error const toolbox = tippy(triggerButton, { content: h( "div", { className: "tableToolbox" }, - items.map((item) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick() { - onClickItem(item); - }, - }, - [ - h("div", { - className: "iconContainer", - innerHTML: item.icon, - }), - h("div", { className: "label" }, item.label), - ] - ) - ) - ), - ...tippyOptions, - }); - - return Array.isArray(toolbox) ? toolbox[0] : toolbox; -} - -function createColorPickerToolbox({ - triggerButton, - tippyOptions, - onSelectColor = () => {}, -}: { - triggerButton: HTMLElement; - tippyOptions: Partial; - onSelectColor?: (color: string) => void; -}) { - const items = { - Default: "rgb(var(--color-primary-100))", - Orange: "#FFE5D1", - Grey: "#F1F1F1", - Yellow: "#FEF3C7", - Green: "#DCFCE7", - Red: "#FFDDDD", - Blue: "#D9E4FF", - Pink: "#FFE8FA", - Purple: "#E8DAFB", - }; - - const colorPicker = tippy(triggerButton, { - ...defaultTippyOptions, - content: h( - "div", - { className: "tableColorPickerToolbox" }, - Object.entries(items).map(([key, value]) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick: () => { - onSelectColor(value); - colorPicker.hide(); - }, - }, - [ - h("div", { - className: "colorContainer", - style: { - backgroundColor: value, - }, - }), + items.map((item, index) => { + if (item.label === "Pick color") { + return h("div", { className: "flex flex-col" }, [ + h("div", { className: "divider" }), + h("div", { className: "colorPickerLabel" }, item.label), h( "div", - { - className: "label", - }, - key + { className: "colorPicker grid" }, + Object.entries(colors).map(([colorName, colorValue]) => + h("div", { + className: "colorPickerItem", + style: `background-color: ${colorValue.backgroundColor}; + color: ${colorValue.textColor || "inherit"};`, + innerHTML: colorValue?.icon || "", + onClick: () => onSelectColor(colorValue), + }) + ) ), - ] - ) - ) + h("div", { className: "divider" }), + ]); + } else { + return h( + "div", + { + className: "toolboxItem", + itemType: "div", + onClick: () => onClickItem(item), + }, + [ + h("div", { className: "iconContainer", innerHTML: item.icon }), + h("div", { className: "label" }, item.label), + ] + ); + } + }) ), - onHidden: (instance) => { - instance.destroy(); - }, - showOnCreate: true, ...tippyOptions, }); - return colorPicker; + return Array.isArray(toolbox) ? toolbox[0] : toolbox; } export class TableView implements NodeView { @@ -347,10 +313,27 @@ export class TableView implements NodeView { this.rowsControl, this.columnsControl ); + const columnColors = { + Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" }, + Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" }, + Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" }, + Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" }, + Green: { backgroundColor: "#DCFCE7", textColor: "#171717" }, + Red: { backgroundColor: "#FFDDDD", textColor: "#171717" }, + Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" }, + Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" }, + None: { + backgroundColor: "none", + textColor: "none", + icon: ``, + }, + }; this.columnsToolbox = createToolbox({ triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), items: columnsToolboxItems, + colors: columnColors, + onSelectColor: (color) => setCellsBackgroundColor(this.editor, color), tippyOptions: { ...defaultTippyOptions, appendTo: this.controls, @@ -368,10 +351,12 @@ export class TableView implements NodeView { this.rowsToolbox = createToolbox({ triggerButton: this.rowsControl.firstElementChild, items: rowsToolboxItems, + colors: columnColors, tippyOptions: { ...defaultTippyOptions, appendTo: this.controls, }, + onSelectColor: (color) => setTableRowBackgroundColor(editor, color), onClickItem: (item) => { item.action({ editor: this.editor, @@ -383,8 +368,6 @@ export class TableView implements NodeView { }); } - // Table - this.colgroup = h( "colgroup", null, @@ -437,16 +420,19 @@ export class TableView implements NodeView { } updateControls() { - const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => { - if (curr.spec.hoveredCell !== undefined) { - acc["hoveredCell"] = curr.spec.hoveredCell; - } + 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 (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"); @@ -457,12 +443,12 @@ export class TableView implements NodeView { const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; - if (!this.table) { + if (!this.table || !cellDom) { return; } - const tableRect = this.table.getBoundingClientRect(); - const cellRect = cellDom.getBoundingClientRect(); + const tableRect = this.table?.getBoundingClientRect(); + const cellRect = cellDom?.getBoundingClientRect(); if (this.columnsControl) { this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/core/src/ui/extensions/table/table/table.ts index 5600fd82a7e..ef595eee209 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -107,10 +107,9 @@ export const Table = Node.create({ addCommands() { return { insertTable: - ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ rows = 3, cols = 3, withHeaderRow = false } = {}) => ({ tr, dispatch, editor }) => { const node = createTable(editor.schema, rows, cols, withHeaderRow); - if (dispatch) { const offset = tr.selection.anchor + 1; diff --git a/packages/editor/core/src/ui/extensions/typography/index.ts b/packages/editor/core/src/ui/extensions/typography/index.ts new file mode 100644 index 00000000000..78af3c46e2c --- /dev/null +++ b/packages/editor/core/src/ui/extensions/typography/index.ts @@ -0,0 +1,109 @@ +import { Extension } from "@tiptap/core"; +import { + TypographyOptions, + emDash, + ellipsis, + leftArrow, + rightArrow, + copyright, + trademark, + servicemark, + registeredTrademark, + oneHalf, + plusMinus, + notEqual, + laquo, + raquo, + multiplication, + superscriptTwo, + superscriptThree, + oneQuarter, + threeQuarters, + impliesArrowRight, +} from "src/ui/extensions/typography/inputRules"; + +export const CustomTypographyExtension = Extension.create({ + name: "typography", + + addInputRules() { + const rules = []; + + if (this.options.emDash !== false) { + rules.push(emDash(this.options.emDash)); + } + + if (this.options.impliesArrowRight !== false) { + rules.push(impliesArrowRight(this.options.impliesArrowRight)); + } + + if (this.options.ellipsis !== false) { + rules.push(ellipsis(this.options.ellipsis)); + } + + if (this.options.leftArrow !== false) { + rules.push(leftArrow(this.options.leftArrow)); + } + + if (this.options.rightArrow !== false) { + rules.push(rightArrow(this.options.rightArrow)); + } + + if (this.options.copyright !== false) { + rules.push(copyright(this.options.copyright)); + } + + if (this.options.trademark !== false) { + rules.push(trademark(this.options.trademark)); + } + + if (this.options.servicemark !== false) { + rules.push(servicemark(this.options.servicemark)); + } + + if (this.options.registeredTrademark !== false) { + rules.push(registeredTrademark(this.options.registeredTrademark)); + } + + if (this.options.oneHalf !== false) { + rules.push(oneHalf(this.options.oneHalf)); + } + + if (this.options.plusMinus !== false) { + rules.push(plusMinus(this.options.plusMinus)); + } + + if (this.options.notEqual !== false) { + rules.push(notEqual(this.options.notEqual)); + } + + if (this.options.laquo !== false) { + rules.push(laquo(this.options.laquo)); + } + + if (this.options.raquo !== false) { + rules.push(raquo(this.options.raquo)); + } + + if (this.options.multiplication !== false) { + rules.push(multiplication(this.options.multiplication)); + } + + if (this.options.superscriptTwo !== false) { + rules.push(superscriptTwo(this.options.superscriptTwo)); + } + + if (this.options.superscriptThree !== false) { + rules.push(superscriptThree(this.options.superscriptThree)); + } + + if (this.options.oneQuarter !== false) { + rules.push(oneQuarter(this.options.oneQuarter)); + } + + if (this.options.threeQuarters !== false) { + rules.push(threeQuarters(this.options.threeQuarters)); + } + + return rules; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/typography/inputRules.ts b/packages/editor/core/src/ui/extensions/typography/inputRules.ts new file mode 100644 index 00000000000..f528e92426d --- /dev/null +++ b/packages/editor/core/src/ui/extensions/typography/inputRules.ts @@ -0,0 +1,137 @@ +import { textInputRule } from "@tiptap/core"; + +export interface TypographyOptions { + emDash: false | string; + ellipsis: false | string; + leftArrow: false | string; + rightArrow: false | string; + copyright: false | string; + trademark: false | string; + servicemark: false | string; + registeredTrademark: false | string; + oneHalf: false | string; + plusMinus: false | string; + notEqual: false | string; + laquo: false | string; + raquo: false | string; + multiplication: false | string; + superscriptTwo: false | string; + superscriptThree: false | string; + oneQuarter: false | string; + threeQuarters: false | string; + impliesArrowRight: false | string; +} + +export const emDash = (override?: string) => + textInputRule({ + find: /--$/, + replace: override ?? "—", + }); + +export const impliesArrowRight = (override?: string) => + textInputRule({ + find: /=>$/, + replace: override ?? "⇒", + }); + +export const leftArrow = (override?: string) => + textInputRule({ + find: /<-$/, + replace: override ?? "←", + }); + +export const rightArrow = (override?: string) => + textInputRule({ + find: /->$/, + replace: override ?? "→", + }); + +export const ellipsis = (override?: string) => + textInputRule({ + find: /\.\.\.$/, + replace: override ?? "…", + }); + +export const copyright = (override?: string) => + textInputRule({ + find: /\(c\)$/, + replace: override ?? "©", + }); + +export const trademark = (override?: string) => + textInputRule({ + find: /\(tm\)$/, + replace: override ?? "™", + }); + +export const servicemark = (override?: string) => + textInputRule({ + find: /\(sm\)$/, + replace: override ?? "℠", + }); + +export const registeredTrademark = (override?: string) => + textInputRule({ + find: /\(r\)$/, + replace: override ?? "®", + }); + +export const oneHalf = (override?: string) => + textInputRule({ + find: /(?:^|\s)(1\/2)\s$/, + replace: override ?? "½", + }); + +export const plusMinus = (override?: string) => + textInputRule({ + find: /\+\/-$/, + replace: override ?? "±", + }); + +export const notEqual = (override?: string) => + textInputRule({ + find: /!=$/, + replace: override ?? "≠", + }); + +export const laquo = (override?: string) => + textInputRule({ + find: /<<$/, + replace: override ?? "«", + }); + +export const raquo = (override?: string) => + textInputRule({ + find: />>$/, + replace: override ?? "»", + }); + +export const multiplication = (override?: string) => + textInputRule({ + find: /\d+\s?([*x])\s?\d+$/, + replace: override ?? "×", + }); + +export const superscriptTwo = (override?: string) => + textInputRule({ + find: /\^2$/, + replace: override ?? "²", + }); + +export const superscriptThree = (override?: string) => + textInputRule({ + find: /\^3$/, + replace: override ?? "³", + }); + +export const oneQuarter = (override?: string) => + textInputRule({ + find: /(?:^|\s)(1\/4)\s$/, + replace: override ?? "¼", + }); + +export const threeQuarters = (override?: string) => + textInputRule({ + find: /(?:^|\s)(3\/4)\s$/, + replace: override ?? "¾", + }); diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 2aaeb4264cb..1846efe4751 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -42,15 +42,6 @@ export function CoreEditorProps( return false; }, handleDrop: (view, event, _slice, moved) => { - if (typeof window !== "undefined") { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return; - } - } - } if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { event.preventDefault(); const file = event.dataTransfer.files[0]; diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index b33bc12fbf4..bd1f2d90fea 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/document-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx index 136d04e01e0..97191543991 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -40,9 +40,11 @@ export const LinkEditView = ({ const [positionRef, setPositionRef] = useState({ from: from, to: to }); const [localUrl, setLocalUrl] = useState(viewProps.url); - const linkRemoved = useRef(); + const linkRemoved = useRef(); const getText = (from: number, to: number) => { + if (to >= editor.state.doc.content.size) return ""; + const text = editor.state.doc.textBetween(from, to, "\n"); return text; }; @@ -72,10 +74,12 @@ export const LinkEditView = ({ const url = isValidUrl(localUrl) ? localUrl : viewProps.url; + if (to >= editor.state.doc.content.size) return; + editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url }))); }, - [localUrl] + [localUrl, editor, from, to, viewProps.url] ); const handleUpdateText = (text: string) => { diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx index 869c7a8c6f3..e586bfd80cc 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx @@ -145,7 +145,7 @@ const IssueSuggestionList = ({
{sections.map((section) => { const sectionItems = displayedItems[section]; @@ -175,8 +175,8 @@ const IssueSuggestionList = ({ >
{item.identifier}
-
-

{item.title}

+
+

{item.title}

))} diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx index be57a4a91c5..397e8c576dd 100644 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(editor)]; - if (shouldShowImageItem()) { - items.push(ImageItem(editor, uploadFile, setIsSubmitting)); - } - + items.push(ImageItem(editor, uploadFile, setIsSubmitting)); return items; } const complexItems: BubbleMenuItem[] = getComplexItems(); - function shouldShowImageItem(): boolean { - if (typeof window !== "undefined") { - const selectionRange: any = window?.getSelection(); - const { selection } = props.editor.state; - - if (selectionRange.rangeCount !== 0) { - const range = selectionRange.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return false; - } - if (isCellSelection(selection)) { - return false; - } - } - return true; - } - return false; - } - return (
diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 8481abdf31b..0bdd70824eb 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index af99fec61f6..ce4088413c3 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -35,7 +35,7 @@ export interface DragHandleOptions { } function absoluteRect(node: Element) { - const data = node.getBoundingClientRect(); + const data = node?.getBoundingClientRect(); return { top: data.top, @@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) { } function nodePosAtDOM(node: Element, view: EditorView) { - const boundingRect = node.getBoundingClientRect(); + const boundingRect = node?.getBoundingClientRect(); if (node.nodeName === "IMG") { return view.posAtCoords({ diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 71d70399dc6..e033f620a6c 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", 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 71ad4e0e1c1..c6786698dd4 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 @@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(props.editor)]; - if (shouldShowImageItem()) { - items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); - } + items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); return items; } const complexItems: BubbleMenuItem[] = getComplexItems(); - function shouldShowImageItem(): boolean { - if (typeof window !== "undefined") { - const selectionRange: any = window?.getSelection(); - const { selection } = props.editor.state; - - if (selectionRange.rangeCount !== 0) { - const range = selectionRange.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return false; - } - if (isCellSelection(selection)) { - return false; - } - } - return true; - } - return false; - } - const handleAccessChange = (accessKey: string) => { props.commentAccessSpecifier?.onAccessChange(accessKey); }; diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index a85a8b998e5..0f3d0d8f70e 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 6bfe6726172..2fee408c9bc 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,7 +1,7 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.15.1", + "version": "0.16.0", "main": "index.js", "license": "MIT", "dependencies": { diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 50ede8674b9..d7e807b91f3 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.15.1", + "version": "0.16.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 42ce3fed5d4..e0829e87be1 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.15.1", + "version": "0.16.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 0e5c2eb16f0..9c993884533 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.15.1", + "version": "0.16.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 5d715385a0e..e7ec66ae212 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -30,10 +30,9 @@ export interface ICycle { is_favorite: boolean; issue: string; name: string; - owned_by: string; + owned_by_id: string; progress_snapshot: TProgressSnapshot; - project: string; - project_detail: IProjectLite; + project_id: string; status: TCycleGroups; sort_order: number; start_date: string | null; @@ -42,12 +41,11 @@ export interface ICycle { unstarted_issues: number; updated_at: Date; updated_by: string; - assignees: IUserLite[]; + assignee_ids: string[]; view_props: { filters: IIssueFilterOptions; }; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export type TProgressSnapshot = { diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index 1f4a35dd470..ebe53713814 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -58,7 +58,6 @@ export interface IIssueLink { export interface ILinkDetails { created_at: Date; created_by: string; - created_by_detail: IUserLite; id: string; metadata: any; title: string; @@ -204,6 +203,8 @@ export interface ViewFlags { export type GroupByColumnTypes = | "project" + | "cycle" + | "module" | "state" | "state_detail.group" | "priority" diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 527abe63038..42c95dc4e30 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -1,4 +1,7 @@ import { TIssuePriorities } from "../issues"; +import { TIssueAttachment } from "./issue_attachment"; +import { TIssueLink } from "./issue_link"; +import { TIssueReaction } from "./issue_reaction"; // new issue structure types export type TIssue = { @@ -34,7 +37,12 @@ export type TIssue = { updated_by: string; is_draft: boolean; - is_subscribed: boolean; + is_subscribed?: boolean; + + parent?: partial; + issue_reactions?: TIssueReaction[]; + issue_attachment?: TIssueAttachment[]; + issue_link?: TIssueLink[]; // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts index 90daa08faeb..7c3819e004d 100644 --- a/packages/types/src/issues/issue_attachment.d.ts +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -1,17 +1,15 @@ export type TIssueAttachment = { id: string; - created_at: string; - updated_at: string; attributes: { name: string; size: number; }; asset: string; - created_by: string; + issue_id: string; + + //need + updated_at: string; updated_by: string; - project: string; - workspace: string; - issue: string; }; export type TIssueAttachmentMap = { diff --git a/packages/types/src/issues/issue_link.d.ts b/packages/types/src/issues/issue_link.d.ts index 2c469e6829b..10f0d279204 100644 --- a/packages/types/src/issues/issue_link.d.ts +++ b/packages/types/src/issues/issue_link.d.ts @@ -4,11 +4,13 @@ export type TIssueLinkEditableFields = { }; export type TIssueLink = TIssueLinkEditableFields & { - created_at: Date; - created_by: string; - created_by_detail: IUserLite; + created_by_id: string; id: string; metadata: any; + issue_id: string; + + //need + created_at: Date; }; export type TIssueLinkMap = { diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts index 88ef274261a..a4eaee0a876 100644 --- a/packages/types/src/issues/issue_reaction.d.ts +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -1,15 +1,8 @@ export type TIssueReaction = { - actor: string; - actor_detail: IUserLite; - created_at: Date; - created_by: string; + actor_id: string; id: string; - issue: string; - project: string; + issue_id: string; reaction: string; - updated_at: Date; - updated_by: string; - workspace: string; }; export type TIssueReactionMap = { diff --git a/packages/types/src/modules.d.ts b/packages/types/src/modules.d.ts index 0e49da7fe07..fcf2d86a21a 100644 --- a/packages/types/src/modules.d.ts +++ b/packages/types/src/modules.d.ts @@ -27,16 +27,12 @@ export interface IModule { labels: TLabelsDistribution[]; }; id: string; - lead: string | null; - lead_detail: IUserLite | null; + lead_id: string | null; link_module: ILinkDetails[]; - links_list: ModuleLink[]; - members: string[]; - members_detail: IUserLite[]; + member_ids: string[]; is_favorite: boolean; name: string; - project: string; - project_detail: IProjectLite; + project_id: string; sort_order: number; start_date: string | null; started_issues: number; @@ -49,8 +45,7 @@ export interface IModule { view_props: { filters: IIssueFilterOptions; }; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export interface ModuleIssueResponse { diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 8033c19a94e..652e2776f51 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -12,27 +12,27 @@ export interface PaginatedUserNotification { } export interface IUserNotification { - id: string; - created_at: Date; - updated_at: Date; + archived_at: string | null; + created_at: string; + created_by: null; data: Data; entity_identifier: string; entity_name: string; - title: string; + id: string; message: null; message_html: string; message_stripped: null; - sender: string; + project: string; read_at: Date | null; - archived_at: Date | null; + receiver: string; + sender: string; snoozed_till: Date | null; - created_by: null; - updated_by: null; - workspace: string; - project: string; + title: string; triggered_by: string; triggered_by_details: IUserLite; - receiver: string; + updated_at: Date; + updated_by: null; + workspace: string; } export interface Data { diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 61cc7081b29..c2c98def330 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -14,6 +14,8 @@ export type TIssueGroupByOptions = | "project" | "assignees" | "mentions" + | "cycle" + | "module" | null; export type TIssueOrderByOptions = @@ -30,6 +32,10 @@ export type TIssueOrderByOptions = | "-assignees__first_name" | "labels__name" | "-labels__name" + | "modules__name" + | "-modules__name" + | "cycle__name" + | "-cycle__name" | "target_date" | "-target_date" | "estimate_point" @@ -56,6 +62,8 @@ export type TIssueParams = | "created_by" | "subscriber" | "labels" + | "cycle" + | "module" | "start_date" | "target_date" | "project" @@ -75,6 +83,8 @@ export interface IIssueFilterOptions { labels?: string[] | null; priority?: string[] | null; project?: string[] | null; + cycle?: string[] | null; + module?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; @@ -109,6 +119,8 @@ export interface IIssueDisplayProperties { estimate?: boolean; created_on?: boolean; updated_on?: boolean; + modules?: boolean; + cycle?: boolean; } export type TIssueKanbanFilters = { diff --git a/packages/ui/package.json b/packages/ui/package.json index 912fcfeb8fb..756a0f2f1be 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.15.1", + "version": "0.16.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index dbdbaf0950a..ee4b66d7b88 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -5,10 +5,11 @@ export type TControlLink = React.AnchorHTMLAttributes & { onClick: () => void; children: React.ReactNode; target?: string; + disabled?: boolean; }; export const ControlLink: React.FC = (props) => { - const { href, onClick, children, target = "_self", ...rest } = props; + const { href, onClick, children, target = "_self", disabled = false, ...rest } = props; const LEFT_CLICK_EVENT_CODE = 0; const _onClick = (event: React.MouseEvent) => { @@ -19,6 +20,8 @@ export const ControlLink: React.FC = (props) => { } }; + if (disabled) return <>{children}; + return ( {children} diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 37aba932a59..d1623dddfdb 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -27,6 +27,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { noBorder = false, noChevron = false, optionsClassName = "", + menuItemsClassName = "", verticalEllipsis = false, portalElement, menuButtonOnClick, @@ -70,7 +71,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { useOutsideClickDetector(dropdownRef, closeDropdown); let menuItems = ( - +
{ }; const MenuItem: React.FC = (props) => { - const { children, onClick, className = "" } = props; + const { children, disabled = false, onClick, className } = props; return ( - + {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 0fa183cb2ce..37608ea8db6 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -122,7 +122,7 @@ const Option = (props: ICustomSelectItemProps) => { value={value} className={({ active }) => cn( - "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200", + "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200 flex items-center justify-between gap-2", { "bg-custom-background-80": active, }, @@ -131,10 +131,10 @@ const Option = (props: ICustomSelectItemProps) => { } > {({ selected }) => ( -
-
{children}
+ <> + {children} {selected && } -
+ )} ); diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 930f332b9c0..93ac63b97b4 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -24,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + menuItemsClassName?: string; onMenuClose?: () => void; closeOnSelect?: boolean; portalElement?: Element | null; @@ -64,6 +65,7 @@ export type ICustomSearchSelectProps = IDropdownProps & export interface ICustomMenuItemProps { children: React.ReactNode; + disabled?: boolean; onClick?: (args?: any) => void; className?: string; } diff --git a/space/package.json b/space/package.json index 9ee7279cd41..a1d600a60bf 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.15.1", + "version": "0.16.0", "private": true, "scripts": { "dev": "turbo run develop", diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index ee677fe91e1..6a7b3c7b9d0 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -20,7 +20,8 @@ export const CustomAnalyticsSidebarHeader = observer(() => { const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; - const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; + const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; return ( <> @@ -57,7 +58,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Lead
- {moduleDetails.lead_detail?.display_name} + {moduleLeadDetails && {moduleLeadDetails?.display_name}}
Start Date
diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx index c2e12dc3c2c..3ad2805f28f 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -5,7 +5,7 @@ import { mutate } from "swr"; // services import { AnalyticsService } from "services/analytics.service"; // hooks -import { useCycle, useModule, useProject, useUser } from "hooks/store"; +import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; @@ -39,6 +39,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { // store hooks const { currentUser } = useUser(); const { workspaceProjectIds, getProjectById } = useProject(); + const { getWorkspaceById } = useWorkspace(); + const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchModuleDetails, getModuleById } = useModule(); @@ -70,11 +72,14 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { if (cycleDetails || moduleDetails) { const details = cycleDetails || moduleDetails; - eventPayload.workspaceId = details?.workspace_detail?.id; - eventPayload.workspaceName = details?.workspace_detail?.name; - eventPayload.projectId = details?.project_detail.id; - eventPayload.projectIdentifier = details?.project_detail.identifier; - eventPayload.projectName = details?.project_detail.name; + const currentProjectDetails = getProjectById(details?.project_id || ""); + const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || ""); + + eventPayload.workspaceId = details?.workspace_id; + eventPayload.workspaceName = currentWorkspaceDetails?.name; + eventPayload.projectId = details?.project_id; + eventPayload.projectIdentifier = currentProjectDetails?.identifier; + eventPayload.projectName = currentProjectDetails?.name; } if (cycleDetails) { @@ -138,14 +143,18 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; - return ( -
- {analytics ? analytics.total : "..."}
Issues
+ {analytics ? analytics.total : "..."} +
Issues
{isProjectLevel && (
@@ -154,8 +163,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { (cycleId ? cycleDetails?.created_at : moduleId - ? moduleDetails?.created_at - : projectDetails?.created_at) ?? "" + ? moduleDetails?.created_at + : projectDetails?.created_at) ?? "" )}
)} diff --git a/web/components/analytics/scope-and-demand/scope-and-demand.tsx b/web/components/analytics/scope-and-demand/scope-and-demand.tsx index 0f9e2c712d8..6f26ad73ff9 100644 --- a/web/components/analytics/scope-and-demand/scope-and-demand.tsx +++ b/web/components/analytics/scope-and-demand/scope-and-demand.tsx @@ -47,7 +47,7 @@ export const ScopeAndDemand: React.FC = (props) => { <> {!defaultAnalyticsError ? ( defaultAnalytics ? ( -
+
diff --git a/web/components/api-token/modal/form.tsx b/web/components/api-token/modal/form.tsx index ae7717b3933..77753e64d20 100644 --- a/web/components/api-token/modal/form.tsx +++ b/web/components/api-token/modal/form.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; import { add } from "date-fns"; import { Controller, useForm } from "react-hook-form"; +import { DateDropdown } from "components/dropdowns"; import { Calendar } from "lucide-react"; // hooks import useToast from "hooks/use-toast"; -// components -import { CustomDatePicker } from "components/ui"; // ui import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; // helpers @@ -167,7 +166,7 @@ export const CreateApiTokenForm: React.FC = (props) => { @@ -194,20 +193,13 @@ export const CreateApiTokenForm: React.FC = (props) => { }} /> {watch("expired_at") === "custom" && ( - setCustomDate(date ? new Date(date) : null)} + onChange={(date) => setCustomDate(date)} minDate={tomorrow} - customInput={ -
- - {customDate ? renderFormattedDate(customDate) : "Set date"} -
- } + icon={} + buttonVariant="border-with-text" + placeholder="Set date" disabled={neverExpires} /> )} diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 974efff3a1a..d871b64d047 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => {

Auto-archive closed issues

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

@@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { { handleChange({ archive_in: val }); @@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80" onClick={() => setmonthModal(true)} > - Customise Time Range + Customize time range diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 8d6662c112d..2ae4d1f9c65 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => {

Auto-close issues

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

@@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { { handleChange({ close_in: val }); @@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" onClick={() => setmonthModal(true)} > - Customize Time Range + Customize time range diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index 1d306bb0401..01d07f64a82 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen,
- Customise Time Range + Customize time range
diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index dbf349f9d73..b52976aa8c8 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -154,237 +154,239 @@ export const CommandModal: React.FC = observer(() => {
-
- - -
- { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - onKeyDown={(e) => { - // when search is empty and page is undefined - // when user tries to close the modal with esc - if (e.key === "Escape" && !page && !searchTerm) closePalette(); +
+
+ + +
+ { + if (value.toLowerCase().includes(search.toLowerCase())) return 1; + return 0; + }} + onKeyDown={(e) => { + // when search is empty and page is undefined + // when user tries to close the modal with esc + if (e.key === "Escape" && !page && !searchTerm) closePalette(); - // Escape goes to previous page - // Backspace goes to previous page when search is empty - if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { - e.preventDefault(); - setPages((pages) => pages.slice(0, -1)); - setPlaceholder("Type a command or search..."); - } - }} - > -
pages.slice(0, -1)); + setPlaceholder("Type a command or search..."); + } + }} > - {issueDetails && ( -
- {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name} -
- )} - {projectId && ( - -
- - setIsWorkspaceLevel((prevData) => !prevData)} - /> +
+ {issueDetails && ( +
+ {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
- - )} -
-
-
- - - {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
- )} + )} + {projectId && ( + +
+ + setIsWorkspaceLevel((prevData) => !prevData)} + /> +
+
+ )} +
+
+
- {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
No results found.
- )} + + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} - {(isLoading || isSearching) && ( - - - - - - - - - )} + {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
No results found.
+ )} - {debouncedSearchTerm !== "" && ( - - )} + {(isLoading || isSearching) && ( + + + + + + + + + )} - {!page && ( - <> - {/* issue actions */} - {issueId && ( - setPages(newPages)} - setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} - setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} - /> - )} - - { - closePalette(); - setTrackElement("Command Palette"); - toggleCreateIssueModal(true); - }} - className="focus:bg-custom-background-80" - > -
- - Create new issue -
- C -
-
+ {debouncedSearchTerm !== "" && ( + + )} - {workspaceSlug && ( - + {!page && ( + <> + {/* issue actions */} + {issueId && ( + setPages(newPages)} + setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} + setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} + /> + )} + { closePalette(); - setTrackElement("Command palette"); - toggleCreateProjectModal(true); + setTrackElement("Command Palette"); + toggleCreateIssueModal(true); }} - className="focus:outline-none" + className="focus:bg-custom-background-80" >
- - Create new project + + Create new issue
- P + C
- )} - {/* project actions */} - {projectId && } + {workspaceSlug && ( + + { + closePalette(); + setTrackElement("Command palette"); + toggleCreateProjectModal(true); + }} + className="focus:outline-none" + > +
+ + Create new project +
+ P +
+
+ )} - - { - setPlaceholder("Search workspace settings..."); - setSearchTerm(""); - setPages([...pages, "settings"]); - }} - className="focus:outline-none" - > -
- - Search settings... -
-
-
- - -
- - Create new workspace -
-
- { - setPlaceholder("Change interface theme..."); - setSearchTerm(""); - setPages([...pages, "change-interface-theme"]); - }} - className="focus:outline-none" - > -
- - Change interface theme... -
-
-
+ {/* project actions */} + {projectId && } + + + { + setPlaceholder("Search workspace settings..."); + setSearchTerm(""); + setPages([...pages, "settings"]); + }} + className="focus:outline-none" + > +
+ + Search settings... +
+
+
+ + +
+ + Create new workspace +
+
+ { + setPlaceholder("Change interface theme..."); + setSearchTerm(""); + setPages([...pages, "change-interface-theme"]); + }} + className="focus:outline-none" + > +
+ + Change interface theme... +
+
+
- {/* help options */} - - - )} + {/* help options */} + + + )} - {/* workspace settings actions */} - {page === "settings" && workspaceSlug && ( - - )} + {/* workspace settings actions */} + {page === "settings" && workspaceSlug && ( + + )} - {/* issue details page actions */} - {page === "change-issue-state" && issueDetails && ( - - )} - {page === "change-issue-priority" && issueDetails && ( - - )} - {page === "change-issue-assignee" && issueDetails && ( - - )} + {/* issue details page actions */} + {page === "change-issue-state" && issueDetails && ( + + )} + {page === "change-issue-priority" && issueDetails && ( + + )} + {page === "change-issue-assignee" && issueDetails && ( + + )} - {/* theme actions */} - {page === "change-interface-theme" && ( - { - closePalette(); - setPages((pages) => pages.slice(0, -1)); - }} - /> - )} -
- -
- - + {/* theme actions */} + {page === "change-interface-theme" && ( + { + closePalette(); + setPages((pages) => pages.slice(0, -1)); + }} + /> + )} + +
+
+
+
+
diff --git a/web/components/core/filters/date-filter-modal.tsx b/web/components/core/filters/date-filter-modal.tsx index 9b460bf283b..c5238ec1c86 100644 --- a/web/components/core/filters/date-filter-modal.tsx +++ b/web/components/core/filters/date-filter-modal.tsx @@ -1,13 +1,12 @@ import { Fragment } from "react"; import { Controller, useForm } from "react-hook-form"; -import DatePicker from "react-datepicker"; +import { DayPicker } from "react-day-picker"; import { Dialog, Transition } from "@headlessui/react"; +import { X } from "lucide-react"; // components import { DateFilterSelect } from "./date-filter-select"; // ui import { Button } from "@plane/ui"; -// icons -import { X } from "lucide-react"; // helpers import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper"; @@ -46,9 +45,6 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false; - const nextDay = new Date(watch("date1")); - nextDay.setDate(nextDay.getDate() + 1); - return ( @@ -91,12 +87,15 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o control={control} name="date1" render={({ field: { value, onChange } }) => ( - onChange(val)} - dateFormat="dd-MM-yyyy" - calendarClassName="h-full" - inline + onChange(date)} + mode="single" + disabled={[ + { after: new Date(watch("date2")) } + ]} + className="border border-custom-border-200 p-3 rounded-md" /> )} /> @@ -105,13 +104,15 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o control={control} name="date2" render={({ field: { value, onChange } }) => ( - onChange(date)} + mode="single" + disabled={[ + { before: new Date(watch("date1")) } + ]} + className="border border-custom-border-200 p-3 rounded-md" /> )} /> diff --git a/web/components/core/filters/date-filter-select.tsx b/web/components/core/filters/date-filter-select.tsx index 2585e2f9575..9bb10f800d0 100644 --- a/web/components/core/filters/date-filter-select.tsx +++ b/web/components/core/filters/date-filter-select.tsx @@ -51,10 +51,10 @@ export const DateFilterSelect: React.FC = ({ title, value, onChange }) => > {dueDateRange.map((option, index) => ( - <> +
{option.icon} {title} {option.name} - +
))} diff --git a/web/components/core/index.ts b/web/components/core/index.ts index 4f99f360612..f68ff5f3cbf 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -4,3 +4,4 @@ export * from "./sidebar"; export * from "./theme"; export * from "./activity"; export * from "./image-picker-popover"; +export * from "./page-title"; diff --git a/web/components/core/modals/bulk-delete-issues-modal.tsx b/web/components/core/modals/bulk-delete-issues-modal.tsx index f5eab83efa3..39be2872b59 100644 --- a/web/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal.tsx @@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { const [query, setQuery] = useState(""); // fetching project issues. const { data: issues } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, - workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null + workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, + workspaceSlug && projectId && isOpen + ? () => issueService.getIssues(workspaceSlug as string, projectId as string) + : null ); const { setToastAlert } = useToast(); diff --git a/web/components/core/page-title.tsx b/web/components/core/page-title.tsx new file mode 100644 index 00000000000..f9f4e94b2f0 --- /dev/null +++ b/web/components/core/page-title.tsx @@ -0,0 +1,18 @@ +import Head from "next/head"; + +type PageHeadTitleProps = { + title?: string; + description?: string; +}; + +export const PageHead: React.FC = (props) => { + const { title } = props; + + if (!title) return null; + + return ( + + {title} + + ); +}; diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 52b1e9de1b6..48a5e16b721 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -8,6 +8,9 @@ import { calculateTimeAgo } from "helpers/date-time.helper"; import { ILinkDetails, UserAuth } from "@plane/types"; // hooks import useToast from "hooks/use-toast"; +import { observer } from "mobx-react"; +import { useMeasure } from "@nivo/core"; +import { useMember } from "hooks/store"; type Props = { links: ILinkDetails[]; @@ -16,9 +19,10 @@ type Props = { userAuth: UserAuth; }; -export const LinksList: React.FC = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { +export const LinksList: React.FC = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { // toast const { setToastAlert } = useToast(); + const { getUserDetails } = useMember(); const isNotAllowed = userAuth.isGuest || userAuth.isViewer; @@ -33,70 +37,75 @@ export const LinksList: React.FC = ({ links, handleDeleteLink, handleEdit return ( <> - {links.map((link) => ( -
-
-
- - - - - copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} - > - {link.title && link.title !== "" ? link.title : link.url} + {links.map((link) => { + const createdByDetails = getUserDetails(link.created_by); + return ( +
+
+
+ + - -
- - {!isNotAllowed && ( -
- - - - - + + copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} + > + {link.title && link.title !== "" ? link.title : link.url} + +
- )} -
-
-

- Added {calculateTimeAgo(link.created_at)} -
- by{" "} - {link.created_by_detail.is_bot - ? link.created_by_detail.first_name + " Bot" - : link.created_by_detail.display_name} -

+ + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+
+

+ Added {calculateTimeAgo(link.created_at)} +
+ {createdByDetails && ( + <> + by{" "} + {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name} + + )} +

+
-
- ))} + ); + })} ); -}; +}); diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index cb433de05cc..0212e49802a 100644 --- a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -5,17 +5,17 @@ import { observer } from "mobx-react"; type Props = { onClick?: () => void; -} +}; export const SidebarHamburgerToggle: FC = observer((props) => { - const { onClick } = props + const { onClick } = props; const { theme: themeStore } = useApplication(); return (
{ - if (onClick) onClick() - else themeStore.toggleMobileSidebar() + if (onClick) onClick(); + else themeStore.toggleSidebar(); }} > diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index 8fd02140388..12c387f471e 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -125,7 +125,10 @@ export const SidebarProgressStats: React.FC = ({ - + {distribution?.assignees.length > 0 ? ( distribution.assignees.map((assignee, index) => { if (assignee.assignee_id) @@ -182,7 +185,10 @@ export const SidebarProgressStats: React.FC = ({
)} - + {distribution?.labels.length > 0 ? ( distribution.labels.map((label, index) => ( = ({
)} - + {Object.keys(groupedIssues).map((group, index) => ( = observer((props ); const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; - const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined; + const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined; const { data: activeCycleIssues } = useSWR( workspaceSlug && projectId && currentProjectActiveCycleId @@ -222,12 +222,13 @@ export const ActiveCycleDetails: React.FC = observer((props {cycleOwnerDetails?.display_name}
- {activeCycle.assignees.length > 0 && ( + {activeCycle.assignee_ids.length > 0 && (
- {activeCycle.assignees.map((assignee) => ( - - ))} + {activeCycle.assignee_ids.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })}
)} diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle-stats.tsx index 1ffe19260da..3ca5caeb204 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle-stats.tsx @@ -69,7 +69,10 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { {cycle && cycle.total_issues > 0 ? ( - + {cycle.distribution?.assignees?.map((assignee, index) => { if (assignee.assignee_id) return ( @@ -104,7 +107,11 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ); })} - + + {cycle.distribution?.labels?.map((label, index) => ( { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["cycle"]} />
diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index e96b0185832..7d6b1e00036 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import Link from "next/link"; import { observer } from "mobx-react"; // hooks -import { useEventTracker, useCycle, useUser } from "hooks/store"; +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; @@ -40,6 +40,7 @@ export const CyclesBoardCard: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); + const { getUserDetails } = useMember(); // toast alert const { setToastAlert } = useToast(); // computed @@ -212,13 +213,14 @@ export const CyclesBoardCard: FC = observer((props) => { {issueCount}
- {cycleDetails.assignees.length > 0 && ( - + {cycleDetails.assignee_ids.length > 0 && ( +
- {cycleDetails.assignees.map((assignee) => ( - - ))} + {cycleDetails.assignee_ids.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })}
diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx index 34e973614c6..1a90692675d 100644 --- a/web/components/cycles/cycles-board.tsx +++ b/web/components/cycles/cycles-board.tsx @@ -39,7 +39,7 @@ export const CyclesBoard: FC = observer((props) => { peekCycle ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" - } auto-rows-max transition-all `} + } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`} > {cycleIds.map((cycleId) => ( diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index ed2b26c537d..31958cd847f 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react"; // hooks -import { useEventTracker, useCycle, useUser } from "hooks/store"; +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; @@ -44,6 +44,7 @@ export const CyclesListItem: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); + const { getUserDetails } = useMember(); // toast alert const { setToastAlert } = useToast(); @@ -230,13 +231,14 @@ export const CyclesListItem: FC = observer((props) => {
- +
- {cycleDetails.assignees.length > 0 ? ( + {cycleDetails.assignee_ids?.length > 0 ? ( - {cycleDetails.assignees.map((assignee) => ( - - ))} + {cycleDetails.assignee_ids?.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })} ) : ( diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx index 838d88a305e..173a7f4b7cd 100644 --- a/web/components/cycles/cycles-list.tsx +++ b/web/components/cycles/cycles-list.tsx @@ -37,7 +37,7 @@ export const CyclesList: FC = observer((props) => { {cycleIds.length > 0 ? (
-
+
{cycleIds.map((cycleId) => ( = (props) => { formState: { errors, isSubmitting, dirtyFields }, handleSubmit, control, - watch, reset, } = useForm({ defaultValues: { - project: projectId, + project_id: projectId, name: data?.name || "", description: data?.description || "", start_date: data?.start_date || null, @@ -51,23 +50,14 @@ export const CycleForm: React.FC = (props) => { }); }, [data, reset]); - const startDate = watch("start_date"); - const endDate = watch("end_date"); - - const minDate = startDate ? new Date(startDate) : new Date(); - minDate.setDate(minDate.getDate() + 1); - - const maxDate = endDate ? new Date(endDate) : null; - maxDate?.setDate(maxDate.getDate() - 1); - return ( - handleFormSubmit(formData,dirtyFields))}> + handleFormSubmit(formData, dirtyFields))}>
{!status && ( ( = (props) => {
-
- ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} + ( + ( + { + onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null); + onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + hideIcon={{ + to: true, + }} tabIndex={3} /> -
- )} - /> -
- ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="End date" - minDate={minDate} - tabIndex={4} - /> -
+ )} + /> )} />
@@ -172,10 +160,10 @@ export const CycleForm: React.FC = (props) => {
- -
diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index beb239d87ec..5d82c94a863 100644 --- a/web/components/cycles/gantt-chart/blocks.tsx +++ b/web/components/cycles/gantt-chart/blocks.tsx @@ -40,7 +40,7 @@ export const CycleGanttBlock: React.FC = observer((props) => { ? "rgb(var(--color-text-200))" : "", }} - onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} >
= observer((props) => { return (
router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} > = observer((props) => { const payload: any = { ...data }; if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; - await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload); + await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload); }; const blockFormat = (blocks: (ICycle | null)[]) => { diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index e8f19d6a18d..b22afb2b44d 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -40,7 +40,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await createCycle(workspaceSlug, selectedProjectId, payload) .then((res) => { setToastAlert({ @@ -69,7 +69,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const handleUpdateCycle = async (cycleId: string, payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) .then((res) => { const changed_properties = Object.keys(dirtyFields); @@ -155,8 +155,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { // if data is present, set active project to the project of the // issue. This has more priority than the project in the url. - if (data && data.project) { - setActiveProject(data.project); + if (data && data.project_id) { + setActiveProject(data.project_id); return; } diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index c825feb379b..646736bd2b6 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { useForm } from "react-hook-form"; -import { Disclosure, Popover, Transition } from "@headlessui/react"; +import { Controller, useForm } from "react-hook-form"; +import { Disclosure, Transition } from "@headlessui/react"; import isEmpty from "lodash/isEmpty"; // services import { CycleService } from "services/cycle.service"; @@ -14,27 +14,12 @@ import { SidebarProgressStats } from "components/core"; import ProgressChart from "components/core/sidebar/progress-chart"; import { CycleDeleteModal } from "components/cycles/delete-modal"; // ui -import { CustomRangeDatePicker } from "components/ui"; import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui"; // icons -import { - ChevronDown, - LinkIcon, - Trash2, - UserCircle2, - AlertCircle, - ChevronRight, - CalendarCheck2, - CalendarClock, -} from "lucide-react"; +import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; -import { - findHowManyDaysLeft, - isDateGreaterThanToday, - renderFormattedPayloadDate, - renderFormattedDate, -} from "helpers/date-time.helper"; +import { findHowManyDaysLeft, renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { ICycle } from "@plane/types"; // constants @@ -42,6 +27,7 @@ import { EUserWorkspaceRoles } from "constants/workspace"; import { CYCLE_UPDATED } from "constants/event-tracker"; // fetch-keys import { CYCLE_STATUS } from "constants/cycle"; +import { DateRangeDropdown } from "components/dropdowns"; type Props = { cycleId: string; @@ -61,9 +47,6 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const { cycleId, handleClose } = props; // states const [cycleDeleteModal, setCycleDeleteModal] = useState(false); - // refs - const startDateButtonRef = useRef(null); - const endDateButtonRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; @@ -74,13 +57,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { } = useUser(); const { getCycleById, updateCycleDetails } = useCycle(); const { getUserDetails } = useMember(); - + // derived values const cycleDetails = getCycleById(cycleId); - const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; - + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; + // toast alert const { setToastAlert } = useToast(); - - const { setValue, reset, watch } = useForm({ + // form info + const { control, reset } = useForm({ defaultValues, }); @@ -145,160 +128,38 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { } }; - const handleStartDateChange = async (date: string) => { - setValue("start_date", date); + const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => { + if (!startDate || !endDate) return; - if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click(); + let isDateValid = false; - if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("end_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create cycle in past date. Please enter a valid date.", - }); - reset({ ...cycleDetails }); - return; - } - - if (cycleDetails?.start_date && cycleDetails?.end_date) { - const isDateValidForExistingCycle = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, - cycle_id: cycleDetails.id, - }); + const payload = { + start_date: renderFormattedPayloadDate(startDate), + end_date: renderFormattedPayloadDate(endDate), + }; - if (isDateValidForExistingCycle) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "start_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - } - - reset({ ...cycleDetails }); - return; - } - - const isDateValid = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, + if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date) + isDateValid = await dateChecker({ + ...payload, + cycle_id: cycleDetails.id, }); - - if (isDateValid) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "start_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - reset({ ...cycleDetails }); - } - } - }; - - const handleEndDateChange = async (date: string) => { - setValue("end_date", date); - - if (!watch("start_date") || watch("start_date") === "") startDateButtonRef.current?.click(); - - if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("end_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create cycle in past date. Please enter a valid date.", - }); - reset({ ...cycleDetails }); - return; - } - - if (cycleDetails?.start_date && cycleDetails?.end_date) { - const isDateValidForExistingCycle = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, - cycle_id: cycleDetails.id, - }); - - if (isDateValidForExistingCycle) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "end_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - } - reset({ ...cycleDetails }); - return; - } - - const isDateValid = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, + else isDateValid = await dateChecker(payload); + + if (isDateValid) { + submitChanges(payload, "date_range"); + setToastAlert({ + type: "success", + title: "Success!", + message: "Cycle updated successfully.", }); - - if (isDateValid) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "end_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - reset({ ...cycleDetails }); - } + } else { + setToastAlert({ + type: "error", + title: "Error!", + message: + "You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.", + }); + reset({ ...cycleDetails }); } }; @@ -351,9 +212,6 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ); - const endDate = new Date(watch("end_date") ?? cycleDetails.end_date ?? ""); - const startDate = new Date(watch("start_date") ?? cycleDetails.start_date ?? ""); - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = @@ -440,125 +298,52 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
-
+
- Start date -
-
- - {({ close }) => ( - <> - - - {renderFormattedDate(startDate) ?? "No date selected"} - - - - - - { - if (val) { - setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON"); - handleStartDateChange(val); - close(); - } - }} - startDate={watch("start_date") ?? watch("end_date") ?? null} - endDate={watch("end_date") ?? watch("start_date") ?? null} - maxDate={new Date(`${watch("end_date")}`)} - selectsStart={watch("end_date") ? true : false} - /> - - - - )} - -
-
- -
-
- - Target date + Date range
-
- - {({ close }) => ( - <> - - - {renderFormattedDate(endDate) ?? "No date selected"} - - - - - - { - if (val) { - setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON"); - handleEndDateChange(val); - close(); - } - }} - startDate={watch("start_date") ?? watch("end_date") ?? null} - endDate={watch("end_date") ?? watch("start_date") ?? null} - minDate={new Date(`${watch("start_date")}`)} - selectsEnd={watch("start_date") ? true : false} - /> - - - +
+ ( + ( + { + onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null); + onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null); + handleDateChange(val?.from, val?.to); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + required={cycleDetails.status !== "draft"} + /> + )} + /> )} - + />
-
+
Lead
-
+
{cycleOwnerDetails?.display_name} @@ -567,11 +352,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
-
+
Issues
-
+
{issueCount}
diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index 5956e4a1e23..adff1954578 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -56,7 +56,7 @@ export const TransferIssuesModal: React.FC = observer((props) => { const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => { const cycleDetails = getCycleById(optionId); - return cycleDetails?.name.toLowerCase().includes(query.toLowerCase()); + return cycleDetails?.name?.toLowerCase().includes(query?.toLowerCase()); }); // useEffect(() => { diff --git a/web/components/cycles/transfer-issues.tsx b/web/components/cycles/transfer-issues.tsx index 5ec23cd70e4..517df442106 100644 --- a/web/components/cycles/transfer-issues.tsx +++ b/web/components/cycles/transfer-issues.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; import useSWR from "swr"; +import isEmpty from "lodash/isEmpty"; // component import { Button, TransferIcon } from "@plane/ui"; // icon @@ -15,12 +16,13 @@ import { CYCLE_DETAILS } from "constants/fetch-keys"; type Props = { handleClick: () => void; + disabled?: boolean; }; const cycleService = new CycleService(); export const TransferIssues: React.FC = (props) => { - const { handleClick } = props; + const { handleClick, disabled = false } = props; const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; @@ -43,9 +45,14 @@ export const TransferIssues: React.FC = (props) => { Completed cycles are not editable.
- {transferableIssuesCount > 0 && ( + {isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
-
diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index ed6bac324d7..7c8fbd2a98e 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -67,6 +67,7 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { const filterParams = getRedirectionFilters(selectedTab); const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); if (!widgetDetails || !widgetStats) return ; @@ -84,30 +85,25 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { onChange={(val) => { if (val === selectedDurationFilter) return; + let newTab = selectedTab; // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ duration: val, tab: "pending" }); - return; - } + if (val === "none" && selectedTab !== "completed") newTab = "pending"; // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { - handleUpdateFilters({ - duration: val, - tab: "upcoming", - }); - return; - } + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; - handleUpdateFilters({ duration: val }); + handleUpdateFilters({ + duration: val, + tab: newTab, + }); }} />
tab.key === selectedTab)} + selectedIndex={selectedTabIndex} onChange={(i) => { - const selectedTab = tabsList[i]; - handleUpdateFilters({ tab: selectedTab?.key ?? "pending" }); + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); }} className="h-full flex flex-col" > @@ -115,18 +111,21 @@ export const AssignedIssuesWidget: React.FC = observer((props) => {
- {tabsList.map((tab) => ( - - - - ))} + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })}
diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx index 4ef5708c867..e7832883bc9 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -64,6 +64,7 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { const filterParams = getRedirectionFilters(selectedTab); const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); if (!widgetDetails || !widgetStats) return ; @@ -81,30 +82,25 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { onChange={(val) => { if (val === selectedDurationFilter) return; + let newTab = selectedTab; // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ duration: val, tab: "pending" }); - return; - } + if (val === "none" && selectedTab !== "completed") newTab = "pending"; // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { - handleUpdateFilters({ - duration: val, - tab: "upcoming", - }); - return; - } + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; - handleUpdateFilters({ duration: val }); + handleUpdateFilters({ + duration: val, + tab: newTab, + }); }} />
tab.key === selectedTab)} + selectedIndex={selectedTabIndex} onChange={(i) => { - const selectedTab = tabsList[i]; - handleUpdateFilters({ tab: selectedTab.key ?? "pending" }); + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); }} className="h-full flex flex-col" > @@ -112,18 +108,21 @@ export const CreatedIssuesWidget: React.FC = observer((props) => {
- {tabsList.map((tab) => ( - - - - ))} + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })}
diff --git a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx index fe003e16710..716a3afc18d 100644 --- a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx +++ b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -179,7 +179,7 @@ export const CreatedUpcomingIssueListItem: React.FC = observ : "-"}
- {issue.assignee_ids.length > 0 ? ( + {issue.assignee_ids && issue.assignee_ids?.length > 0 ? ( {issue.assignee_ids?.map((assigneeId) => { const userDetails = getUserDetails(assigneeId); diff --git a/web/components/dashboard/widgets/issue-panels/issues-list.tsx b/web/components/dashboard/widgets/issue-panels/issues-list.tsx index cf3f322329e..16b2b95d9ee 100644 --- a/web/components/dashboard/widgets/issue-panels/issues-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/issues-list.tsx @@ -14,24 +14,23 @@ import { IssueListItemProps, } from "components/dashboard/widgets"; // ui -import { getButtonStyling } from "@plane/ui"; +import { Loader, getButtonStyling } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; import { getRedirectionFilters } from "helpers/dashboard.helper"; // types -import { TIssue, TIssuesListTypes } from "@plane/types"; +import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types"; export type WidgetIssuesListProps = { isLoading: boolean; - issues: TIssue[]; tab: TIssuesListTypes; - totalIssues: number; type: "assigned" | "created"; + widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse; workspaceSlug: string; }; export const WidgetIssuesList: React.FC = (props) => { - const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; + const { isLoading, tab, type, widgetStats, workspaceSlug } = props; // store hooks const { setPeekIssue } = useIssueDetail(); @@ -59,12 +58,19 @@ export const WidgetIssuesList: React.FC = (props) => { }, }; + const issuesList = widgetStats.issues; + return ( <>
{isLoading ? ( - <> - ) : issues.length > 0 ? ( + + + + + + + ) : issuesList.length > 0 ? ( <>
= (props) => { > Issues - {totalIssues} + {widgetStats.count}
{["upcoming", "pending"].includes(tab) &&
Due date
} @@ -84,7 +90,7 @@ export const WidgetIssuesList: React.FC = (props) => { {type === "created" &&
Assigned to
}
- {issues.map((issue) => { + {issuesList.map((issue) => { const IssueListItem = ISSUE_LIST_ITEM[type][tab]; if (!IssueListItem) return null; @@ -107,7 +113,7 @@ export const WidgetIssuesList: React.FC = (props) => {
)}
- {issues.length > 0 && ( + {!isLoading && issuesList.length > 0 && ( = observer((props) => { key: "overdue", title: "Issues overdue", count: widgetStats?.pending_issues_count, - link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`, + link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`, }, { key: "created", diff --git a/web/components/dashboard/widgets/recent-projects.tsx b/web/components/dashboard/widgets/recent-projects.tsx index eb7a5e4e552..79be923334b 100644 --- a/web/components/dashboard/widgets/recent-projects.tsx +++ b/web/components/dashboard/widgets/recent-projects.tsx @@ -96,7 +96,7 @@ export const RecentProjectsWidget: React.FC = observer((props) => { href={`/${workspaceSlug}/projects`} className="text-lg font-semibold text-custom-text-300 mx-7 hover:underline" > - Your projects + Recent projects
{canCreateProject && ( diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx deleted file mode 100644 index 5086d2d26b7..00000000000 --- a/web/components/dropdowns/cycle.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; -// hooks -import { useApplication, useCycle } from "hooks/store"; -import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { DropdownButton } from "./buttons"; -// icons -import { ContrastIcon } from "@plane/ui"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { TDropdownProps } from "./types"; -// constants -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; - -type Props = TDropdownProps & { - button?: ReactNode; - dropdownArrow?: boolean; - dropdownArrowClassName?: string; - onChange: (val: string | null) => void; - onClose?: () => void; - projectId: string; - value: string | null; -}; - -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - -export const CycleDropdown: React.FC = observer((props) => { - const { - button, - buttonClassName, - buttonContainerClassName, - buttonVariant, - className = "", - disabled = false, - dropdownArrow = false, - dropdownArrowClassName = "", - hideIcon = false, - onChange, - onClose, - placeholder = "Cycle", - placement, - projectId, - showTooltip = false, - tabIndex, - value, - } = props; - // states - const [query, setQuery] = useState(""); - const [isOpen, setIsOpen] = useState(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { - router: { workspaceSlug }, - } = useApplication(); - const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); - const cycleIds = getProjectCycleIds(projectId); - - const options: DropdownOptions = cycleIds?.map((cycleId) => { - const cycleDetails = getCycleById(cycleId); - - return { - value: cycleId, - query: `${cycleDetails?.name}`, - content: ( -
- - {cycleDetails?.name} -
- ), - }; - }); - options?.unshift({ - value: null, - query: "No cycle", - content: ( -
- - No cycle -
- ), - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const selectedCycle = value ? getCycleById(value) : null; - - const onOpen = () => { - if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId); - }; - - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const dropdownOnChange = (val: string | null) => { - onChange(val); - handleClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matches found

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/cycle/cycle-options.tsx b/web/components/dropdowns/cycle/cycle-options.tsx new file mode 100644 index 00000000000..e691569b7a9 --- /dev/null +++ b/web/components/dropdowns/cycle/cycle-options.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import { observer } from "mobx-react"; +//components +import { ContrastIcon, CycleGroupIcon } from "@plane/ui"; +//store +import { useApplication, useCycle } from "hooks/store"; +//hooks +import { usePopper } from "react-popper"; +//icon +import { Check, Search } from "lucide-react"; +//types +import { Placement } from "@popperjs/core"; +import { TCycleGroups } from "@plane/types"; + +type DropdownOptions = + | { + value: string | null; + query: string; + content: JSX.Element; + }[] + | undefined; + +interface Props { + projectId: string; + referenceElement: HTMLButtonElement | null; + placement: Placement | undefined; + isOpen: boolean; +} + +export const CycleOptions = observer((props: any) => { + const { projectId, isOpen, referenceElement, placement } = props; + + //state hooks + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + const inputRef = useRef(null); + + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); + + useEffect(() => { + if (isOpen) { + onOpen(); + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => { + const cycleDetails = getCycleById(cycleId); + return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true; + }); + + const onOpen = () => { + if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId); + }; + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + const options: DropdownOptions = cycleIds?.map((cycleId) => { + const cycleDetails = getCycleById(cycleId); + const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + + return { + value: cycleId, + query: `${cycleDetails?.name}`, + content: ( +
+ + {cycleDetails?.name} +
+ ), + }; + }); + options?.unshift({ + value: null, + query: "No cycle", + content: ( +
+ + No cycle +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matches found

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/cycle/index.tsx b/web/components/dropdowns/cycle/index.tsx new file mode 100644 index 00000000000..2c05d9ddf06 --- /dev/null +++ b/web/components/dropdowns/cycle/index.tsx @@ -0,0 +1,150 @@ +import { Fragment, ReactNode, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Combobox } from "@headlessui/react"; +import { ChevronDown } from "lucide-react"; +// hooks +import { useCycle } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { DropdownButton } from "../buttons"; +// icons +import { ContrastIcon } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TDropdownProps } from "../types"; +// constants +import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; +import { CycleOptions } from "./cycle-options"; + +type Props = TDropdownProps & { + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + onChange: (val: string | null) => void; + onClose?: () => void; + projectId: string; + value: string | null; +}; + +export const CycleDropdown: React.FC = observer((props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + onChange, + onClose, + placeholder = "Cycle", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + + const [isOpen, setIsOpen] = useState(false); + const { getCycleNameById } = useCycle(); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + const selectedName = value ? getCycleNameById(value) : null; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose && onClose(); + }; + + const toggleDropdown = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const dropdownOnChange = (val: string | null) => { + onChange(val); + handleClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + + )} + + ); +}); diff --git a/web/components/dropdowns/date-range.tsx b/web/components/dropdowns/date-range.tsx new file mode 100644 index 00000000000..d3ef691b9b1 --- /dev/null +++ b/web/components/dropdowns/date-range.tsx @@ -0,0 +1,261 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Placement } from "@popperjs/core"; +import { DateRange, DayPicker, Matcher } from "react-day-picker"; +import { ArrowRight, CalendarDays } from "lucide-react"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +// components +import { DropdownButton } from "./buttons"; +// ui +import { Button } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; +// types +import { TButtonVariants } from "./types"; + +type Props = { + applyButtonText?: string; + bothRequired?: boolean; + buttonClassName?: string; + buttonContainerClassName?: string; + buttonFromDateClassName?: string; + buttonToDateClassName?: string; + buttonVariant: TButtonVariants; + cancelButtonText?: string; + className?: string; + disabled?: boolean; + hideIcon?: { + from?: boolean; + to?: boolean; + }; + icon?: React.ReactNode; + minDate?: Date; + maxDate?: Date; + onSelect: (range: DateRange | undefined) => void; + placeholder?: { + from?: string; + to?: string; + }; + placement?: Placement; + required?: boolean; + showTooltip?: boolean; + tabIndex?: number; + value: { + from: Date | undefined; + to: Date | undefined; + }; +}; + +export const DateRangeDropdown: React.FC = (props) => { + const { + applyButtonText = "Apply changes", + bothRequired = true, + buttonClassName, + buttonContainerClassName, + buttonFromDateClassName, + buttonToDateClassName, + buttonVariant, + cancelButtonText = "Cancel", + className, + disabled = false, + hideIcon = { + from: true, + to: true, + }, + icon = , + minDate, + maxDate, + onSelect, + placeholder = { + from: "Add date", + to: "Add date", + }, + placement, + required = false, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [dateRange, setDateRange] = useState(value); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const onOpen = () => { + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + setDateRange({ + from: value.from, + to: value.to, + }); + if (referenceElement) referenceElement.blur(); + }; + + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + const disabledDays: Matcher[] = []; + if (minDate) disabledDays.push({ before: minDate }); + if (maxDate) disabledDays.push({ after: maxDate }); + + useEffect(() => { + setDateRange(value); + }, [value]); + + return ( + { + if (e.key === "Enter") { + if (!isOpen) handleKeyDown(e); + } else handleKeyDown(e); + }} + > + + + + {isOpen && ( + +
+ { + // if both the dates are not required, immediately call onSelect + if (!bothRequired) onSelect(val); + setDateRange({ + from: val?.from ?? undefined, + to: val?.to ?? undefined, + }); + }} + mode="range" + disabled={disabledDays} + showOutsideDays + initialFocus + footer={ + bothRequired && ( +
+
+ + +
+ ) + } + /> +
+ + )} + + ); +}; diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index 2603b3eb2bc..570ea45da74 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState } from "react"; import { Combobox } from "@headlessui/react"; -import DatePicker from "react-datepicker"; +import { DayPicker, Matcher } from "react-day-picker"; import { usePopper } from "react-popper"; import { CalendarDays, X } from "lucide-react"; // hooks @@ -50,6 +50,7 @@ export const DateDropdown: React.FC = (props) => { tabIndex, value, } = props; + // states const [isOpen, setIsOpen] = useState(false); // refs const dropdownRef = useRef(null); @@ -85,6 +86,7 @@ export const DateDropdown: React.FC = (props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: Date | null) => { @@ -102,18 +104,25 @@ export const DateDropdown: React.FC = (props) => { useOutsideClickDetector(dropdownRef, handleClose); + const disabledDays: Matcher[] = []; + if (minDate) disabledDays.push({ before: minDate }); + if (maxDate) disabledDays.push({ after: maxDate }); + return ( { + if (e.key === "Enter") { + if (!isOpen) handleKeyDown(e); + } else handleKeyDown(e); + }} disabled={disabled} >
diff --git a/web/components/dropdowns/index.ts b/web/components/dropdowns/index.ts index 036ed9f757f..64b7efe808d 100644 --- a/web/components/dropdowns/index.ts +++ b/web/components/dropdowns/index.ts @@ -1,5 +1,6 @@ export * from "./member"; export * from "./cycle"; +export * from "./date-range"; export * from "./date"; export * from "./estimate"; export * from "./module"; diff --git a/web/components/dropdowns/member/index.ts b/web/components/dropdowns/member/index.ts deleted file mode 100644 index a9f7e09c8cd..00000000000 --- a/web/components/dropdowns/member/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./project-member"; -export * from "./workspace-member"; diff --git a/web/components/dropdowns/member/index.tsx b/web/components/dropdowns/member/index.tsx new file mode 100644 index 00000000000..0513ec62713 --- /dev/null +++ b/web/components/dropdowns/member/index.tsx @@ -0,0 +1,157 @@ +import { Fragment, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Combobox } from "@headlessui/react"; +import { ChevronDown } from "lucide-react"; +// hooks +import { useMember } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { ButtonAvatars } from "./avatar"; +import { DropdownButton } from "../buttons"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { MemberDropdownProps } from "./types"; +// constants +import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; +import { MemberOptions } from "./member-options"; + +type Props = { + projectId?: string; + onClose?: () => void; +} & MemberDropdownProps; + +export const MemberDropdown: React.FC = observer((props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + multiple, + onChange, + onClose, + placeholder = "Members", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + const { getUserDetails } = useMember(); + + const comboboxProps: any = { + value, + onChange, + disabled, + }; + if (multiple) comboboxProps.multiple = true; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose && onClose(); + }; + + const toggleDropdown = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const dropdownOnChange = (val: string & string[]) => { + onChange(val); + if (!multiple) handleClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + + )} + + ); +}); diff --git a/web/components/dropdowns/member/member-options.tsx b/web/components/dropdowns/member/member-options.tsx new file mode 100644 index 00000000000..46a0b9cbad4 --- /dev/null +++ b/web/components/dropdowns/member/member-options.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import { observer } from "mobx-react"; +//components +import { Avatar } from "@plane/ui"; +//store +import { useApplication, useMember, useUser } from "hooks/store"; +//hooks +import { usePopper } from "react-popper"; +//icon +import { Check, Search } from "lucide-react"; +//types +import { Placement } from "@popperjs/core"; + +interface Props { + projectId?: string; + referenceElement: HTMLButtonElement | null; + placement: Placement | undefined; + isOpen: boolean; +} + +export const MemberOptions = observer((props: Props) => { + const { projectId, referenceElement, placement, isOpen } = props; + + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + const inputRef = useRef(null); + + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { + getUserDetails, + project: { getProjectMemberIds, fetchProjectMembers }, + workspace: { workspaceMemberIds }, + } = useMember(); + const { currentUser } = useUser(); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + useEffect(() => { + if (isOpen) { + onOpen(); + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + + const memberIds = projectId ? getProjectMemberIds(projectId) : workspaceMemberIds; + const onOpen = () => { + if (!memberIds && workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId); + }; + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + const options = memberIds?.map((userId) => { + const userDetails = getUserDetails(userId); + + return { + value: userId, + query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, + content: ( +
+ + {currentUser?.id === userId ? "You" : userDetails?.display_name} +
+ ), + }; + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/member/project-member.tsx b/web/components/dropdowns/member/project-member.tsx deleted file mode 100644 index d1f285aa555..00000000000 --- a/web/components/dropdowns/member/project-member.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { Fragment, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; -// hooks -import { useApplication, useMember, useUser } from "hooks/store"; -import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { ButtonAvatars } from "./avatar"; -import { DropdownButton } from "../buttons"; -// icons -import { Avatar } from "@plane/ui"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { MemberDropdownProps } from "./types"; -// constants -import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; - -type Props = { - projectId: string; - onClose?: () => void; -} & MemberDropdownProps; - -export const ProjectMemberDropdown: React.FC = observer((props) => { - const { - button, - buttonClassName, - buttonContainerClassName, - buttonVariant, - className = "", - disabled = false, - dropdownArrow = false, - dropdownArrowClassName = "", - hideIcon = false, - multiple, - onChange, - onClose, - placeholder = "Members", - placement, - projectId, - showTooltip = false, - tabIndex, - value, - } = props; - // states - const [query, setQuery] = useState(""); - const [isOpen, setIsOpen] = useState(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { - router: { workspaceSlug }, - } = useApplication(); - const { currentUser } = useUser(); - const { - getUserDetails, - project: { getProjectMemberIds, fetchProjectMembers }, - } = useMember(); - const projectMemberIds = getProjectMemberIds(projectId); - - const options = projectMemberIds?.map((userId) => { - const userDetails = getUserDetails(userId); - - return { - value: userId, - query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, - content: ( -
- - {currentUser?.id === userId ? "You" : userDetails?.display_name} -
- ), - }; - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const comboboxProps: any = { - value, - onChange, - disabled, - }; - if (multiple) comboboxProps.multiple = true; - - const onOpen = () => { - if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId); - }; - - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const dropdownOnChange = (val: string & string[]) => { - onChange(val); - if (!multiple) handleClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/member/workspace-member.tsx b/web/components/dropdowns/member/workspace-member.tsx deleted file mode 100644 index 7a2628ccaff..00000000000 --- a/web/components/dropdowns/member/workspace-member.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { Fragment, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; -// hooks -import { useMember, useUser } from "hooks/store"; -import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { ButtonAvatars } from "./avatar"; -import { DropdownButton } from "../buttons"; -// icons -import { Avatar } from "@plane/ui"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { MemberDropdownProps } from "./types"; -// constants -import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; - -export const WorkspaceMemberDropdown: React.FC = observer((props) => { - const { - button, - buttonClassName, - buttonContainerClassName, - buttonVariant, - className = "", - disabled = false, - dropdownArrow = false, - dropdownArrowClassName = "", - hideIcon = false, - multiple, - onChange, - onClose, - placeholder = "Members", - placement, - showTooltip = false, - tabIndex, - value, - } = props; - // states - const [query, setQuery] = useState(""); - const [isOpen, setIsOpen] = useState(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { currentUser } = useUser(); - const { - getUserDetails, - workspace: { workspaceMemberIds }, - } = useMember(); - - const options = workspaceMemberIds?.map((userId) => { - const userDetails = getUserDetails(userId); - - return { - value: userId, - query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, - content: ( -
- - {currentUser?.id === userId ? "You" : userDetails?.display_name} -
- ), - }; - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const comboboxProps: any = { - value, - onChange, - disabled, - }; - if (multiple) comboboxProps.multiple = true; - - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const dropdownOnChange = (val: string & string[]) => { - onChange(val); - if (!multiple) handleClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/module.tsx b/web/components/dropdowns/module/index.tsx similarity index 57% rename from web/components/dropdowns/module.tsx rename to web/components/dropdowns/module/index.tsx index c05eeb97e65..5e0a3977f55 100644 --- a/web/components/dropdowns/module.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -1,22 +1,22 @@ import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search, X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; // hooks -import { useApplication, useModule } from "hooks/store"; +import { useModule } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { DropdownButton } from "./buttons"; +import { DropdownButton } from "../buttons"; // icons import { DiceIcon, Tooltip } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; // types -import { TDropdownProps } from "./types"; +import { TDropdownProps } from "../types"; // constants -import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; +import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants"; +import { ModuleOptions } from "./module-options"; type Props = TDropdownProps & { button?: ReactNode; @@ -38,14 +38,6 @@ type Props = TDropdownProps & { } ); -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - type ButtonContentProps = { disabled: boolean; dropdownArrow: boolean; @@ -77,20 +69,24 @@ const ButtonContent: React.FC = (props) => { return ( <> {showCount ? ( - <> +
{!hideIcon && } - - {value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder} - - +
+ {value.length > 0 + ? value.length === 1 + ? `${getModuleById(value[0])?.name || "module"}` + : `${value.length} Module${value.length === 1 ? "" : "s"}` + : placeholder} +
+
) : value.length > 0 ? ( -
+
{value.map((moduleId) => { const moduleDetails = getModuleById(moduleId); return (
{!hideIcon && } {!hideText && ( @@ -162,64 +158,14 @@ export const ModuleDropdown: React.FC = observer((props) => { value, } = props; // states - const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); // refs const dropdownRef = useRef(null); const inputRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { - router: { workspaceSlug }, - } = useApplication(); - const { getProjectModuleIds, fetchModules, getModuleById } = useModule(); - const moduleIds = getProjectModuleIds(projectId); - - const options: DropdownOptions = moduleIds?.map((moduleId) => { - const moduleDetails = getModuleById(moduleId); - return { - value: moduleId, - query: `${moduleDetails?.name}`, - content: ( -
- - {moduleDetails?.name} -
- ), - }; - }); - if (!multiple) - options?.unshift({ - value: null, - query: "No module", - content: ( -
- - No module -
- ), - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - const onOpen = () => { - if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId); - }; + const { getModuleNameById } = useModule(); const handleClose = () => { if (!isOpen) return; @@ -228,8 +174,8 @@ export const ModuleDropdown: React.FC = observer((props) => { }; const toggleDropdown = () => { - if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: string & string[]) => { @@ -274,7 +220,10 @@ export const ModuleDropdown: React.FC = observer((props) => {
diff --git a/web/components/dropdowns/state.tsx b/web/components/dropdowns/state.tsx index fa068fdd01b..9fa2f38c84e 100644 --- a/web/components/dropdowns/state.tsx +++ b/web/components/dropdowns/state.tsx @@ -104,6 +104,7 @@ export const StateDropdown: React.FC = observer((props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: string) => { @@ -119,6 +120,13 @@ export const StateDropdown: React.FC = observer((props) => { toggleDropdown(); }; + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + useOutsideClickDetector(dropdownRef, handleClose); useEffect(() => { @@ -205,6 +213,7 @@ export const StateDropdown: React.FC = observer((props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index 0c72b986a11..57d5d889672 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -53,7 +53,7 @@ const EmojiIconPicker: React.FC = (props) => { setIsOpen((prev) => !prev)} - className="outline-none" + className="outline-none flex items-center justify-center" disabled={disabled} > {label} diff --git a/web/components/gantt-chart/blocks/block.tsx b/web/components/gantt-chart/blocks/block.tsx new file mode 100644 index 00000000000..1e0882aeec2 --- /dev/null +++ b/web/components/gantt-chart/blocks/block.tsx @@ -0,0 +1,106 @@ +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "../hooks"; +import { useIssueDetail } from "hooks/store"; +// components +import { ChartAddBlock, ChartDraggable } from "../helpers"; +// helpers +import { cn } from "helpers/common.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import { IBlockUpdateData, IGanttBlock } from "../types"; +// constants +import { BLOCK_HEIGHT } from "../constants"; + +type Props = { + block: IGanttBlock; + blockToRender: (data: any) => React.ReactNode; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + enableBlockLeftResize: boolean; + enableBlockRightResize: boolean; + enableBlockMove: boolean; + enableAddBlock: boolean; + ganttContainerRef: React.RefObject; +}; + +export const GanttChartBlock: React.FC = observer((props) => { + const { + block, + blockToRender, + blockUpdateHandler, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + enableAddBlock, + ganttContainerRef, + } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { peekIssue } = useIssueDetail(); + + const isBlockVisibleOnChart = block.start_date && block.target_date; + + const handleChartBlockPosition = ( + block: IGanttBlock, + totalBlockShifts: number, + dragDirection: "left" | "right" | "move" + ) => { + if (!block.start_date || !block.target_date) return; + + const originalStartDate = new Date(block.start_date); + const updatedStartDate = new Date(originalStartDate); + + const originalTargetDate = new Date(block.target_date); + const updatedTargetDate = new Date(originalTargetDate); + + // update the start date on left resize + if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); + // update the target date on right resize + 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); + updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); + } + + // call the block update handler with the updated dates + blockUpdateHandler(block.data, { + start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, + target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, + }); + }; + + return ( +
+
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + > + {isBlockVisibleOnChart ? ( + handleChartBlockPosition(block, ...args)} + enableBlockLeftResize={enableBlockLeftResize} + enableBlockRightResize={enableBlockRightResize} + enableBlockMove={enableBlockMove} + ganttContainerRef={ganttContainerRef} + /> + ) : ( + enableAddBlock && + )} +
+
+ ); +}); diff --git a/web/components/gantt-chart/blocks/blocks-list.tsx b/web/components/gantt-chart/blocks/blocks-list.tsx index 15a3e52950c..d98524ecc40 100644 --- a/web/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/components/gantt-chart/blocks/blocks-list.tsx @@ -1,16 +1,10 @@ -import { observer } from "mobx-react"; import { FC } from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; -import { useChart } from "../hooks"; -// helpers -import { ChartAddBlock, ChartDraggable } from "components/gantt-chart"; -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; +// components +import { GanttChartBlock } from "./block"; // types import { IBlockUpdateData, IGanttBlock } from "../types"; // constants -import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants"; +import { HEADER_HEIGHT } from "../constants"; export type GanttChartBlocksProps = { itemsContainerWidth: number; @@ -21,10 +15,11 @@ export type GanttChartBlocksProps = { enableBlockRightResize: boolean; enableBlockMove: boolean; enableAddBlock: boolean; + ganttContainerRef: React.RefObject; showAllBlocks: boolean; }; -export const GanttChartBlocksList: FC = observer((props) => { +export const GanttChartBlocksList: FC = (props) => { const { itemsContainerWidth, blocks, @@ -34,52 +29,9 @@ export const GanttChartBlocksList: FC = observer((props) enableBlockRightResize, enableBlockMove, enableAddBlock, + ganttContainerRef, showAllBlocks, } = props; - // store hooks - const { peekIssue } = useIssueDetail(); - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleChartBlockPosition = ( - block: IGanttBlock, - totalBlockShifts: number, - dragDirection: "left" | "right" | "move" - ) => { - if (!block.start_date || !block.target_date) return; - - const originalStartDate = new Date(block.start_date); - const updatedStartDate = new Date(originalStartDate); - - const originalTargetDate = new Date(block.target_date); - const updatedTargetDate = new Date(originalTargetDate); - - // update the start date on left resize - if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); - // update the target date on right resize - 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); - updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - } - - // call the block update handler with the updated dates - blockUpdateHandler(block.data, { - start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, - target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, - }); - }; return (
= observer((props) // hide the block if it doesn't have start and target dates and showAllBlocks is false if (!showAllBlocks && !(block.start_date && block.target_date)) return; - const isBlockVisibleOnChart = block.start_date && block.target_date; - return ( -
-
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - > - {isBlockVisibleOnChart ? ( - handleChartBlockPosition(block, ...args)} - enableBlockLeftResize={enableBlockLeftResize} - enableBlockRightResize={enableBlockRightResize} - enableBlockMove={enableBlockMove} - /> - ) : ( - enableAddBlock && - )} -
-
+ ); })}
); -}); +}; diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index 6dcfdc36f1a..2ebd0360d17 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -1,10 +1,13 @@ import { Expand, Shrink } from "lucide-react"; // hooks -import { useChart } from "../hooks"; // helpers import { cn } from "helpers/common.helper"; // types import { IGanttBlock, TGanttViews } from "../types"; +// constants +import { VIEWS_LIST } from "components/gantt-chart/data"; +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { observer } from "mobx-react"; type Props = { blocks: IGanttBlock[] | null; @@ -16,10 +19,10 @@ type Props = { toggleFullScreenMode: () => void; }; -export const GanttChartHeader: React.FC = (props) => { +export const GanttChartHeader: React.FC = observer((props) => { const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props; // chart hook - const { currentView, allViews } = useChart(); + const { currentView } = useGanttChart(); return (
@@ -29,7 +32,7 @@ export const GanttChartHeader: React.FC = (props) => {
- {allViews?.map((chartView: any) => ( + {VIEWS_LIST.map((chartView: any) => (
= (props) => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index 7a35adbb6c9..8a1c5de266d 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -1,3 +1,7 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "../hooks/use-gantt-chart"; // components import { BiWeekChartView, @@ -12,7 +16,6 @@ import { TGanttViews, WeekChartView, YearChartView, - useChart, } from "components/gantt-chart"; // helpers import { cn } from "helpers/common.helper"; @@ -36,7 +39,7 @@ type Props = { quickAdd?: React.JSX.Element | undefined; }; -export const GanttChartMainContent: React.FC = (props) => { +export const GanttChartMainContent: React.FC = observer((props) => { const { blocks, blockToRender, @@ -55,13 +58,15 @@ export const GanttChartMainContent: React.FC = (props) => { updateCurrentViewRenderPayload, quickAdd, } = props; + // refs + const ganttContainerRef = useRef(null); // chart hook - const { currentView, currentViewData, updateScrollLeft } = useChart(); + const { currentView, currentViewData } = useGanttChart(); // handling scroll functionality const onScroll = (e: React.UIEvent) => { const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget; - updateScrollLeft(scrollLeft); + // updateScrollLeft(scrollLeft); const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth; const approxRangeRight = scrollWidth - (scrollLeft + clientWidth); @@ -90,11 +95,12 @@ export const GanttChartMainContent: React.FC = (props) => { // DO NOT REMOVE THE ID id="gantt-container" className={cn( - "h-full w-full overflow-auto horizontal-scroll-enable flex border-t-[0.5px] border-custom-border-200", + "h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200", { "mb-8": bottomSpacing, } )} + ref={ganttContainerRef} onScroll={onScroll} > = (props) => { enableBlockRightResize={enableBlockRightResize} enableBlockMove={enableBlockMove} enableAddBlock={enableAddBlock} + ganttContainerRef={ganttContainerRef} showAllBlocks={showAllBlocks} /> )}
); -}; +}); diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index 877c1590104..be6229ce382 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -1,6 +1,9 @@ import { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "../hooks/use-gantt-chart"; // components -import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart"; +import { GanttChartHeader, GanttChartMainContent } from "components/gantt-chart"; // views import { generateMonthChart, @@ -34,7 +37,7 @@ type ChartViewRootProps = { quickAdd?: React.JSX.Element | undefined; }; -export const ChartViewRoot: FC = (props) => { +export const ChartViewRoot: FC = observer((props) => { const { border, title, @@ -57,7 +60,8 @@ export const ChartViewRoot: FC = (props) => { const [fullScreenMode, setFullScreenMode] = useState(false); const [chartBlocks, setChartBlocks] = useState(null); // hooks - const { currentView, currentViewData, renderView, dispatch } = useChart(); + const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } = + useGanttChart(); // rendering the block structure const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => @@ -87,36 +91,20 @@ export const ChartViewRoot: FC = (props) => { // updating the prevData, currentData and nextData if (currentRender.payload.length > 0) { + updateCurrentViewData(currentRender.state); + if (side === "left") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: selectedCurrentView, - currentViewData: currentRender.state, - renderView: [...currentRender.payload, ...renderView], - }, - }); + updateCurrentView(selectedCurrentView); + updateRenderView([...currentRender.payload, ...renderView]); updatingCurrentLeftScrollPosition(currentRender.scrollWidth); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else if (side === "right") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...renderView, ...currentRender.payload], - }, - }); + updateCurrentView(view); + updateRenderView([...renderView, ...currentRender.payload]); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...currentRender.payload], - }, - }); + updateCurrentView(view); + updateRenderView(currentRender.payload); setItemsContainerWidth(currentRender.scrollWidth); setTimeout(() => { handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate); @@ -206,4 +194,4 @@ export const ChartViewRoot: FC = (props) => { />
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/bi-week.tsx b/web/components/gantt-chart/chart/views/bi-week.tsx index 6e53d5390ca..f0ad084e9aa 100644 --- a/web/components/gantt-chart/chart/views/bi-week.tsx +++ b/web/components/gantt-chart/chart/views/bi-week.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "components/gantt-chart"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const BiWeekChartView: FC = () => { +export const BiWeekChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const BiWeekChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/day.tsx b/web/components/gantt-chart/chart/views/day.tsx index a50b7748ad7..84b2edac41c 100644 --- a/web/components/gantt-chart/chart/views/day.tsx +++ b/web/components/gantt-chart/chart/views/day.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const DayChartView: FC = () => { +export const DayChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const DayChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/hours.tsx b/web/components/gantt-chart/chart/views/hours.tsx index e1fd02e3f94..bd1a7b6dd28 100644 --- a/web/components/gantt-chart/chart/views/hours.tsx +++ b/web/components/gantt-chart/chart/views/hours.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "components/gantt-chart"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const HourChartView: FC = () => { +export const HourChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const HourChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx index c559e968854..b67b453f1e1 100644 --- a/web/components/gantt-chart/chart/views/month.tsx +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -1,6 +1,7 @@ import { FC } from "react"; +import { observer } from "mobx-react"; // hooks -import { useChart } from "components/gantt-chart"; +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; // helpers import { cn } from "helpers/common.helper"; // types @@ -8,15 +9,15 @@ import { IMonthBlock } from "../../views"; // constants import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants"; -export const MonthChartView: FC = () => { +export const MonthChartView: FC = observer(() => { // chart hook - const { currentViewData, renderView } = useChart(); + const { currentViewData, renderView } = useGanttChart(); const monthBlocks: IMonthBlock[] = renderView; return ( -
+
{monthBlocks?.map((block, rootIndex) => ( -
+
= () => { ))}
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/quarter.tsx b/web/components/gantt-chart/chart/views/quarter.tsx index ffbc1cbfe85..b8adc4b3a50 100644 --- a/web/components/gantt-chart/chart/views/quarter.tsx +++ b/web/components/gantt-chart/chart/views/quarter.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const QuarterChartView: FC = () => { +export const QuarterChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -46,4 +47,4 @@ export const QuarterChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/week.tsx b/web/components/gantt-chart/chart/views/week.tsx index 8170affa466..981fc9236f0 100644 --- a/web/components/gantt-chart/chart/views/week.tsx +++ b/web/components/gantt-chart/chart/views/week.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const WeekChartView: FC = () => { +export const WeekChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const WeekChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/year.tsx b/web/components/gantt-chart/chart/views/year.tsx index 9dbeedeceff..659126ac33a 100644 --- a/web/components/gantt-chart/chart/views/year.tsx +++ b/web/components/gantt-chart/chart/views/year.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const YearChartView: FC = () => { +export const YearChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -46,4 +47,4 @@ export const YearChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/contexts/index.tsx b/web/components/gantt-chart/contexts/index.tsx index 84e7a19b5e8..1d8a19f1a60 100644 --- a/web/components/gantt-chart/contexts/index.tsx +++ b/web/components/gantt-chart/contexts/index.tsx @@ -1,57 +1,19 @@ -import React, { createContext, useState } from "react"; -// types -import { ChartContextData, ChartContextActionPayload, ChartContextReducer } from "../types"; -// data -import { allViewsWithData, currentViewDataWithView } from "../data"; +import { createContext } from "react"; +// mobx store +import { GanttStore } from "store/issue/issue_gantt_view.store"; -export const ChartContext = createContext(undefined); +let ganttViewStore = new GanttStore(); -const chartReducer = (state: ChartContextData, action: ChartContextActionPayload): ChartContextData => { - switch (action.type) { - case "CURRENT_VIEW": - return { ...state, currentView: action.payload }; - case "CURRENT_VIEW_DATA": - return { ...state, currentViewData: action.payload }; - case "RENDER_VIEW": - return { ...state, currentViewData: action.payload }; - case "PARTIAL_UPDATE": - return { ...state, ...action.payload }; - default: - return state; - } -}; - -const initialView = "month"; - -export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // states; - const [state, dispatch] = useState({ - currentView: initialView, - currentViewData: currentViewDataWithView(initialView), - renderView: [], - allViews: allViewsWithData, - activeBlock: null, - }); - const [scrollLeft, setScrollLeft] = useState(0); +export const GanttStoreContext = createContext(ganttViewStore); - const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { - const newState = chartReducer(state, action); - dispatch(() => newState); - return newState; - }; - - const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft); +const initializeStore = () => { + const _ganttStore = ganttViewStore ?? new GanttStore(); + if (typeof window === "undefined") return _ganttStore; + if (!ganttViewStore) ganttViewStore = _ganttStore; + return _ganttStore; +}; - return ( - - {children} - - ); +export const GanttStoreProvider = ({ children }: any) => { + const store = initializeStore(); + return {children}; }; diff --git a/web/components/gantt-chart/data/index.ts b/web/components/gantt-chart/data/index.ts index 58ac6e4b2a5..cc15c5d9ec9 100644 --- a/web/components/gantt-chart/data/index.ts +++ b/web/components/gantt-chart/data/index.ts @@ -1,5 +1,5 @@ // types -import { WeekMonthDataType, ChartDataType } from "../types"; +import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types"; // constants export const weeks: WeekMonthDataType[] = [ @@ -53,7 +53,7 @@ export const datePreview = (date: Date, includeTime: boolean = false) => { }; // context data -export const allViewsWithData: ChartDataType[] = [ +export const VIEWS_LIST: ChartDataType[] = [ // { // key: "hours", // title: "Hours", @@ -133,7 +133,5 @@ export const allViewsWithData: ChartDataType[] = [ // }, ]; -export const currentViewDataWithView = (view: string = "month") => { - const currentView: ChartDataType | undefined = allViewsWithData.find((_viewData) => _viewData.key === view); - return currentView; -}; +export const currentViewDataWithView = (view: TGanttViews = "month") => + VIEWS_LIST.find((_viewData) => _viewData.key === view); diff --git a/web/components/gantt-chart/helpers/add-block.tsx b/web/components/gantt-chart/helpers/add-block.tsx index bfeddffa243..b7497013fb5 100644 --- a/web/components/gantt-chart/helpers/add-block.tsx +++ b/web/components/gantt-chart/helpers/add-block.tsx @@ -1,21 +1,21 @@ import { useEffect, useRef, useState } from "react"; import { addDays } from "date-fns"; import { Plus } from "lucide-react"; -// hooks -import { useChart } from "../hooks"; // ui import { Tooltip } from "@plane/ui"; // helpers import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { IBlockUpdateData, IGanttBlock } from "../types"; +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { observer } from "mobx-react"; type Props = { block: IGanttBlock; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; }; -export const ChartAddBlock: React.FC = (props) => { +export const ChartAddBlock: React.FC = observer((props) => { const { block, blockUpdateHandler } = props; // states const [isButtonVisible, setIsButtonVisible] = useState(false); @@ -24,7 +24,7 @@ export const ChartAddBlock: React.FC = (props) => { // refs const containerRef = useRef(null); // chart hook - const { currentViewData } = useChart(); + const { currentViewData } = useGanttChart(); const handleButtonClick = () => { if (!currentViewData) return; @@ -88,4 +88,4 @@ export const ChartAddBlock: React.FC = (props) => { )}
); -}; +}); diff --git a/web/components/gantt-chart/helpers/block-structure.ts b/web/components/gantt-chart/helpers/block-structure.ts deleted file mode 100644 index 0f18b43cc45..00000000000 --- a/web/components/gantt-chart/helpers/block-structure.ts +++ /dev/null @@ -1,12 +0,0 @@ -// types -import { TIssue } from "@plane/types"; -import { IGanttBlock } from "components/gantt-chart"; - -export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] => - blocks?.map((block) => ({ - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: block.start_date ? new Date(block.start_date) : null, - target_date: block.target_date ? new Date(block.target_date) : null, - })); diff --git a/web/components/gantt-chart/helpers/draggable.tsx b/web/components/gantt-chart/helpers/draggable.tsx index ac1602346fe..c2b4dc61914 100644 --- a/web/components/gantt-chart/helpers/draggable.tsx +++ b/web/components/gantt-chart/helpers/draggable.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useRef, useState } from "react"; import { ArrowRight } from "lucide-react"; // hooks -import { IGanttBlock, useChart } from "components/gantt-chart"; +import { IGanttBlock } from "components/gantt-chart"; // helpers import { cn } from "helpers/common.helper"; // constants import { SIDEBAR_WIDTH } from "../constants"; +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { observer } from "mobx-react"; type Props = { block: IGanttBlock; @@ -14,19 +16,29 @@ type Props = { enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; + ganttContainerRef: React.RefObject; }; -export const ChartDraggable: React.FC = (props) => { - const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props; +export const ChartDraggable: React.FC = observer((props) => { + const { + block, + blockToRender, + handleBlock, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + ganttContainerRef, + } = props; // states const [isLeftResizing, setIsLeftResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false); const [isMoving, setIsMoving] = useState(false); const [isHidden, setIsHidden] = useState(true); + const [scrollLeft, setScrollLeft] = useState(0); // refs const resizableRef = useRef(null); // chart hook - const { currentViewData, scrollLeft } = useChart(); + const { currentViewData } = useGanttChart(); // check if cursor reaches either end while resizing/dragging const checkScrollEnd = (e: MouseEvent): number => { const SCROLL_THRESHOLD = 70; @@ -212,6 +224,17 @@ export const ChartDraggable: React.FC = (props) => { block.position?.width && scrollLeft > block.position.marginLeft + block.position.width; + useEffect(() => { + const ganttContainer = ganttContainerRef.current; + if (!ganttContainer) return; + + const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft); + ganttContainer.addEventListener("scroll", handleScroll); + return () => { + ganttContainer.removeEventListener("scroll", handleScroll); + }; + }, [ganttContainerRef]); + useEffect(() => { const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement; const resizableBlock = resizableRef.current; @@ -234,7 +257,7 @@ export const ChartDraggable: React.FC = (props) => { return () => { observer.unobserve(resizableBlock); }; - }, [block.data.name]); + }, []); return ( <> @@ -312,4 +335,4 @@ export const ChartDraggable: React.FC = (props) => {
); -}; +}); diff --git a/web/components/gantt-chart/helpers/index.ts b/web/components/gantt-chart/helpers/index.ts index 1b51dc37473..c96d42eec44 100644 --- a/web/components/gantt-chart/helpers/index.ts +++ b/web/components/gantt-chart/helpers/index.ts @@ -1,3 +1,2 @@ export * from "./add-block"; -export * from "./block-structure"; export * from "./draggable"; diff --git a/web/components/gantt-chart/hooks/index.ts b/web/components/gantt-chart/hooks/index.ts new file mode 100644 index 00000000000..00965067512 --- /dev/null +++ b/web/components/gantt-chart/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-gantt-chart"; diff --git a/web/components/gantt-chart/hooks/index.tsx b/web/components/gantt-chart/hooks/index.tsx deleted file mode 100644 index 5fb9bee3f40..00000000000 --- a/web/components/gantt-chart/hooks/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from "react"; -// types -import { ChartContextReducer } from "../types"; -// context -import { ChartContext } from "../contexts"; - -export const useChart = (): ChartContextReducer => { - const context = useContext(ChartContext); - - if (!context) throw new Error("useChart must be used within a GanttChart"); - - return context; -}; diff --git a/web/components/gantt-chart/hooks/use-gantt-chart.ts b/web/components/gantt-chart/hooks/use-gantt-chart.ts new file mode 100644 index 00000000000..23e025e9062 --- /dev/null +++ b/web/components/gantt-chart/hooks/use-gantt-chart.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { GanttStoreContext } from "components/gantt-chart/contexts"; +// types +import { IGanttStore } from "store/issue/issue_gantt_view.store"; + +export const useGanttChart = (): IGanttStore => { + const context = useContext(GanttStoreContext); + if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider"); + return context; +}; diff --git a/web/components/gantt-chart/index.ts b/web/components/gantt-chart/index.ts index 54a2cc597a7..78297ffcdb2 100644 --- a/web/components/gantt-chart/index.ts +++ b/web/components/gantt-chart/index.ts @@ -3,5 +3,5 @@ export * from "./chart"; export * from "./helpers"; export * from "./hooks"; export * from "./root"; -export * from "./types"; export * from "./sidebar"; +export * from "./types"; diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index ac132500bb9..4df5d993184 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // components import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; // context -import { ChartContextProvider } from "./contexts"; +import { GanttStoreProvider } from "components/gantt-chart/contexts"; type GanttChartRootProps = { border?: boolean; @@ -42,7 +42,7 @@ export const GanttChartRoot: FC = (props) => { } = props; return ( - + = (props) => { showAllBlocks={showAllBlocks} quickAdd={quickAdd} /> - + ); }; diff --git a/web/components/gantt-chart/sidebar/cycles.tsx b/web/components/gantt-chart/sidebar/cycles.tsx deleted file mode 100644 index 384869a407c..00000000000 --- a/web/components/gantt-chart/sidebar/cycles.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -// ui -import { Loader } from "@plane/ui"; -// components -import { CycleGanttSidebarBlock } from "components/cycles"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; -// constants -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; -}; - -export const CycleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- ); -}; diff --git a/web/components/gantt-chart/sidebar/cycles/block.tsx b/web/components/gantt-chart/sidebar/cycles/block.tsx new file mode 100644 index 00000000000..f1374c7531d --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/block.tsx @@ -0,0 +1,72 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { CycleGanttSidebarBlock } from "components/cycles"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IGanttBlock } from "components/gantt-chart/types"; +// constants +import { BLOCK_HEIGHT } from "components/gantt-chart/constants"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const CyclesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/cycles/index.ts b/web/components/gantt-chart/sidebar/cycles/index.ts new file mode 100644 index 00000000000..01acaeffb14 --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/cycles/sidebar.tsx b/web/components/gantt-chart/sidebar/cycles/sidebar.tsx new file mode 100644 index 00000000000..11f67a099f3 --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/sidebar.tsx @@ -0,0 +1,100 @@ +import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; +// ui +import { Loader } from "@plane/ui"; +// components +import { CyclesSidebarBlock } from "./block"; +// types +import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; +}; + +export const CycleGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/issues.tsx b/web/components/gantt-chart/sidebar/issues.tsx deleted file mode 100644 index 52e30ded52e..00000000000 --- a/web/components/gantt-chart/sidebar/issues.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { observer } from "mobx-react"; -import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -import { useIssueDetail } from "hooks/store"; -// ui -import { Loader } from "@plane/ui"; -// components -import { IssueGanttSidebarBlock } from "components/issues"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; - showAllBlocks?: boolean; -}; - -export const IssueGanttSidebar: React.FC = observer((props: Props) => { - const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; - - const { activeBlock, dispatch } = useChart(); - const { peekIssue } = useIssueDetail(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - <> - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const isBlockVisibleOnSidebar = block.start_date && block.target_date; - - // hide the block if it doesn't have start and target dates and showAllBlocks is false - if (!showAllBlocks && !isBlockVisibleOnSidebar) return; - - const duration = - !block.start_date || !block.target_date - ? null - : findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration && ( -
- - {duration} day{duration > 1 ? "s" : ""} - -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- - ); -}); diff --git a/web/components/gantt-chart/sidebar/issues/block.tsx b/web/components/gantt-chart/sidebar/issues/block.tsx new file mode 100644 index 00000000000..03a17a65b03 --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/block.tsx @@ -0,0 +1,77 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { IssueGanttSidebarBlock } from "components/issues"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IGanttBlock } from "../../types"; +// constants +import { BLOCK_HEIGHT } from "../../constants"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const IssuesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { peekIssue } = useIssueDetail(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration && ( +
+ + {duration} day{duration > 1 ? "s" : ""} + +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/issues/index.ts b/web/components/gantt-chart/sidebar/issues/index.ts new file mode 100644 index 00000000000..01acaeffb14 --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/components/gantt-chart/sidebar/issues/sidebar.tsx new file mode 100644 index 00000000000..323938eec95 --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -0,0 +1,107 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; +// components +import { IssuesSidebarBlock } from "./block"; +// ui +import { Loader } from "@plane/ui"; +// types +import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; + +type Props = { + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; + showAllBlocks?: boolean; +}; + +export const IssueGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => { + const isBlockVisibleOnSidebar = block.start_date && block.target_date; + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!showAllBlocks && !isBlockVisibleOnSidebar) return; + + return ( + + {(provided, snapshot) => ( + + )} + + ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/modules.tsx b/web/components/gantt-chart/sidebar/modules.tsx deleted file mode 100644 index bdf8ca571e5..00000000000 --- a/web/components/gantt-chart/sidebar/modules.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -// ui -import { Loader } from "@plane/ui"; -// components -import { ModuleGanttSidebarBlock } from "components/modules"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; -// constants -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; -}; - -export const ModuleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration !== undefined && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- ); -}; diff --git a/web/components/gantt-chart/sidebar/modules/block.tsx b/web/components/gantt-chart/sidebar/modules/block.tsx new file mode 100644 index 00000000000..4b2e4722638 --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/block.tsx @@ -0,0 +1,72 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { ModuleGanttSidebarBlock } from "components/modules"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IGanttBlock } from "components/gantt-chart/types"; +// constants +import { BLOCK_HEIGHT } from "components/gantt-chart/constants"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const ModulesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration !== undefined && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/modules/index.ts b/web/components/gantt-chart/sidebar/modules/index.ts new file mode 100644 index 00000000000..01acaeffb14 --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/modules/sidebar.tsx b/web/components/gantt-chart/sidebar/modules/sidebar.tsx new file mode 100644 index 00000000000..dee83fa79af --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/sidebar.tsx @@ -0,0 +1,100 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; +// ui +import { Loader } from "@plane/ui"; +// components +import { ModulesSidebarBlock } from "./block"; +// types +import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; +}; + +export const ModuleGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/project-views.tsx b/web/components/gantt-chart/sidebar/project-views.tsx index a27c4dded1f..a7e7c5e35ad 100644 --- a/web/components/gantt-chart/sidebar/project-views.tsx +++ b/web/components/gantt-chart/sidebar/project-views.tsx @@ -1,17 +1,10 @@ import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; // ui import { Loader } from "@plane/ui"; // components -import { IssueGanttSidebarBlock } from "components/issues"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; +import { IssuesSidebarBlock } from "./issues/block"; // types import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; -// constants -import { BLOCK_HEIGHT } from "../constants"; type Props = { title: string; @@ -23,18 +16,6 @@ type Props = { export const ProjectViewGanttSidebar: React.FC = (props) => { const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; const handleOrderChange = (result: DropResult) => { if (!blocks) return; @@ -89,59 +70,23 @@ export const ProjectViewGanttSidebar: React.FC = (props) => { > <> {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration !== undefined && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) ) : ( diff --git a/web/components/gantt-chart/types/index.ts b/web/components/gantt-chart/types/index.ts index 1360f9f45a6..6268e4363aa 100644 --- a/web/components/gantt-chart/types/index.ts +++ b/web/components/gantt-chart/types/index.ts @@ -1,10 +1,3 @@ -// context types -export type allViewsType = { - key: string; - title: string; - data: Object | null; -}; - export interface IGanttBlock { data: any; id: string; @@ -29,34 +22,6 @@ export interface IBlockUpdateData { export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; -export interface ChartContextData { - allViews: allViewsType[]; - currentView: TGanttViews; - currentViewData: ChartDataType | undefined; - renderView: any; - activeBlock: IGanttBlock | null; -} - -export type ChartContextActionPayload = - | { - type: "CURRENT_VIEW"; - payload: TGanttViews; - } - | { - type: "CURRENT_VIEW_DATA" | "RENDER_VIEW"; - payload: ChartDataType | undefined; - } - | { - type: "PARTIAL_UPDATE"; - payload: Partial; - }; - -export interface ChartContextReducer extends ChartContextData { - scrollLeft: number; - updateScrollLeft: (scrollLeft: number) => void; - dispatch: (action: ChartContextActionPayload) => void; -} - // chart render types export interface WeekMonthDataType { key: number; diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 05030c5001a..7cdc2313319 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -149,7 +149,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} /> -
+
@@ -175,7 +175,12 @@ export const CycleIssuesHeader: React.FC = observer(() => { } /> - ... + + ... + } /> @@ -239,6 +244,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["cycle"]} /> @@ -282,5 +288,3 @@ export const CycleIssuesHeader: React.FC = observer(() => { ); }); - - diff --git a/web/components/headers/global-issues.tsx b/web/components/headers/global-issues.tsx index cca1a972b94..3c40cbbffd1 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/components/headers/global-issues.tsx @@ -107,7 +107,7 @@ export const GlobalIssuesHeader: React.FC = observer((props) => { return ( <> setCreateViewModal(false)} /> -
+
diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index d51c0f43233..b84504ee21b 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -152,7 +152,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} /> -
+
@@ -178,7 +178,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => { } /> - ... + + ... + } /> @@ -243,13 +248,19 @@ export const ModuleIssuesHeader: React.FC = observer(() => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["module"]} />
{canUserCreateIssue && ( <> -
diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/components/headers/project-archived-issue-details.tsx index 3b3e05f1a12..9d4596f8357 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/components/headers/project-archived-issue-details.tsx @@ -71,7 +71,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { link={ } /> } diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index b7ca78ede3d..d1da1c85971 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -109,7 +109,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { type="text" link={ } /> } diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 5c44a84d668..43030c5c2d0 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -1,8 +1,7 @@ import { useCallback, useState } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { Briefcase, Circle, ExternalLink, Plus, Inbox } from "lucide-react"; +import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks import { useApplication, @@ -11,7 +10,6 @@ import { useProject, useProjectState, useUser, - useInbox, useMember, } from "hooks/store"; // components @@ -54,7 +52,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const { currentProjectDetails } = useProject(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); - const { getInboxesByProjectId, getInboxById } = useInbox(); const activeLayout = issueFilters?.displayFilters?.layout; @@ -101,9 +98,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const inboxesMap = currentProjectDetails?.inbox_view ? getInboxesByProjectId(currentProjectDetails.id) : undefined; - const inboxDetails = inboxesMap && inboxesMap.length > 0 ? getInboxById(inboxesMap[0]) : undefined; - const deployUrl = process.env.NEXT_PUBLIC_DEPLOY_URL; const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -115,7 +109,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} /> -
+
@@ -154,7 +148,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { } />} + link={ + } /> + } />
@@ -201,24 +197,15 @@ export const ProjectIssuesHeader: React.FC = observer(() => { />
- {currentProjectDetails?.inbox_view && inboxDetails && ( - - - - - - - )} + {canUserCreateIssue && ( <> - + +
+
+ + +
+
+ + + ); +}; diff --git a/web/components/issues/delete-archived-issue-modal.tsx b/web/components/issues/delete-archived-issue-modal.tsx deleted file mode 100644 index 49d9e19ddee..00000000000 --- a/web/components/issues/delete-archived-issue-modal.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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"; -// hooks -import useToast from "hooks/use-toast"; -import { useIssues, useProject } from "hooks/store"; -// ui -import { Button } from "@plane/ui"; -// types -import type { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data: TIssue; - 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 { getProjectById } = useProject(); - - const { - issues: { removeIssue }, - } = useIssues(EIssuesStoreType.ARCHIVED); - - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - useEffect(() => { - setIsDeleteLoading(false); - }, [isOpen]); - - const onClose = () => { - setIsDeleteLoading(false); - handleClose(); - }; - - const handleIssueDelete = async () => { - if (!workspaceSlug) return; - - setIsDeleteLoading(true); - - await removeIssue(workspaceSlug.toString(), data.project_id, 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{" "} - - {getProjectById(data?.project_id)?.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 deleted file mode 100644 index 6a2caba1801..00000000000 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { Dialog, Transition } from "@headlessui/react"; -// services -import { IssueDraftService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -// icons -import { AlertTriangle } from "lucide-react"; -// ui -import { Button } from "@plane/ui"; -// types -import type { TIssue } from "@plane/types"; -import { useProject } from "hooks/store"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data: TIssue | null; - onSubmit?: () => Promise | void; -}; - -const issueDraftService = new IssueDraftService(); - -export const DeleteDraftIssueModal: React.FC = (props) => { - const { isOpen, handleClose, data, onSubmit } = props; - // states - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - // toast alert - const { setToastAlert } = useToast(); - // hooks - const { getProjectById } = useProject(); - - useEffect(() => { - setIsDeleteLoading(false); - }, [isOpen]); - - const onClose = () => { - setIsDeleteLoading(false); - handleClose(); - }; - - const handleDeletion = async () => { - if (!workspaceSlug || !data) return; - - setIsDeleteLoading(true); - - await issueDraftService - .deleteDraftIssue(workspaceSlug.toString(), data.project_id, data.id) - .then(() => { - setIsDeleteLoading(false); - handleClose(); - - setToastAlert({ - title: "Success", - message: "Draft Issue deleted successfully", - type: "success", - }); - }) - .catch((error) => { - console.error(error); - handleClose(); - setToastAlert({ - title: "Error", - message: "Something went wrong", - type: "error", - }); - setIsDeleteLoading(false); - }); - if (onSubmit) await onSubmit(); - }; - - return ( - - - -
- - -
-
- - -
-
- - - -

Delete Draft Issue

-
-
- -

- Are you sure you want to delete issue{" "} - - {data && getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} - - {""}? All of the data related to the draft issue will be permanently removed. This action cannot - be undone. -

-
-
- - -
-
-
-
-
-
-
-
- ); -}; diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index a063980c083..3a9c0653edf 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -23,14 +23,14 @@ export const DeleteIssueModal: React.FC = (props) => { const { issueMap } = useIssues(); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const { setToastAlert } = useToast(); // hooks const { getProjectById } = useProject(); useEffect(() => { - setIsDeleteLoading(false); + setIsDeleting(false); }, [isOpen]); if (!dataId && !data) return null; @@ -38,12 +38,12 @@ export const DeleteIssueModal: React.FC = (props) => { const issue = data ? data : issueMap[dataId!]; const onClose = () => { - setIsDeleteLoading(false); + setIsDeleting(false); handleClose(); }; const handleIssueDelete = async () => { - setIsDeleteLoading(true); + setIsDeleting(true); if (onSubmit) await onSubmit() .then(() => { @@ -56,7 +56,7 @@ export const DeleteIssueModal: React.FC = (props) => { message: "Failed to delete issue", }); }) - .finally(() => setIsDeleteLoading(false)); + .finally(() => setIsDeleting(false)); }; return ( @@ -109,14 +109,8 @@ export const DeleteIssueModal: React.FC = (props) => { -
diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx deleted file mode 100644 index cfd6370fad2..00000000000 --- a/web/components/issues/draft-issue-form.tsx +++ /dev/null @@ -1,668 +0,0 @@ -import React, { FC, useState, useEffect, useRef } from "react"; -import { useRouter } from "next/router"; -import { Controller, useForm } from "react-hook-form"; -import { observer } from "mobx-react-lite"; -import { Sparkle, X } from "lucide-react"; -// hooks -import { useApplication, useEstimate, useMention, useProject, useWorkspace } from "hooks/store"; -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; -// services -import { AIService } from "services/ai.service"; -import { FileService } from "services/file.service"; -// components -import { GptAssistantPopover } from "components/core"; -import { ParentIssuesListModal } from "components/issues"; -import { IssueLabelSelect } from "components/issues/select"; -import { CreateStateModal } from "components/states"; -import { CreateLabelModal } from "components/labels"; -import { RichTextEditorWithRef } from "@plane/rich-text-editor"; -import { - CycleDropdown, - DateDropdown, - EstimateDropdown, - ModuleDropdown, - PriorityDropdown, - ProjectDropdown, - ProjectMemberDropdown, - StateDropdown, -} from "components/dropdowns"; -// ui -import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; -// helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -// types -import type { IUser, TIssue, ISearchIssueResponse } from "@plane/types"; - -const aiService = new AIService(); -const fileService = new FileService(); - -const defaultValues: Partial = { - project_id: "", - name: "", - description_html: "

", - estimate_point: null, - state_id: "", - parent_id: null, - priority: "none", - assignee_ids: [], - label_ids: [], - start_date: undefined, - target_date: undefined, -}; - -interface IssueFormProps { - handleFormSubmit: ( - formData: Partial, - action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" - ) => Promise; - data?: Partial | null; - isOpen: boolean; - prePopulatedData?: Partial | null; - projectId: string; - setActiveProject: React.Dispatch>; - createMore: boolean; - setCreateMore: React.Dispatch>; - handleClose: () => void; - handleDiscard: () => void; - status: boolean; - user: IUser | undefined; - fieldsToShow: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - )[]; -} - -export const DraftIssueForm: FC = observer((props) => { - const { - handleFormSubmit, - data, - isOpen, - prePopulatedData, - projectId, - setActiveProject, - createMore, - setCreateMore, - status, - fieldsToShow, - handleDiscard, - } = props; - // states - const [stateModal, setStateModal] = useState(false); - const [labelModal, setLabelModal] = useState(false); - const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); - const [selectedParentIssue, setSelectedParentIssue] = useState(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // store hooks - const { areEstimatesEnabledForProject } = useEstimate(); - const { mentionHighlights, mentionSuggestions } = useMention(); - // hooks - const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); - const { setToastAlert } = useToast(); - // refs - const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; - - // store - const { - config: { envConfig }, - } = useApplication(); - const { getProjectById } = useProject(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - reset, - watch, - control, - getValues, - setValue, - setFocus, - } = useForm({ - defaultValues: prePopulatedData ?? defaultValues, - reValidateMode: "onChange", - }); - - const issueName = watch("name"); - - const payload: Partial = { - name: watch("name"), - description_html: watch("description_html"), - state_id: watch("state_id"), - priority: watch("priority"), - assignee_ids: watch("assignee_ids"), - label_ids: watch("label_ids"), - start_date: watch("start_date"), - target_date: watch("target_date"), - project_id: watch("project_id"), - parent_id: watch("parent_id"), - cycle_id: watch("cycle_id"), - module_ids: watch("module_ids"), - }; - - useEffect(() => { - if (!isOpen || data) return; - - setLocalStorageValue( - JSON.stringify({ - ...payload, - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(payload), isOpen, data]); - - // const onClose = () => { - // handleClose(); - // }; - - // const onClose = () => { - // handleClose(); - // }; - - const handleCreateUpdateIssue = async ( - formData: Partial, - action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" - ) => { - await handleFormSubmit( - { - ...(data ?? {}), - ...formData, - // is_draft: action === "createDraft" || action === "updateDraft", - }, - action - ); - // TODO: check_with_backend - - setGptAssistantModal(false); - - reset({ - ...defaultValues, - project_id: projectId, - description_html: "

", - }); - editorRef?.current?.clearEditor(); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - - // setValue("description", {}); - setValue("description_html", `${watch("description_html")}

${response}

`); - editorRef.current?.setEditorValue(`${watch("description_html")}`); - }; - - const handleAutoGenerateDescription = async () => { - if (!workspaceSlug || !projectId) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: issueName, - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToastAlert({ - type: "error", - title: "Error!", - message: - "Issue title isn't informative enough to generate the description. Please try with a different title.", - }); - else handleAiAssistance(res.response_html); - }) - .catch((err) => { - const error = err?.data?.error; - - if (err.status === 429) - setToastAlert({ - type: "error", - title: "Error!", - message: error || "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToastAlert({ - type: "error", - title: "Error!", - message: error || "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - useEffect(() => { - setFocus("name"); - }, [setFocus]); - - // update projectId in form when projectId changes - useEffect(() => { - reset({ - ...getValues(), - project_id: projectId, - }); - }, [getValues, projectId, reset]); - - const startDate = watch("start_date"); - const targetDate = watch("target_date"); - - const minDate = startDate ? new Date(startDate) : null; - minDate?.setDate(minDate.getDate()); - - const maxDate = targetDate ? new Date(targetDate) : null; - maxDate?.setDate(maxDate.getDate()); - - const projectDetails = getProjectById(projectId); - - return ( - <> - {projectId && ( - <> - setStateModal(false)} projectId={projectId} /> - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])} - /> - - )} - - handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft") - )} - > -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( - ( -
- { - onChange(val); - setActiveProject(val); - }} - buttonVariant="border-with-text" - /> -
- )} - /> - )} -

- {status ? "Update" : "Create"} issue -

-
- {watch("parent_id") && - (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && - selectedParentIssue && ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - { - setValue("parent_id", null); - setSelectedParentIssue(null); - }} - /> -
-
- )} -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( -
-
- {issueName && issueName !== "" && ( - - )} - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - button={ - - } - className=" !min-w-[38rem]" - placement="top-end" - /> - )} -
- ( - { - onChange(description_html); - }} - mentionHighlights={mentionHighlights} - mentionSuggestions={mentionSuggestions} - /> - )} - /> -
- )} -
- {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( - ( -
- 0 ? "transparent-without-text" : "border-with-text"} - buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} - placeholder="Assignees" - multiple - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Start date" - maxDate={maxDate ?? undefined} - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Due date" - minDate={minDate ?? undefined} - /> -
- )} - /> - )} - {projectDetails?.cycle_view && ( - ( -
- onChange(cycleId)} - value={value} - buttonVariant="border-with-text" - /> -
- )} - /> - )} - - {projectDetails?.module_view && workspaceSlug && ( - ( -
- -
- )} - /> - )} - - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && - areEstimatesEnabledForProject(projectId) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - projectId={projectId} - /> - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - - {watch("parent_id") ? ( - <> - setParentIssueListModalOpen(true)}> - Change parent issue - - setValue("parent_id", null)}> - Remove parent issue - - - ) : ( - setParentIssueListModalOpen(true)}> - Select Parent Issue - - )} - - )} -
-
-
-
-
-
setCreateMore((prevData) => !prevData)} - > - Create more - {}} size="md" /> -
-
- - - -
-
- - - ); -}); diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx deleted file mode 100644 index 0324c1b0387..00000000000 --- a/web/components/issues/draft-issue-modal.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; -import { Dialog, Transition } from "@headlessui/react"; -// services -import { IssueService } from "services/issue"; -import { ModuleService } from "services/module.service"; -// hooks -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; -import { useIssues, useProject, useUser } from "hooks/store"; -// components -import { DraftIssueForm } from "components/issues"; -// types -import type { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; -// fetch-keys -import { PROJECT_ISSUES_DETAILS, USER_ISSUE, SUB_ISSUES } from "constants/fetch-keys"; - -interface IssuesModalProps { - data?: TIssue | null; - handleClose: () => void; - isOpen: boolean; - isUpdatingSingleIssue?: boolean; - prePopulateData?: Partial; - fieldsToShow?: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - )[]; - onSubmit?: (data: Partial) => Promise | void; -} - -// services -const issueService = new IssueService(); -const moduleService = new ModuleService(); - -export const CreateUpdateDraftIssueModal: React.FC = observer((props) => { - const { - data, - handleClose, - isOpen, - isUpdatingSingleIssue = false, - prePopulateData: prePopulateDataProps, - fieldsToShow = ["all"], - onSubmit, - } = props; - - // states - const [createMore, setCreateMore] = useState(false); - const [activeProject, setActiveProject] = useState(null); - const [prePopulateData, setPreloadedData] = useState | undefined>(undefined); - // router - const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - // store - const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); - const { currentUser } = useUser(); - const { workspaceProjectIds: workspaceProjects } = useProject(); - // derived values - const projects = workspaceProjects; - - const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {}); - - const { setToastAlert } = useToast(); - - const onClose = () => { - handleClose(); - setActiveProject(null); - }; - - const onDiscard = () => { - clearDraftIssueLocalStorage(); - onClose(); - }; - - useEffect(() => { - setPreloadedData(prePopulateDataProps ?? {}); - - if (cycleId && !prePopulateDataProps?.cycle_id) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - cycle: cycleId.toString(), - })); - } - if (moduleId && !prePopulateDataProps?.module_ids) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - module: moduleId.toString(), - })); - } - if ( - (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignee_ids - ) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], - })); - } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); - - useEffect(() => { - setPreloadedData(prePopulateDataProps ?? {}); - - if (cycleId && !prePopulateDataProps?.cycle_id) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - cycle: cycleId.toString(), - })); - } - if (moduleId && !prePopulateDataProps?.module_ids) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - module: moduleId.toString(), - })); - } - if ( - (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignee_ids - ) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], - })); - } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); - - useEffect(() => { - // if modal is closed, reset active project to null - // and return to avoid activeProject being set to some other project - if (!isOpen) { - setActiveProject(null); - return; - } - - // if data is present, set active project to the project of the - // issue. This has more priority than the project in the url. - if (data && data.project_id) return setActiveProject(data.project_id); - - if (prePopulateData && prePopulateData.project_id && !activeProject) - return setActiveProject(prePopulateData.project_id); - - if (prePopulateData && prePopulateData.project_id && !activeProject) - return setActiveProject(prePopulateData.project_id); - - // if data is not present, set active project to the project - // in the url. This has the least priority. - if (projects && projects.length > 0 && !activeProject) - setActiveProject(projects?.find((id) => id === projectId) ?? projects?.[0] ?? null); - }, [activeProject, data, projectId, projects, isOpen, prePopulateData]); - - const createDraftIssue = async (payload: Partial) => { - if (!workspaceSlug || !activeProject || !currentUser) return; - - await draftIssues - .createIssue(workspaceSlug as string, activeProject ?? "", payload) - .then(async () => { - await draftIssues.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation"); - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - - if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) - mutate(USER_ISSUE(workspaceSlug.toString())); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be created. Please try again.", - }); - }); - - if (!createMore) onClose(); - }; - - const updateDraftIssue = async (payload: Partial) => { - await draftIssues - .updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload) - .then((res) => { - if (isUpdatingSingleIssue) { - mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); - } else { - if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString())); - } - - // if (!payload.is_draft) { // TODO: check_with_backend - // if (payload.cycle_id && payload.cycle_id !== "") addIssueToCycle(res.id, payload.cycle_id); - // if (payload.module_id && payload.module_id !== "") addIssueToModule(res.id, payload.module_id); - // } - - if (!createMore) onClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue updated successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be updated. Please try again.", - }); - }); - }; - - const addIssueToCycle = async (issueId: string, cycleId: string) => { - if (!workspaceSlug || !activeProject) return; - - await issueService.addIssueToCycle(workspaceSlug as string, activeProject ?? "", cycleId, { - issues: [issueId], - }); - }; - - const addIssueToModule = async (issueId: string, moduleIds: string[]) => { - if (!workspaceSlug || !activeProject) return; - - await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, { - modules: moduleIds, - }); - }; - - const createIssue = async (payload: Partial) => { - if (!workspaceSlug || !activeProject) return; - - await issueService - .createIssue(workspaceSlug.toString(), activeProject, payload) - .then(async (res) => { - if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res.id, payload.cycle_id); - if (payload.module_ids && payload.module_ids.length > 0) await addIssueToModule(res.id, payload.module_ids); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - - if (!createMore) onClose(); - - if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) - mutate(USER_ISSUE(workspaceSlug as string)); - - if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id)); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be created. Please try again.", - }); - }); - }; - - const handleFormSubmit = async ( - formData: Partial, - action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" - ) => { - if (!workspaceSlug || !activeProject) return; - - const payload: Partial = { - ...formData, - // description: formData.description ?? "", - description_html: formData.description_html ?? "

", - }; - - if (action === "createDraft") await createDraftIssue(payload); - else if (action === "updateDraft" || action === "convertToNewIssue") await updateDraftIssue(payload); - else if (action === "createNewIssue") await createIssue(payload); - - clearDraftIssueLocalStorage(); - - if (onSubmit) await onSubmit(payload); - }; - - if (!projects || projects.length === 0) return null; - - return ( - <> - - - -
- - -
-
- - - - - -
-
-
-
- - ); -}); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 3904049e9c4..d001a29c272 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -14,10 +14,5 @@ export * from "./issue-detail"; export * from "./peek-overview"; -// draft issue -export * from "./draft-issue-form"; -export * from "./draft-issue-modal"; -export * from "./delete-draft-issue-modal"; - // archived issue -export * from "./delete-archived-issue-modal"; +export * from "./archive-issue-modal"; diff --git a/web/components/issues/issue-detail/cycle-select.tsx b/web/components/issues/issue-detail/cycle-select.tsx index fb8449d6f59..84dccefac48 100644 --- a/web/components/issues/issue-detail/cycle-select.tsx +++ b/web/components/issues/issue-detail/cycle-select.tsx @@ -50,7 +50,7 @@ export const IssueCycleSelect: React.FC = observer((props) => buttonVariant="transparent-with-text" className="w-full group" buttonContainerClassName="w-full text-left" - buttonClassName={`text-sm ${issue?.cycle_id ? "" : "text-custom-text-400"}`} + buttonClassName={`text-sm justify-between ${issue?.cycle_id ? "" : "text-custom-text-400"}`} placeholder="No cycle" hideIcon dropdownArrow diff --git a/web/components/issues/issue-detail/inbox/root.tsx b/web/components/issues/issue-detail/inbox/root.tsx index 3f0f1f128e9..d96b36efae3 100644 --- a/web/components/issues/issue-detail/inbox/root.tsx +++ b/web/components/issues/issue-detail/inbox/root.tsx @@ -30,6 +30,8 @@ export const InboxIssueDetailRoot: FC = (props) => { } = useInboxIssues(); const { issue: { getIssueById }, + fetchActivities, + fetchComments, } = useIssueDetail(); const { captureIssueEvent } = useEventTracker(); const { setToastAlert } = useToast(); @@ -54,7 +56,7 @@ export const InboxIssueDetailRoot: FC = (props) => { showToast: boolean = true ) => { try { - const response = await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); + await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); if (showToast) { setToastAlert({ title: "Issue updated successfully", @@ -64,7 +66,7 @@ export const InboxIssueDetailRoot: FC = (props) => { } captureIssueEvent({ eventName: "Inbox issue updated", - payload: { ...response, state: "SUCCESS", element: "Inbox" }, + payload: { ...data, state: "SUCCESS", element: "Inbox" }, updates: { changed_property: Object.keys(data).join(","), change_details: Object.values(data).join(","), @@ -125,6 +127,8 @@ export const InboxIssueDetailRoot: FC = (props) => { async () => { if (workspaceSlug && projectId && inboxId && issueId) { await issueOperations.fetch(workspaceSlug, projectId, issueId); + await fetchActivities(workspaceSlug, projectId, issueId); + await fetchComments(workspaceSlug, projectId, issueId); } } ); @@ -138,7 +142,7 @@ export const InboxIssueDetailRoot: FC = (props) => { if (!issue) return <>; return (
-
+
= observer((props) => { Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} @@ -154,6 +154,10 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} disabled={!is_editable} + isInboxIssue + onLabelUpdate={(val: string[]) => + issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val }) + } />
diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx index 55f07870ca7..2335e4d3250 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx @@ -1,10 +1,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { MessageSquare } from "lucide-react"; +import { RotateCcw } from "lucide-react"; // hooks import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent } from "./"; +// ui +import { ArchiveIcon } from "@plane/ui"; type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined }; @@ -18,13 +20,21 @@ export const IssueArchivedAtActivity: FC = observer((p const activity = getActivityById(activityId); if (!activity) return <>; + return ( ); }); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index c7b75340ba0..e209b4bbf23 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -14,10 +14,11 @@ type TIssueActivityBlockComponent = { activityId: string; ends: "top" | "bottom" | undefined; children: ReactNode; + customUserName?: string; }; export const IssueActivityBlockComponent: FC = (props) => { - const { icon, activityId, ends, children } = props; + const { icon, activityId, ends, children, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -37,7 +38,7 @@ export const IssueActivityBlockComponent: FC = (pr {icon ? icon : }
- + {children} = (props) => { - const { activityId } = props; + const { activityId, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -18,12 +18,19 @@ export const IssueUser: FC = (props) => { const activity = getActivityById(activityId); if (!activity) return <>; + return ( - - {activity.actor_detail?.display_name} - + <> + {customUserName ? ( + {customUserName} + ) : ( + + {activity.actor_detail?.display_name} + + )} + ); }; diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx index 2000721ee18..0c2c3cbb51d 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx @@ -14,6 +14,8 @@ import { FileService } from "services/file.service"; // types import { TIssueComment } from "@plane/types"; import { TActivityOperations } from "../root"; +// helpers +import { isEmptyHtmlString } from "helpers/string.helper"; const fileService = new FileService(); @@ -67,6 +69,12 @@ export const IssueCommentCard: FC = (props) => { isEditing && setFocus("comment_html"); }, [isEditing, setFocus]); + const isEmpty = + watch("comment_html") === "" || + watch("comment_html")?.trim() === "" || + watch("comment_html") === "

" || + isEmptyHtmlString(watch("comment_html") ?? ""); + if (!comment || !currentUser) return <>; return ( = (props) => { > <>
-
+
{ + if (e.key === "Enter" && !e.shiftKey && !isEmpty) { + handleSubmit(onEnter)(e); + } + }} + > = (props) => { - - {is_editable && ( - - )} +
+ + + + {isArchivingAllowed && ( + + + + )} + {is_editable && ( + + + + )} +
-
+
Properties
{/* TODO: render properties using a common component */} -
+
@@ -158,7 +200,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} @@ -237,9 +279,12 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { buttonVariant="transparent-with-text" className="w-3/5 flex-grow group" buttonContainerClassName="w-full text-left" - buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`} + buttonClassName={cn("text-sm", { + "text-custom-text-400": !issue.target_date, + "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), + })} hideIcon - clearIconClassName="h-3 w-3 hidden group-hover:inline" + clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100" // TODO: add this logic // showPlaceholderIcon /> @@ -269,8 +314,8 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )} {projectDetails?.module_view && ( -
-
+
+
Module
@@ -376,20 +421,20 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { disabled={!is_editable} />
-
-
-
- - Labels -
-
- +
+
+ + Labels +
+
+ +
diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index 8d05140b373..7321ef27f55 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -1,11 +1,12 @@ -import { FC, useState } from "react"; import { Bell, BellOff } from "lucide-react"; import { observer } from "mobx-react-lite"; +import { FC, useState } from "react"; // UI -import { Button } from "@plane/ui"; +import { Button, Loader } from "@plane/ui"; // hooks import { useIssueDetail } from "hooks/store"; import useToast from "hooks/use-toast"; +import isNil from "lodash/isNil"; export type TIssueSubscription = { workspaceSlug: string; @@ -25,17 +26,17 @@ export const IssueSubscription: FC = observer((props) => { // state const [loading, setLoading] = useState(false); - const subscription = getSubscriptionByIssueId(issueId); + const isSubscribed = getSubscriptionByIssueId(issueId); const handleSubscription = async () => { setLoading(true); try { - if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId); + if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId); else await createSubscription(workspaceSlug, projectId, issueId); setToastAlert({ type: "success", - title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, - message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, + title: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`, + message: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`, }); setLoading(false); } catch (error) { @@ -48,20 +49,27 @@ export const IssueSubscription: FC = observer((props) => { } }; + if (isNil(isSubscribed)) + return ( + + + + ); + return (
+ )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 4ca2538e5aa..03b0c5138e4 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -1,12 +1,15 @@ import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; +import { useRouter } from "next/router"; // hooks -import { useUser } from "hooks/store"; +import { useApplication, useUser } from "hooks/store"; // components import { + AppliedCycleFilters, AppliedDateFilters, AppliedLabelsFilters, AppliedMembersFilters, + AppliedModuleFilters, AppliedPriorityFilters, AppliedProjectFilters, AppliedStateFilters, @@ -34,6 +37,9 @@ const dateFilters = ["start_date", "target_date"]; export const AppliedFiltersList: React.FC = observer((props) => { const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; // store hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); const { membership: { currentProjectRole }, } = useUser(); @@ -104,6 +110,20 @@ export const AppliedFiltersList: React.FC = observer((props) => { values={value} /> )} + {filterKey === "cycle" && !cycleId && ( + handleRemoveFilter("cycle", val)} + values={value} + /> + )} + {filterKey === "module" && !moduleId && ( + handleRemoveFilter("module", val)} + values={value} + /> + )} {isEditingAllowed && ( + )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx index 3c94b4f3fc5..131bea46bce 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx @@ -11,7 +11,7 @@ import { FilterSubGroupBy, } from "components/issues"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueGroupByOptions } from "@plane/types"; import { ILayoutDisplayFiltersOptions } from "constants/issue"; type Props = { @@ -20,6 +20,7 @@ type Props = { handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial) => void; handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial) => void; layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; + ignoreGroupedFilters?: Partial[]; }; export const DisplayFiltersSelection: React.FC = observer((props) => { @@ -29,6 +30,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { handleDisplayFiltersUpdate, handleDisplayPropertiesUpdate, layoutDisplayFiltersOptions, + ignoreGroupedFilters = [], } = props; const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) => @@ -54,6 +56,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { group_by: val, }) } + ignoreGroupedFilters={ignoreGroupedFilters} />
)} @@ -71,6 +74,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { }) } subGroupByOptions={layoutDisplayFiltersOptions?.display_filters.sub_group_by ?? []} + ignoreGroupedFilters={ignoreGroupedFilters} />
)} diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/display-properties.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/display-properties.tsx index 3ea1453e824..f97140185ec 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/display-properties.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/display-properties.tsx @@ -1,6 +1,6 @@ import React from "react"; import { observer } from "mobx-react-lite"; - +import { useRouter } from "next/router"; // components import { FilterHeader } from "../helpers/filter-header"; // types @@ -14,10 +14,19 @@ type Props = { }; export const FilterDisplayProperties: React.FC = observer((props) => { + const router = useRouter(); + const { moduleId, cycleId } = router.query; const { displayProperties, handleUpdate } = props; const [previewEnabled, setPreviewEnabled] = React.useState(true); + const handleDisplayPropertyVisibility = (key: keyof IIssueDisplayProperties): boolean => { + const visibility = true; + if (key === "modules" && moduleId) return false; + if (key === "cycle" && cycleId) return false; + return visibility; + }; + return ( <> = observer((props) => { /> {previewEnabled && (
- {ISSUE_DISPLAY_PROPERTIES.map((displayProperty) => ( - - ))} + {ISSUE_DISPLAY_PROPERTIES.map( + (displayProperty) => + handleDisplayPropertyVisibility(displayProperty?.key) && ( + + ) + )}
)} diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx index 659d86d089f..a4478e83456 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; - // components import { FilterHeader, FilterOption } from "components/issues"; // types @@ -12,10 +11,11 @@ type Props = { displayFilters: IIssueDisplayFilterOptions; groupByOptions: TIssueGroupByOptions[]; handleUpdate: (val: TIssueGroupByOptions) => void; + ignoreGroupedFilters: Partial[]; }; export const FilterGroupBy: React.FC = observer((props) => { - const { displayFilters, groupByOptions, handleUpdate } = props; + const { displayFilters, groupByOptions, handleUpdate, ignoreGroupedFilters } = props; const [previewEnabled, setPreviewEnabled] = useState(true); @@ -34,6 +34,7 @@ export const FilterGroupBy: React.FC = observer((props) => { {ISSUE_GROUP_BY_OPTIONS.filter((option) => groupByOptions.includes(option.key)).map((groupBy) => { if (displayFilters.layout === "kanban" && selectedSubGroupBy !== null && groupBy.key === selectedSubGroupBy) return null; + if (ignoreGroupedFilters.includes(groupBy?.key)) return null; return ( void; subGroupByOptions: TIssueGroupByOptions[]; + ignoreGroupedFilters: Partial[]; }; export const FilterSubGroupBy: React.FC = observer((props) => { - const { displayFilters, handleUpdate, subGroupByOptions } = props; + const { displayFilters, handleUpdate, subGroupByOptions, ignoreGroupedFilters } = props; const [previewEnabled, setPreviewEnabled] = useState(true); @@ -33,6 +33,7 @@ export const FilterSubGroupBy: React.FC = observer((props) => {
{ISSUE_GROUP_BY_OPTIONS.filter((option) => subGroupByOptions.includes(option.key)).map((subGroupBy) => { if (selectedGroupBy !== null && subGroupBy.key === selectedGroupBy) return null; + if (ignoreGroupedFilters.includes(subGroupBy?.key)) return null; return ( void; + searchQuery: string; +}; + +export const FilterCycle: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getCycleById, getProjectCycleIds } = useCycle(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const cycleIds = projectId ? getProjectCycleIds(projectId) : undefined; + const cycles = cycleIds?.map((projectId) => getCycleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + cycles?.filter((cycle) => cycle.name.toLowerCase().includes(searchQuery.toLowerCase())), + (cycle) => cycle.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + const cycleStatus = (status: TCycleGroups) => (status ? status.toLocaleLowerCase() : "draft") as TCycleGroups; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={ + + } + title={cycle.name} + activePulse={cycleStatus(cycle?.status) === "current" ? true : false} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx index af8cfc84a55..afdee86f2cc 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; +// hooks +import { useApplication } from "hooks/store"; // components import { FilterAssignees, @@ -13,6 +15,8 @@ import { FilterState, FilterStateGroup, FilterTargetDate, + FilterCycle, + FilterModule, } from "components/issues"; // types import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types"; @@ -30,6 +34,10 @@ type Props = { export const FilterSelection: React.FC = observer((props) => { const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states } = props; + // hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -102,6 +110,28 @@ export const FilterSelection: React.FC = observer((props) => {
)} + {/* cycle */} + {isFilterEnabled("cycle") && !cycleId && ( +
+ handleFiltersUpdate("cycle", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* module */} + {isFilterEnabled("module") && !moduleId && ( +
+ handleFiltersUpdate("module", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* assignees */} {isFilterEnabled("mentions") && (
diff --git a/web/components/issues/issue-layouts/filters/header/filters/index.ts b/web/components/issues/issue-layouts/filters/header/filters/index.ts index 2d3a04d0f7f..ab5756bf4d7 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/index.ts +++ b/web/components/issues/issue-layouts/filters/header/filters/index.ts @@ -8,4 +8,6 @@ export * from "./project"; export * from "./start-date"; export * from "./state-group"; export * from "./state"; +export * from "./cycle"; +export * from "./module"; export * from "./target-date"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/module.tsx b/web/components/issues/issue-layouts/filters/header/filters/module.tsx new file mode 100644 index 00000000000..49e00f84d44 --- /dev/null +++ b/web/components/issues/issue-layouts/filters/header/filters/module.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +import { useApplication, useModule } from "hooks/store"; +// ui +import { Loader, DiceIcon } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterModule: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getModuleById, getProjectModuleIds } = useModule(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const moduleIds = projectId ? getProjectModuleIds(projectId) : undefined; + const modules = moduleIds?.map((projectId) => getModuleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + modules?.filter((module) => module.name.toLowerCase().includes(searchQuery.toLowerCase())), + (module) => module.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={} + title={cycle.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx b/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx index f46d962eabf..26c7bfaf55c 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx @@ -8,10 +8,11 @@ type Props = { title: React.ReactNode; onClick?: () => void; multiple?: boolean; + activePulse?: boolean; }; export const FilterOption: React.FC = (props) => { - const { icon, isChecked, multiple = true, onClick, title } = props; + const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props; return (
+ {activePulse && ( +
+ )} ); }; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index b5f092abac0..ec33872ebb4 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -5,12 +5,9 @@ import { observer } from "mobx-react-lite"; import { useIssues, useUser } from "hooks/store"; // components import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues"; -import { - GanttChartRoot, - IBlockUpdateData, - renderIssueBlocksStructure, - IssueGanttSidebar, -} from "components/gantt-chart"; +import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "components/gantt-chart"; +// helpers +import { renderIssueBlocksStructure } from "helpers/issue.helper"; // types import { TIssue, TUnGroupedIssues } from "@plane/types"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index 18a767455c1..209d876ac56 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -29,6 +29,7 @@ export const IssueGanttBlock: React.FC = observer((props) => { const handleIssuePeekOverview = () => workspaceSlug && issueDetails && + !issueDetails.tempId && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); return ( @@ -65,7 +66,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { const { issueId } = props; // store hooks const { getStateById } = useProjectState(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { router: { workspaceSlug }, } = useApplication(); @@ -75,7 +76,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { } = useIssueDetail(); // derived values const issueDetails = getIssueById(issueId); - const projectDetails = issueDetails && getProjectById(issueDetails?.project_id); + const projectIdentifier = issueDetails && getProjectIdentifierById(issueDetails?.project_id); const stateDetails = issueDetails && getStateById(issueDetails?.state_id); const handleIssuePeekOverview = () => @@ -89,11 +90,12 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { target="_blank" onClick={handleIssuePeekOverview} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issueDetails?.tempId} > -
+
{stateDetails && }
- {projectDetails?.identifier} {issueDetails?.sequence_id} + {projectIdentifier} {issueDetails?.sequence_id}
{issueDetails?.name} diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 83f72d8ea05..7bdaf282d22 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -41,6 +41,8 @@ export interface IBaseKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; showLoader?: boolean; viewId?: string; @@ -188,6 +190,12 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + handleArchive={ + issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined + } + handleRestore={ + issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined + } readOnly={!isEditingAllowed || isCompletedCycle} /> ), @@ -225,9 +233,15 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value); else _kanbanFilters.push(value); - issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { - [toggle]: _kanbanFilters, - }); + issuesFilter.updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.KANBAN_FILTERS, + { + [toggle]: _kanbanFilters, + }, + viewId + ); } }; @@ -249,7 +263,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas )}
@@ -277,27 +291,29 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas
- +
+ +
diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 6102ce0dd5d..be27f77068a 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -42,9 +42,9 @@ interface IssueDetailsBlockProps { const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props; // hooks - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { - router: { workspaceSlug, projectId }, + router: { workspaceSlug }, } = useApplication(); const { setPeekIssue } = useIssueDetail(); @@ -64,7 +64,7 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop
- {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} + {getProjectIdentifierById(issue.project_id)}-{issue.sequence_id}
{quickActions(issue)}
@@ -76,10 +76,13 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop ) : ( handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > {issue.name} @@ -138,7 +141,7 @@ export const KanbanIssueBlock: React.FC = memo((props) => { >
boolean; scrollableContainerRef?: MutableRefObject; isDragStarted?: boolean; + showEmptyGroup?: boolean; } const GroupByKanBan: React.FC = observer((props) => { @@ -72,32 +82,46 @@ const GroupByKanBan: React.FC = observer((props) => { canEditProperties, scrollableContainerRef, isDragStarted, + showEmptyGroup = true, } = props; const member = useMember(); const project = useProject(); const label = useLabel(); + const cycle = useCycle(); + const _module = useModule(); const projectState = useProjectState(); const { peekIssue } = useIssueDetail(); - const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); + const list = getGroupByColumns(group_by as GroupByColumnTypes, project, cycle, _module, label, projectState, member); if (!list) return null; - const visibilityGroupBy = (_list: IGroupByColumn) => - sub_group_by ? false : kanbanFilters?.group_by.includes(_list.id) ? true : false; + const groupWithIssues = list.filter((_list) => (issueIds as TGroupedIssues)?.[_list.id]?.length > 0); + + const groupList = showEmptyGroup ? list : groupWithIssues; + + const visibilityGroupBy = (_list: IGroupByColumn) => { + if (sub_group_by) { + if (kanbanFilters?.sub_group_by.includes(_list.id)) return true; + return false; + } else { + if (kanbanFilters?.group_by.includes(_list.id)) return true; + return false; + } + }; const isGroupByCreatedBy = group_by === "created_by"; return ( -
- {list && - list.length > 0 && - list.map((_list: IGroupByColumn) => { +
+ {groupList && + groupList.length > 0 && + groupList.map((_list: IGroupByColumn) => { const groupByVisibilityToggle = visibilityGroupBy(_list); return ( -
+
{sub_group_by === null && (
= observer((props) => { canEditProperties, scrollableContainerRef, isDragStarted, + showEmptyGroup, } = props; const issueKanBanView = useKanbanView(); @@ -222,6 +247,7 @@ export const KanBan: React.FC = observer((props) => { canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} isDragStarted={isDragStarted} + showEmptyGroup={showEmptyGroup} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index f88bb6b92fd..f49af292217 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; // components import { CustomMenu } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; -import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; +import { CreateUpdateIssueModal } from "components/issues"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; // hooks @@ -106,13 +106,21 @@ export const HeaderGroupByCard: FC = observer((props) => { {icon ? icon : }
-
+
{title}
-
+
{count || 0}
@@ -138,6 +146,7 @@ export const HeaderGroupByCard: FC = observer((props) => { } + placement="bottom-end" > { diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 7cbda05e1e7..a05fb179110 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -80,6 +80,10 @@ export const KanbanGroup = (props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: groupValue }; } else if (groupByKey === "priority") { preloadedData = { ...preloadedData, priority: groupValue }; + } else if (groupByKey === "cycle") { + preloadedData = { ...preloadedData, cycle_id: groupValue }; + } else if (groupByKey === "module") { + preloadedData = { ...preloadedData, module_ids: [groupValue] }; } else if (groupByKey === "labels" && groupValue != "None") { preloadedData = { ...preloadedData, label_ids: [groupValue] }; } else if (groupByKey === "assignees" && groupValue != "None") { @@ -96,6 +100,10 @@ export const KanbanGroup = (props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: subGroupValue }; } else if (subGroupByKey === "priority") { preloadedData = { ...preloadedData, priority: subGroupValue }; + } else if (groupByKey === "cycle") { + preloadedData = { ...preloadedData, cycle_id: subGroupValue }; + } else if (groupByKey === "module") { + preloadedData = { ...preloadedData, module_ids: [subGroupValue] }; } else if (subGroupByKey === "labels" && subGroupValue != "None") { preloadedData = { ...preloadedData, label_ids: [subGroupValue] }; } else if (subGroupByKey === "assignees" && subGroupValue != "None") { @@ -115,9 +123,7 @@ export const KanbanGroup = (props: IKanbanGroup) => { {(provided: any, snapshot: any) => (
diff --git a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index 2b311f6eb95..001169933b2 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks @@ -39,6 +39,11 @@ export const CycleKanBanLayout: React.FC = observer(() => { await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, }), [issues, workspaceSlug, cycleId] ); @@ -46,7 +51,15 @@ export const CycleKanBanLayout: React.FC = observer(() => { const isCompletedCycle = cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; - const canEditIssueProperties = () => !isCompletedCycle; + const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]); + + const addIssuesToView = useCallback( + (issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }, + [issues?.addIssueToCycle, workspaceSlug, projectId, cycleId] + ); return ( { QuickActions={CycleIssueQuickActions} viewId={cycleId?.toString() ?? ""} storeType={EIssuesStoreType.CYCLE} - addIssuesToView={(issueIds: string[]) => { - if (!workspaceSlug || !projectId || !cycleId) throw new Error(); - return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); - }} + addIssuesToView={addIssuesToView} canEditPropertiesBasedOnProject={canEditIssueProperties} isCompletedCycle={isCompletedCycle} /> diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index c3af69e6eb1..07ad7eb83ca 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -38,6 +38,11 @@ export const ModuleKanBanLayout: React.FC = observer(() => { await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, }), [issues, workspaceSlug, moduleId] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx index 2e189c9f4fb..c6c04165447 100644 --- a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx @@ -35,6 +35,11 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, }), [issues, workspaceSlug, userId] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 89e2ee1872a..efd86bc8e94 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -32,6 +32,11 @@ export const KanBanLayout: React.FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id); + }, }), [issues, workspaceSlug] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 1cdf71d456e..8dd33b72844 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 5fdb58ef0a6..d60e3b61868 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -18,7 +18,7 @@ import { } from "@plane/types"; // constants import { EIssueActions } from "../types"; -import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "hooks/store"; import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; @@ -30,6 +30,15 @@ interface ISubGroupSwimlaneHeader { kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; } + +const getSubGroupHeaderIssuesCount = (issueIds: TSubGroupedIssues, groupById: string) => { + let headerCount = 0; + Object.keys(issueIds).map((groupState) => { + headerCount = headerCount + (issueIds?.[groupState]?.[groupById]?.length || 0); + }); + return headerCount; +}; + const SubGroupSwimlaneHeader: React.FC = ({ issueIds, sub_group_by, @@ -38,18 +47,18 @@ const SubGroupSwimlaneHeader: React.FC = ({ kanbanFilters, handleKanbanFilters, }) => ( -
+
{list && list.length > 0 && list.map((_list: IGroupByColumn) => ( -
+
= observer((props) => { const member = useMember(); const project = useProject(); const label = useLabel(); + const cycle = useCycle(); + const _module = useModule(); const projectState = useProjectState(); - const groupByList = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); - const subGroupByList = getGroupByColumns(sub_group_by as GroupByColumnTypes, project, label, projectState, member); + const groupByList = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + _module, + label, + projectState, + member + ); + const subGroupByList = getGroupByColumns( + sub_group_by as GroupByColumnTypes, + project, + cycle, + _module, + label, + projectState, + member + ); if (!groupByList || !subGroupByList) return null; diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index b1441cff7d1..ffe9de66142 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -10,6 +10,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { IDraftIssuesFilter, IDraftIssues } from "store/issue/draft"; import { IArchivedIssuesFilter, IArchivedIssues } from "store/issue/archived"; +import { EIssueActions } from "../types"; // components import { IQuickActionProps } from "./list-view-types"; // constants @@ -18,12 +19,6 @@ import { TCreateModalStoreTypes } from "constants/issue"; // hooks import { useIssues, useUser } from "hooks/store"; -enum EIssueActions { - UPDATE = "update", - DELETE = "delete", - REMOVE = "remove", -} - interface IBaseListRoot { issuesFilter: | IProjectIssuesFilter @@ -46,6 +41,8 @@ interface IBaseListRoot { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; viewId?: string; storeType: TCreateModalStoreTypes; @@ -114,6 +111,12 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + handleArchive={ + issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined + } + handleRestore={ + issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined + } readOnly={!isEditingAllowed || isCompletedCycle} /> ), diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 2e48e1f1c8c..cc04ed71620 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -24,9 +24,9 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props; // hooks const { - router: { workspaceSlug, projectId }, + router: { workspaceSlug }, } = useApplication(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { peekIssue, setPeekIssue } = useIssueDetail(); const updateIssue = async (issueToUpdate: TIssue) => { @@ -45,7 +45,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); - const projectDetails = getProjectById(issue.project_id); + const projectIdentifier = getProjectIdentifierById(issue.project_id); return (
= observer((props: IssueBlock > {displayProperties && displayProperties?.key && (
- {projectDetails?.identifier}-{issue.sequence_id} + {projectIdentifier}-{issue.sequence_id}
)} @@ -70,10 +70,13 @@ export const IssueBlock: React.FC = observer((props: IssueBlock ) : ( handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > {issue.name} diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 373897fda6a..c6f82c2be94 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -3,7 +3,7 @@ import { useRef } from "react"; import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; import { HeaderGroupByCard } from "./headers/group-by-card"; // hooks -import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "hooks/store"; // types import { GroupByColumnTypes, @@ -65,10 +65,21 @@ const GroupByList: React.FC = (props) => { const project = useProject(); const label = useLabel(); const projectState = useProjectState(); + const cycle = useCycle(); + const _module = useModule(); const containerRef = useRef(null); - const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + const groups = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + _module, + label, + projectState, + member, + true + ); if (!groups) return null; @@ -108,7 +119,7 @@ const GroupByList: React.FC = (props) => { const isGroupByCreatedBy = group_by === "created_by"; return ( -
+
{groups && groups.length > 0 && groups.map( diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index 5a6b3c462d0..8d9164b3767 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; // lucide icons import { CircleDashed, Plus } from "lucide-react"; // components -import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; +import { CreateUpdateIssueModal } from "components/issues"; import { ExistingIssuesListModal } from "components/core"; import { CustomMenu } from "@plane/ui"; // mobx diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index e369410af7f..f435d0639a3 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -5,6 +5,8 @@ export interface IQuickActionProps { handleDelete: () => Promise; handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; + handleArchive?: () => Promise; + handleRestore?: () => Promise; customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; diff --git a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx index 2ba4ea7f5f7..6e70d00d029 100644 --- a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx @@ -24,6 +24,11 @@ export const ArchivedIssueListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, projectId, issue.id); }, + [EIssueActions.RESTORE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.restoreIssue(workspaceSlug, projectId, issue.id); + }, }), [issues, workspaceSlug, projectId] ); diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index e30c207b649..5c15ebe602e 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks @@ -38,13 +38,26 @@ export const CycleListLayout: React.FC = observer(() => { await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, }), [issues, workspaceSlug, cycleId] ); const isCompletedCycle = cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; - const canEditIssueProperties = () => !isCompletedCycle; + const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]); + + const addIssuesToView = useCallback( + (issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }, + [issues?.addIssueToCycle, workspaceSlug, projectId, cycleId] + ); return ( { issueActions={issueActions} viewId={cycleId?.toString()} storeType={EIssuesStoreType.CYCLE} - addIssuesToView={(issueIds: string[]) => { - if (!workspaceSlug || !projectId || !cycleId) throw new Error(); - return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); - }} + addIssuesToView={addIssuesToView} canEditPropertiesBasedOnProject={canEditIssueProperties} isCompletedCycle={isCompletedCycle} /> diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 520a2da32bc..95c62d34cdb 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -37,6 +37,11 @@ export const ModuleListLayout: React.FC = observer(() => { await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, }), [issues, workspaceSlug, moduleId] ); diff --git a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx index 91e80382a65..fa4a05bbcbb 100644 --- a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx @@ -36,6 +36,11 @@ export const ProfileIssuesListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, }), [issues, workspaceSlug, userId] ); diff --git a/web/components/issues/issue-layouts/list/roots/project-root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx index f0479b71ffa..9e1b5830b70 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -33,6 +33,11 @@ export const ListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, projectId, issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.archiveIssue(workspaceSlug, projectId, issue.id); + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [issues] diff --git a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx index dd384ba93eb..5ecfd6da28e 100644 --- a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewListLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 4d851545e97..776a1cd46f2 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -1,8 +1,10 @@ +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; +import xor from "lodash/xor"; // hooks -import { useEventTracker, useEstimate, useLabel } from "hooks/store"; +import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState } from "hooks/store"; // components import { IssuePropertyLabels } from "../properties/labels"; import { Tooltip } from "@plane/ui"; @@ -11,15 +13,20 @@ import { DateDropdown, EstimateDropdown, PriorityDropdown, - ProjectMemberDropdown, + MemberDropdown, + ModuleDropdown, + CycleDropdown, StateDropdown, } from "components/dropdowns"; // helpers import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; +import { cn } from "helpers/common.helper"; // types import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; // constants import { ISSUE_UPDATED } from "constants/event-tracker"; +import { EIssuesStoreType } from "constants/issue"; export interface IIssueProperties { issue: TIssue; @@ -35,10 +42,43 @@ export const IssueProperties: React.FC = observer((props) => { // store hooks const { labelMap } = useLabel(); const { captureIssueEvent } = useEventTracker(); + const { + issues: { addModulesToIssue, removeModulesFromIssue }, + } = useIssues(EIssuesStoreType.MODULE); + const { + issues: { addIssueToCycle, removeIssueFromCycle }, + } = useIssues(EIssuesStoreType.CYCLE); + const { areEstimatesEnabledForCurrentProject } = useEstimate(); + const { getStateById } = useProjectState(); // router const router = useRouter(); - const { areEstimatesEnabledForCurrentProject } = useEstimate(); + const { workspaceSlug, cycleId, moduleId } = router.query; const currentLayout = `${activeLayout} layout`; + // derived values + const stateDetails = getStateById(issue.state_id); + + const issueOperations = useMemo( + () => ({ + addModulesToIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await addModulesToIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + }, + removeModulesFromIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await removeModulesFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + }, + addIssueToCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await addIssueToCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); + }, + removeIssueFromCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await removeIssueFromCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); + }, + }), + [workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, addIssueToCycle, removeIssueFromCycle] + ); + const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }).then(() => { captureIssueEvent({ @@ -95,6 +135,45 @@ export const IssueProperties: React.FC = observer((props) => { }); }; + const handleModule = useCallback( + (moduleIds: string[] | null) => { + if (!issue || !issue.module_ids || !moduleIds) return; + + const updatedModuleIds = xor(issue.module_ids, moduleIds); + const modulesToAdd: string[] = []; + const modulesToRemove: string[] = []; + for (const moduleId of updatedModuleIds) + if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId); + else modulesToAdd.push(moduleId); + if (modulesToAdd.length > 0) issueOperations.addModulesToIssue(modulesToAdd); + if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove); + + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...issue, state: "SUCCESS", element: currentLayout }, + path: router.asPath, + updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } }, + }); + }, + [issueOperations, captureIssueEvent, currentLayout, router, issue] + ); + + const handleCycle = useCallback( + (cycleId: string | null) => { + if (!issue || issue.cycle_id === cycleId) return; + if (cycleId) issueOperations.addIssueToCycle?.(cycleId); + else issueOperations.removeIssueFromCycle?.(issue.cycle_id ?? ""); + + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...issue, state: "SUCCESS", element: currentLayout }, + path: router.asPath, + updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } }, + }); + }, + [issue, issueOperations, captureIssueEvent, currentLayout, router.asPath] + ); + const handleStartDate = (date: Date | null) => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ @@ -137,6 +216,15 @@ export const IssueProperties: React.FC = observer((props) => { }); }; + const redirectToIssueDetail = () => { + router.push({ + pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${ + issue.id + }`, + hash: "sub-issues", + }); + }; + if (!displayProperties) return null; const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; @@ -196,9 +284,9 @@ export const IssueProperties: React.FC = observer((props) => { } maxDate={maxDate ?? undefined} placeholder="Start date" + icon={} buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} disabled={isReadOnly} showTooltip @@ -212,10 +300,12 @@ export const IssueProperties: React.FC = observer((props) => { } minDate={minDate ?? undefined} placeholder="Due date" + icon={} buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""} + clearIconClassName="!text-custom-text-100" disabled={isReadOnly} showTooltip /> @@ -225,7 +315,7 @@ export const IssueProperties: React.FC = observer((props) => { {/* assignee */}
- = observer((props) => {
+ {/* modules */} + {moduleId === undefined && ( + +
+ +
+
+ )} + + {/* cycles */} + {cycleId === undefined && ( + +
+ +
+
+ )} + {/* estimates */} {areEstimatesEnabledForCurrentProject && ( @@ -258,10 +382,18 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.sub_issue_count && !!issue.sub_issues_count} > -
+
{}} + className={cn( + "flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1", + { + "hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count, + } + )} + >
{issue.sub_issues_count}
@@ -272,7 +404,7 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.attachment_count && !!issue.attachment_count} >
@@ -286,7 +418,7 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.link && !!issue.link_count} >
diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index 7e14ad3da24..0c1091d39dd 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -1,10 +1,11 @@ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search, Tags } from "lucide-react"; // hooks import { useApplication, useLabel } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { Combobox } from "@headlessui/react"; import { Tooltip } from "@plane/ui"; @@ -48,6 +49,10 @@ export const IssuePropertyLabels: React.FC = observer((pro } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + const inputRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -60,18 +65,45 @@ export const IssuePropertyLabels: React.FC = observer((pro const storeLabels = getProjectLabels(projectId); - const openDropDown = () => { - if (!storeLabels && workspaceSlug && projectId) { - setIsLoading(true); + const onOpen = () => { + if (!storeLabels && workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); - } }; const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); onClose && onClose(); }; - const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false); + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", @@ -176,6 +208,7 @@ export const IssuePropertyLabels: React.FC = observer((pro return ( = observer((pro ? "cursor-pointer" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} - onClick={openDropDown} + onClick={handleOnClick} > {label} {!hideDropdownArrow && !disabled &&