diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 5d19be11c..3adaa4230 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 941fbef87..ff9cdd238 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 0d3f97068..44bae0efa 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 296e965d7..83ed41625 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 47a85f3ba..8644f04f0 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 b509fd6f6..52ccda474 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) | -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` - -You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload) - -Thats it! - -## 🍙 Self Hosting - -For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page +`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature. ## 🚀 Features -- **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. +- **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 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. + +- **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. + +- **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. + + + +## 🛠️ 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! + +## ❤️ Community + +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. + +Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects. + +### 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). - -## ❤️ Community - -The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects. - -To chat with other community members you can join the [Plane Discord](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 channels. - ## ⛓️ Security -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. +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 squawk@plane.so to disclose any security vulnerabilities. + +## ❤️ Contribute + +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. + +### We couldn't have done this without you. + +
+ + \ No newline at end of file diff --git a/apiserver/package.json b/apiserver/package.json index fb4f8441d..060944406 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 6f66c373e..84931f46b 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): ) .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 a759b15f6..bf3313779 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView): 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 @@ class IssueLinkAPIEndpoint(BaseAPIView): 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 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) .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 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) 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 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class IssueActivityAPIEndpoint(BaseAPIView): .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 d509a53c7..2e5bb85e2 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .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 0a262a071..ec10f9bab 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 @@ class StateAPIEndpoint(BaseAPIView): 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 @@ class StateAPIEndpoint(BaseAPIView): ) 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 28e881060..9bdd4baaf 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -69,9 +69,13 @@ from .issue import ( 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 446fdb6d5..6693ba931 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer): IssueSerializer, LabelSerializer, CycleIssueSerializer, - IssueFlatSerializer, + IssueLiteSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer + InboxIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) # Expansion mapper @@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer): "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 @@ class DynamicBaseSerializer(BaseSerializer): LabelSerializer, CycleIssueSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer + InboxIssueLiteSerializer, + IssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) # Expansion mapper @@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer): "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 77c3f16cc..a273b349c 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -3,10 +3,7 @@ from rest_framework import serializers # 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 @@ from plane.db.models import ( CycleUserProperties, ) - class CycleWriteSerializer(BaseSerializer): def validate(self, data): if ( @@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer): class Meta: model = Cycle fields = "__all__" - - -class CycleSerializer(BaseSerializer): - is_favorite = serializers.BooleanField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - 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] - - return unique_list - - class Meta: - model = Cycle - fields = "__all__" read_only_fields = [ "workspace", "project", @@ -91,6 +33,52 @@ class CycleSerializer(BaseSerializer): ] +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) + + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) + + + class Meta: + model = Cycle + 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): issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 90069bd41..411c5b73f 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -444,6 +444,22 @@ class IssueLinkSerializer(BaseSerializer): 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 IssueAttachmentSerializer(BaseSerializer): ] +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 IssueReactionSerializer(BaseSerializer): ] +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 IssueStateSerializer(DynamicBaseSerializer): 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 IssueSerializer(DynamicBaseSerializer): "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 e94195671..4aabfc50e 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -5,7 +5,6 @@ from rest_framework import serializers 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 @@ from plane.db.models import ( 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 ModuleWriteSerializer(BaseSerializer): 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 @@ class ModuleWriteSerializer(BaseSerializer): 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 @@ class ModuleWriteSerializer(BaseSerializer): 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 ModuleIssueSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") class Meta: model = ModuleLink @@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer): 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 234c2824d..4ee70450b 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -2,6 +2,7 @@ from django.urls import path from plane.app.views import ( + IssueListEndpoint, IssueViewSet, LabelViewSet, BulkCreateIssueLabelsEndpoint, @@ -25,6 +26,11 @@ from plane.app.views import ( urlpatterns = [ + path( + "workspaces//projects//issues/list/", + IssueListEndpoint.as_view(), + name="project-issue", + ), path( "workspaces//projects//issues/", IssueViewSet.as_view( @@ -84,11 +90,13 @@ urlpatterns = [ 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 @@ urlpatterns = [ name="project-issue-archive", ), path( - "workspaces//projects//archived-issues//", + "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( { "get": "retrieve", - "delete": "destroy", + "post": "archive", + "delete": "unarchive", } ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "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 7e64e586a..a70ff18e5 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -22,6 +22,8 @@ from plane.app.views import ( WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) @@ -219,4 +221,14 @@ urlpatterns = [ 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 0a959a667..d4a13e497 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -49,6 +49,8 @@ from .workspace import ( WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) from .state import StateViewSet from .view import ( @@ -67,6 +69,7 @@ from .cycle import ( ) 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 04a77f789..6eb914b23 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 @@ class DefaultAnalyticsEndpoint(BaseAPIView): .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 29b4bbf8b..b2a27252c 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView): }, { "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 @@ class MobileConfigurationEndpoint(BaseAPIView): }, { "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 63d8d28ae..85e1e9f2e 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,7 +20,10 @@ from django.core import serializers 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 @@ from plane.app.serializers import ( CycleIssueSerializer, CycleFavoriteSerializer, IssueSerializer, - IssueStateSerializer, CycleWriteSerializer, CycleUserPropertiesSerializer, ) @@ -51,7 +53,6 @@ from plane.db.models import ( 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ), ) ) - .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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): } 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .order_by("label_name") ) - data = CycleSerializer(queryset).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -570,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) .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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ] 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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): .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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) # 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 = [] + cycle_issues = list( + CycleIssue.objects.filter( + ~Q(cycle_id=cycle_id), issue_id__in=issues + ) + ) + existing_issues = [ + str(cycle_issue.issue_id) for cycle_issue in cycle_issues + ] + new_issues = list(set(issues) - set(existing_issues)) - 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, - ) + # 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, ) - - CycleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, - ) - CycleIssue.objects.bulk_update( - records_to_update, - ["cycle"], + 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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): { "updated_cycle_issues": update_cycle_issue_activity, "created_cycle_issues": serializers.serialize( - "json", record_to_create + "json", created_records ), } ), @@ -723,16 +903,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleDateCheckEndpoint(BaseAPIView): 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 @@ class CycleDateCheckEndpoint(BaseAPIView): | Q(start_date__gte=start_date, end_date__lte=end_date) ) ).exclude(pk=cycle_id) - if cycles.exists(): return Response( { @@ -909,29 +1080,6 @@ class TransferCycleIssueEndpoint(BaseAPIView): ), ) ) - .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 @@ class TransferCycleIssueEndpoint(BaseAPIView): cycle_id=cycle_id, ) + # Get the assignee distribution assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -980,7 +1129,22 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .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 @@ class TransferCycleIssueEndpoint(BaseAPIView): 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 @@ class TransferCycleIssueEndpoint(BaseAPIView): 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 @@ class TransferCycleIssueEndpoint(BaseAPIView): "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 1366a2886..62ce0d910 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -15,6 +15,10 @@ from django.db.models import ( 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 f76c74d9c..ed32a14fe 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -3,8 +3,12 @@ import json # 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 @@ from plane.db.models import ( 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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): .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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): 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 410e6b332..c22ee3e52 100644 --- a/apiserver/plane/app/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet): 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 edefade16..14e0b6a9a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -4,7 +4,6 @@ import random 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 @@ from django.db.models import ( 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 @@ from plane.db.models import ( 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,44 +80,30 @@ from plane.utils.issue_filters import issue_filters from collections import defaultdict -class IssueViewSet(WebhookMixin, BaseViewSet): - def get_serializer_class(self): - return ( - IssueCreateSerializer - if self.action in ["create", "update", "partial_update"] - else IssueSerializer - ) +class IssueListEndpoint(BaseAPIView): - model = Issue - webhook_event = "issue" permission_classes = [ ProjectEntityPermission, ] - search_fields = [ - "name", - ] + def get(self, request, slug, project_id): + issue_ids = request.GET.get("issues", False) - filterset_fields = [ - "state__name", - "assignees__id", - "workspace__id", - ] + if not issue_ids: + return Response( + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) - def get_queryset(self): - return ( + issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] + + queryset = ( Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id") + 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") - .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,10 +127,34 @@ class IssueViewSet(WebhookMixin, BaseViewSet): .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") # Custom ordering for priority and state @@ -162,7 +169,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = self.get_queryset().filter(**filters) + issue_queryset = queryset.filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -224,9 +231,236 @@ class IssueViewSet(WebhookMixin, BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueSerializer( - issue_queryset, many=True, fields=self.fields, expand=self.expand - ).data + 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 ( + IssueCreateSerializer + if self.action in ["create", "update", "partial_update"] + else IssueSerializer + ) + + model = Issue + webhook_event = "issue" + permission_classes = [ + ProjectEntityPermission, + ] + + search_fields = [ + "name", + ] + + filterset_fields = [ + "state__name", + "assignees__id", + "workspace__id", + ] + + def get_queryset(self): + return ( + Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id") + ) + .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() + + @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 = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + # 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) + + # 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class UserWorkSpaceIssues(BaseAPIView): .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 @@ class UserWorkSpaceIssues(BaseAPIView): 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 @@ class IssueActivityEndpoint(BaseAPIView): .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 @@ class IssueActivityEndpoint(BaseAPIView): 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 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): .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 @@ class LabelViewSet(BaseViewSet): .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,20 +1077,9 @@ class SubIssuesEndpoint(BaseAPIView): 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") - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) + .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() @@ -800,11 +1094,39 @@ class SubIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), + .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())), + ), ) .annotate(state_group=F("state__group")) ) @@ -814,13 +1136,36 @@ class SubIssuesEndpoint(BaseAPIView): 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 @@ class IssueLinkViewSet(BaseViewSet): .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 @@ class IssueArchiveViewSet(BaseViewSet): .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 @@ class IssueArchiveViewSet(BaseViewSet): .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 @@ class IssueArchiveViewSet(BaseViewSet): 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 @@ class IssueArchiveViewSet(BaseViewSet): 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 @@ class IssueArchiveViewSet(BaseViewSet): 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 @@ class IssueSubscriberViewSet(BaseViewSet): .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 @@ class IssueReactionViewSet(BaseViewSet): .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 @@ class CommentReactionViewSet(BaseViewSet): .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 @@ class IssueRelationViewSet(BaseViewSet): .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 @@ class IssueRelationViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): .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 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): 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 4792a1f79..3b52db64f 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -4,11 +4,12 @@ import json # 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 @@ from plane.app.serializers import ( ModuleFavoriteSerializer, IssueSerializer, ModuleUserPropertiesSerializer, + ModuleDetailSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -38,11 +40,9 @@ from plane.db.models import ( 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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) + .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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): 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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): 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 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .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 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ] 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 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class ModuleLinkViewSet(BaseViewSet): .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 1d8ff1fbb..7ecf22fa8 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet): .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 5d2f95673..6f9b2618e 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ) ) ) + .annotate(sort_order=Subquery(sort_order)) .prefetch_related( Prefetch( "project_projectmember", @@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): 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 ccef3d18f..a2ed1c015 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class IssueSearchEndpoint(BaseAPIView): 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 242061e18..34b3d1dcc 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet): .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 27f31f7a9..ade445fae 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.db.models import ( ) 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.serializers import ( 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 @@ class GlobalViewIssuesViewSet(BaseViewSet): .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 @@ class GlobalViewIssuesViewSet(BaseViewSet): 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 @@ class GlobalViewIssuesViewSet(BaseViewSet): 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 @@ class IssueViewViewSet(BaseViewSet): .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 f4d3dbbb5..47de86a1c 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -22,9 +22,14 @@ from django.db.models import ( 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 @@ from plane.db.models import ( WorkspaceUserProperties, Estimate, EstimatePoint, + Module, + ModuleLink, + Cycle, ) from plane.app.permissions import ( WorkSpaceBasePermission, @@ -85,6 +93,12 @@ from plane.app.permissions import ( 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 @@ class WorkSpaceMemberViewSet(BaseViewSet): .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 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): 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 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): # 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): 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 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): ~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 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): Project.objects.filter( workspace__slug=slug, project_projectmember__member=request.user, + project_projectmember__is_active=True, ) .annotate( created_issues=Count( @@ -1343,6 +1363,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): | 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 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .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 @@ class WorkspaceLabelsEndpoint(BaseAPIView): 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 @@ class WorkspaceStatesEndpoint(BaseAPIView): 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 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): 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 9e9b348e1..2a98c6b33 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, - }, - } + 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 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), - } - ) + 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 b99e4b1d9..d8522e769 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 421521363..7a1dc4fc6 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 b86ab5e78..2a16ee911 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 974a545fc..c6c4d7515 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 f254a3cb7..0377ccb8b 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -12,15 +12,9 @@ from django.contrib.auth.models import ( 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 @@ class User(AbstractBaseUser, PermissionsMixin): 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 f03209250..5c8947e73 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 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( 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 d38b1f4c3..3b6dea332 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -9,11 +9,11 @@ from plane.db.models import Issue 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 194bf8d90..eb0f54201 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 d45f665de..424240cc0 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 000000000..88ea66c4c --- /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 000000000..c14603a4b 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 000000000..c8ba1e5f8 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 917d08fdf..9a0eac902 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 2d6ef0a6f..e6bd24b9e 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_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) + # 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 + # 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 - - update_config "INSTALLATION_DATE" "$(date)" + # 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 '+%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 60861878c..07e5ea9f6 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 30f2d15d7..16b6ea7c3 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 762ce322a..9239a9b41 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 7f7f4831a..fcb6b57bb 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 4a56f07c2..6524d1ff5 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 b0d2a1021..dbbea671e 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 8a47a8c59..ca384d34f 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 5bfba3b0f..190731fe0 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 aedb59411..403bd3f02 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 c0decdbf8..bd994f467 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 28c9a9a48..f961c0582 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 c08710ec3..f73c55c09 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,37 +187,57 @@ 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); + 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: "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), - ] - ) - ) + [ + h("div", { className: "iconContainer", innerHTML: item.icon }), + h("div", { className: "label" }, item.label), + ] + ); + } + }) ), ...tippyOptions, }); @@ -214,71 +245,6 @@ function createToolbox({ return Array.isArray(toolbox) ? toolbox[0] : toolbox; } -function createColorPickerToolbox({ - triggerButton, - tippyOptions, - onSelectColor = () => {}, -}: { - triggerButton: HTMLElement; - tippyOptions: Partial; - onSelectColor?: (color: string) => void; -}) { - const items = { - Default: "rgb(var(--color-primary-100))", - Orange: "#FFE5D1", - Grey: "#F1F1F1", - Yellow: "#FEF3C7", - Green: "#DCFCE7", - Red: "#FFDDDD", - Blue: "#D9E4FF", - Pink: "#FFE8FA", - Purple: "#E8DAFB", - }; - - const colorPicker = tippy(triggerButton, { - ...defaultTippyOptions, - content: h( - "div", - { className: "tableColorPickerToolbox" }, - Object.entries(items).map(([key, value]) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick: () => { - onSelectColor(value); - colorPicker.hide(); - }, - }, - [ - h("div", { - className: "colorContainer", - style: { - backgroundColor: value, - }, - }), - h( - "div", - { - className: "label", - }, - key - ), - ] - ) - ) - ), - onHidden: (instance) => { - instance.destroy(); - }, - showOnCreate: true, - ...tippyOptions, - }); - - return colorPicker; -} - export class TableView implements NodeView { node: ProseMirrorNode; cellMinWidth: number; @@ -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 5600fd82a..ef595eee2 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 000000000..78af3c46e --- /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 000000000..f528e9242 --- /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 2aaeb4264..1846efe47 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 b33bc12fb..bd1f2d90f 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 136d04e01..971915439 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 869c7a8c6..e586bfd80 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 be57a4a91..397e8c576 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 8481abdf3..0bdd70824 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 af99fec61..ce4088413 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 71d70399d..e033f620a 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 71ad4e0e1..c6786698d 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 a85a8b998..0f3d0d8f7 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 6bfe67261..2fee408c9 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 50ede8674..d7e807b91 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 42ce3fed5..e0829e87b 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 0e5c2eb16..9c9938845 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 5d715385a..e7ec66ae2 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 1f4a35dd4..ebe537138 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 527abe630..42c95dc4e 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 90daa08fa..7c3819e00 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 2c469e682..10f0d2792 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 88ef27426..a4eaee0a8 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 0e49da7fe..fcf2d86a2 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 8033c19a9..652e2776f 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; - read_at: Date | null; - archived_at: Date | null; - snoozed_till: Date | null; - created_by: null; - updated_by: null; - workspace: string; project: string; + read_at: Date | null; + receiver: string; + sender: string; + snoozed_till: Date | null; + 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 61cc7081b..c2c98def3 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 912fcfeb8..756a0f2f1 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 dbdbaf095..ee4b66d7b 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 37aba932a..d1623dddf 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 0fa183cb2..37608ea8d 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 930f332b9..93ac63b97 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 9ee7279cd..a1d600a60 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 ee677fe91..6a7b3c7b9 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 c2e12dc3c..3ad2805f2 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 0f9e2c712..6f26ad73f 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 ae7717b39..77753e64d 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 974efff3a..d871b64d0 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 8d6662c11..2ae4d1f9c 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 1d306bb04..01d07f64a 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 dbf349f9d..b52976aa8 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}
- - )} -
-
-
+ )} + {projectId && ( + +
+ + setIsWorkspaceLevel((prevData) => !prevData)} + /> +
+
+ )} +
+
+
- - {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
- )} + + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} - {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
No results found.
- )} + {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
No results found.
+ )} - {(isLoading || isSearching) && ( - - - - - - - - - )} + {(isLoading || isSearching) && ( + + + + + + + + + )} - {debouncedSearchTerm !== "" && ( - - )} + {debouncedSearchTerm !== "" && ( + + )} - {!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 -
-
- - {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:bg-custom-background-80" + > +
+ + Create new issue +
+ C +
+
+ + {workspaceSlug && ( + + { + closePalette(); + setTrackElement("Command palette"); + toggleCreateProjectModal(true); + }} + className="focus:outline-none" + > +
+ + Create new project +
+ P +
+
+ )} + + {/* project actions */} + {projectId && } + + + { + setPlaceholder("Search workspace settings..."); + setSearchTerm(""); + setPages([...pages, "settings"]); }} className="focus:outline-none" >
- - Create new project + + Search settings... +
+
+
+ + +
+ + Create new workspace +
+
+ { + setPlaceholder("Change interface theme..."); + setSearchTerm(""); + setPages([...pages, "change-interface-theme"]); + }} + className="focus:outline-none" + > +
+ + Change interface theme...
- P
- )} - {/* project actions */} - {projectId && } + {/* help options */} + + + )} - - { - 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... -
-
-
+ {/* workspace settings actions */} + {page === "settings" && workspaceSlug && ( + + )} - {/* help options */} - - - )} + {/* issue details page actions */} + {page === "change-issue-state" && issueDetails && ( + + )} + {page === "change-issue-priority" && issueDetails && ( + + )} + {page === "change-issue-assignee" && issueDetails && ( + + )} - {/* 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 && ( - - )} - - {/* 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 9b460bf28..c5238ec1c 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 2585e2f95..9bb10f800 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 4f99f3606..f68ff5f3c 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 f5eab83ef..39be2872b 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 000000000..f9f4e94b2 --- /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 52b1e9de1..48a5e16b7 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} + +
- )} + + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+
+

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

+
-
-

- 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} -

-
-
- ))} + ); + })} ); -}; +}); diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index cb433de05..0212e4980 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 8fd021403..12c387f47 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 1ffe19260..3ca5caeb2 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 e96b01858..7d6b1e000 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 34e973614..1a9069267 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 ed2b26c53..31958cd84 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 838d88a30..173a7f4b7 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)} - buttonVariant="border-with-text" - placeholder="Start date" - minDate={new Date()} - maxDate={maxDate ?? undefined} - tabIndex={3} - /> -
- )} - /> -
( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="End date" - minDate={minDate} - tabIndex={4} - /> -
+ name="start_date" + render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => ( + ( + { + 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} + /> + )} + /> )} />
@@ -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 beb239d87..5d82c94a8 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 e8f19d6a1..b22afb2b4 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 c825feb37..646736bd2 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; - } + const payload = { + start_date: renderFormattedPayloadDate(startDate), + end_date: renderFormattedPayloadDate(endDate), + }; - 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")}`), - }, - "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, }); + else isDateValid = await dateChecker(payload); - 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")}`, + 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 + Date range
-
- - {({ 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} - /> - - - +
+ ( + ( + { + 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"} + /> + )} + /> )} - + />
-
- - Target date -
-
- - {({ 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} - /> - - - - )} - -
-
- -
-
+
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 5956e4a1e..adff19545 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 5ec23cd70..517df4421 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 ed6bac324..7c8fbd2a9 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 4ef5708c8..e7832883b 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 fe003e167..716a3afc1 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 cf3f32232..16b2b95d9 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 eb7a5e4e5..79be92333 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 5086d2d26..000000000 --- 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 000000000..e691569b7 --- /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 000000000..2c05d9ddf --- /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 000000000..d3ef691b9 --- /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 2603b3eb2..570ea45da 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 036ed9f75..64b7efe80 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 a9f7e09c8..000000000 --- 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 000000000..0513ec627 --- /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 000000000..46a0b9cba --- /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 d1f285aa5..000000000 --- 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 7a2628cca..000000000 --- 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 c05eeb97e..5e0a3977f 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 fa068fdd0..9fa2f38c8 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 0c72b986a..57d5d8896 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 000000000..1e0882aee --- /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 15a3e5295..d98524ecc 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 6dcfdc36f..2ebd0360d 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 7a35adbb6..8a1c5de26 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 877c15901..be6229ce3 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 6e53d5390..f0ad084e9 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 a50b7748a..84b2edac4 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 e1fd02e3f..bd1a7b6dd 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 c559e9688..b67b453f1 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 ffbc1cbfe..b8adc4b3a 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 8170affa4..981fc9236 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 9dbeedece..659126ac3 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 84e7a19b5..1d8a19f1a 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; - } +export const GanttStoreContext = createContext(ganttViewStore); + +const initializeStore = () => { + const _ganttStore = ganttViewStore ?? new GanttStore(); + if (typeof window === "undefined") return _ganttStore; + if (!ganttViewStore) ganttViewStore = _ganttStore; + return _ganttStore; }; -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); - - const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { - const newState = chartReducer(state, action); - dispatch(() => newState); - return newState; - }; - - const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft); - - 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 58ac6e4b2..cc15c5d9e 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 bfeddffa2..b7497013f 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 0f18b43cc..000000000 --- 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 ac1602346..c2b4dc619 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 1b51dc374..c96d42eec 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 000000000..009650675 --- /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 5fb9bee3f..000000000 --- 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 000000000..23e025e90 --- /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 54a2cc597..78297ffcd 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 ac132500b..4df5d9931 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 384869a40..000000000 --- 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 000000000..f1374c753 --- /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 000000000..01acaeffb --- /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 000000000..11f67a099 --- /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 52e30ded5..000000000 --- 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 000000000..03a17a65b --- /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 000000000..01acaeffb --- /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 000000000..323938eec --- /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 bdf8ca571..000000000 --- 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 000000000..4b2e47226 --- /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 000000000..01acaeffb --- /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 000000000..dee83fa79 --- /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 a27c4dded..a7e7c5e35 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 1360f9f45..6268e4363 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 05030c500..7cdc23133 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 cca1a972b..3c40cbbff 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 d51c0f432..b84504ee2 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 3b3e05f1a..9d4596f83 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 b7ca78ede..d1da1c859 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 5c44a84d6..43030c5c2 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 49d9e19dd..000000000 --- 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 6a2caba18..000000000 --- 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 a063980c0..3a9c0653e 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 cfd6370fa..000000000 --- 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 0324c1b03..000000000 --- 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 3904049e9..d001a29c2 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 fb8449d6f..84dccefac 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 3f0f1f128..d96b36efa 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 55f07870c..2335e4d32 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 c7b75340b..e209b4bbf 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 2000721ee..0c2c3cbb5 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 8d05140b3..7321ef27f 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 4ca2538e5..03b0c5138 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 3c94b4f3f..131bea46b 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 3ea1453e8..f97140185 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 659d86d08..a4478e834 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 af8cfc84a..afdee86f2 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 2d3a04d0f..ab5756bf4 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 000000000..49e00f84d --- /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 f46d962ea..26c7bfaf5 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 b5f092aba..ec33872eb 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 18a767455..209d876ac 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 83f72d8ea..7bdaf282d 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 6102ce0dd..be27f7706 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 f88bb6b92..f49af2922 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 7cbda05e1..a05fb1791 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 2b311f6eb..001169933 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 c3af69e6e..07ad7eb83 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 2e189c9f4..c6c041654 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 89e2ee187..efd86bc8e 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 1cdf71d45..8dd33b728 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 5fdb58ef0..d60e3b618 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 b1441cff7..ffe9de661 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 2e48e1f1c..cc04ed716 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 373897fda..c6f82c2be 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 5a6b3c462..8d9164b37 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 e369410af..f435d0639 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 2ba4ea7f5..6e70d00d0 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 e30c207b6..5c15ebe60 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 520a2da32..95c62d34c 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 91e80382a..fa4a05bbc 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 f0479b71f..9e1b5830b 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 dd384ba93..5ecfd6da2 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 4d851545e..776a1cd46 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 7e14ad3da..0c1091d39 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 &&