diff --git a/.env.example b/.env.example index 90070de1986..71a9074a63a 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_DB="plane" +PGDATA="/var/lib/postgresql/data" # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" -REDIS_URL="redis://${REDIS_HOST}:6379/" # AWS Settings AWS_REGION="" diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 4240c10c509..5d19be11cc8 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -1,7 +1,8 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " -labels: [bug, need testing] +labels: [🐛bug] +assignees: [srinivaspendem, pushya-plane] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index b7ba116797d..941fbef87c5 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -1,7 +1,8 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " -labels: [feature] +labels: [✨feature] +assignees: [srinivaspendem, pushya-plane] body: - type: markdown attributes: diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index db65fbc2ced..38694a62ea5 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -1,61 +1,30 @@ name: Branch Build on: - pull_request: - types: - - closed + workflow_dispatch: + inputs: + branch_name: + description: "Branch Name" + required: true + default: "preview" + push: branches: - master - preview - - qa - develop - - release-* release: types: [released, prereleased] env: - TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }} jobs: branch_build_setup: - if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) || github.event_name == 'release' }} name: Build-Push Web/Space/API/Proxy Docker Image runs-on: ubuntu-20.04 - steps: - name: Check out the repo uses: actions/checkout@v3.3.0 - - - name: Uploading Proxy Source - uses: actions/upload-artifact@v3 - with: - name: proxy-src-code - path: ./nginx - - name: Uploading Backend Source - uses: actions/upload-artifact@v3 - with: - name: backend-src-code - path: ./apiserver - - name: Uploading Web Source - uses: actions/upload-artifact@v3 - with: - name: web-src-code - path: | - ./ - !./apiserver - !./nginx - !./deploy - !./space - - name: Uploading Space Source - uses: actions/upload-artifact@v3 - with: - name: space-src-code - path: | - ./ - !./apiserver - !./nginx - !./deploy - !./web outputs: gh_branch_name: ${{ env.TARGET_BRANCH }} @@ -63,33 +32,38 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Frontend Docker Tag + - name: Set Frontend Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable else TAG=${{ env.FRONTEND_TAG }} fi echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Web Source Code - uses: actions/download-artifact@v3 - with: - name: web-src-code + + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Frontend to Docker Container Registry - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: context: . file: ./web/Dockerfile.web @@ -105,33 +79,39 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Space Docker Tag + - name: Set Space Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable else TAG=${{ env.SPACE_TAG }} fi echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Space Source Code - uses: actions/download-artifact@v3 - with: - name: space-src-code + + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Space to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: context: . file: ./space/Dockerfile.space @@ -147,36 +127,42 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Backend Docker Tag + - name: Set Backend Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable else TAG=${{ env.BACKEND_TAG }} fi echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Backend Source Code - uses: actions/download-artifact@v3 - with: - name: backend-src-code + + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Backend to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: - context: . - file: ./Dockerfile.api + context: ./apiserver + file: ./apiserver/Dockerfile.api platforms: linux/amd64 push: true tags: ${{ env.BACKEND_TAG }} @@ -189,37 +175,42 @@ jobs: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ needs.branch_build_setup.outputs.gh_branch_name }} + PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} steps: - - name: Set Proxy Docker Tag + - name: Set Proxy Docker Tag run: | if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy${{ secrets.DOCKER_REPO_SUFFIX || '' }}:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable else TAG=${{ env.PROXY_TAG }} fi echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV + + - name: Docker Setup QEMU + uses: docker/setup-qemu-action@v3.0.0 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2.5.0 + uses: docker/setup-buildx-action@v3.0.0 + with: + platforms: linux/amd64,linux/arm64 + buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v2.1.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Downloading Proxy Source Code - uses: actions/download-artifact@v3 - with: - name: proxy-src-code + - name: Check out the repo + uses: actions/checkout@v4.1.1 - name: Build and Push Plane-Proxy to Docker Hub - uses: docker/build-push-action@v4.0.0 + uses: docker/build-push-action@v5.1.0 with: - context: . - file: ./Dockerfile + context: ./nginx + file: ./nginx/Dockerfile platforms: linux/amd64 tags: ${{ env.PROXY_TAG }} push: true diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index fd5d5ad03d4..296e965d7f7 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -25,7 +25,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@v38 + uses: tj-actions/changed-files@v41 with: files_yaml: | apiserver: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 29fbde45365..9f6ab1bfb5c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,10 +2,10 @@ name: "CodeQL" on: push: - branches: [ 'develop', 'hot-fix', 'stage-release' ] + branches: [ 'develop', 'preview', 'master' ] pull_request: # The branches below must be a subset of the branches above - branches: [ 'develop' ] + branches: [ 'develop', 'preview', 'master' ] schedule: - cron: '53 19 * * 5' diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 5b5f958d3b6..47a85f3ba8c 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -1,25 +1,23 @@ name: Create Sync Action on: - pull_request: + workflow_dispatch: + push: branches: - - preview - types: - - closed -env: - SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}} + - preview + +env: + SOURCE_BRANCH_NAME: ${{ github.ref_name }} jobs: - create_pr: - # Only run the job when a PR is merged - if: github.event.pull_request.merged == true + sync_changes: runs-on: ubuntu-latest permissions: pull-requests: write contents: read steps: - name: Checkout Code - uses: actions/checkout@v2 + uses: actions/checkout@v4.1.1 with: persist-credentials: false fetch-depth: 0 @@ -43,4 +41,4 @@ jobs: 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 \ No newline at end of file + git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/README.md b/README.md index 5b96dbf6c45..b509fd6f6a6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana > 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/self-hosting/docker-compose). +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 @@ -63,7 +63,7 @@ Thats it! ## 🍙 Self Hosting -For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting/docker-compose) documentation page +For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page ## 🚀 Features diff --git a/apiserver/.env.example b/apiserver/.env.example index 37178b39809..42b0e32e55f 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -8,11 +8,11 @@ SENTRY_DSN="" SENTRY_ENVIRONMENT="development" # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="plane" +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB} # Oauth variables GOOGLE_CLIENT_ID="" @@ -39,9 +39,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # deprecated OPENAI_API_KEY="sk-" # deprecated GPT_ENGINE="gpt-3.5-turbo" # deprecated -# Github -GITHUB_CLIENT_SECRET="" # For fetching release notes - # Settings related to Docker DOCKERIZED=1 # deprecated diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index cb2d1ca280b..bd6684fd5f4 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -33,15 +33,10 @@ RUN pip install -r requirements/local.txt --compile --no-cache-dir RUN addgroup -S plane && \ adduser -S captain -G plane -RUN chown captain.plane /code +COPY . . -USER captain - -# Add in Django deps and generate Django's static files - -USER root - -# RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat +RUN chown -R captain.plane /code +RUN chmod -R +x /code/bin RUN chmod -R 777 /code USER captain diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index c04ee7771a7..a0e45416a45 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -26,7 +26,9 @@ def update_description(): updated_issues.append(issue) Issue.objects.bulk_update( - updated_issues, ["description_html", "description_stripped"], batch_size=100 + updated_issues, + ["description_html", "description_stripped"], + batch_size=100, ) print("Success") except Exception as e: @@ -40,7 +42,9 @@ def update_comments(): updated_issue_comments = [] for issue_comment in issue_comments: - issue_comment.comment_html = f"

{issue_comment.comment_stripped}

" + issue_comment.comment_html = ( + f"

{issue_comment.comment_stripped}

" + ) updated_issue_comments.append(issue_comment) IssueComment.objects.bulk_update( @@ -99,7 +103,9 @@ def updated_issue_sort_order(): issue.sort_order = issue.sequence_id * random.randint(100, 500) updated_issues.append(issue) - Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100) + Issue.objects.bulk_update( + updated_issues, ["sort_order"], batch_size=100 + ) print("Success") except Exception as e: print(e) @@ -137,7 +143,9 @@ def update_project_cover_images(): project.cover_image = project_cover_images[random.randint(0, 19)] updated_projects.append(project) - Project.objects.bulk_update(updated_projects, ["cover_image"], batch_size=100) + Project.objects.bulk_update( + updated_projects, ["cover_image"], batch_size=100 + ) print("Success") except Exception as e: print(e) @@ -186,7 +194,9 @@ def update_label_color(): def create_slack_integration(): try: - _ = Integration.objects.create(provider="slack", network=2, title="Slack") + _ = Integration.objects.create( + provider="slack", network=2, title="Slack" + ) print("Success") except Exception as e: print(e) @@ -212,12 +222,16 @@ def update_integration_verified(): def update_start_date(): try: - issues = Issue.objects.filter(state__group__in=["started", "completed"]) + issues = Issue.objects.filter( + state__group__in=["started", "completed"] + ) updated_issues = [] for issue in issues: issue.start_date = issue.created_at.date() updated_issues.append(issue) - Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500) + Issue.objects.bulk_update( + updated_issues, ["start_date"], batch_size=500 + ) print("Success") except Exception as e: print(e) diff --git a/apiserver/bin/beat b/apiserver/bin/beat old mode 100644 new mode 100755 index 45d357442a9..3a9602a9ee7 --- a/apiserver/bin/beat +++ b/apiserver/bin/beat @@ -2,4 +2,7 @@ set -e python manage.py wait_for_db +# Wait for migrations +python manage.py wait_for_migrations +# Run the processes celery -A plane beat -l info \ No newline at end of file diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 0ec2e495ca8..efea53f8749 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -1,7 +1,8 @@ #!/bin/bash set -e python manage.py wait_for_db -python manage.py migrate +# Wait for migrations +python manage.py wait_for_migrations # Create the default bucket #!/bin/bash diff --git a/apiserver/bin/takeoff.local b/apiserver/bin/takeoff.local index b89c208741e..8f62370ecf4 100755 --- a/apiserver/bin/takeoff.local +++ b/apiserver/bin/takeoff.local @@ -1,7 +1,8 @@ #!/bin/bash set -e python manage.py wait_for_db -python manage.py migrate +# Wait for migrations +python manage.py wait_for_migrations # Create the default bucket #!/bin/bash diff --git a/apiserver/bin/worker b/apiserver/bin/worker index 9d2da1254d8..a70b5f77cf7 100755 --- a/apiserver/bin/worker +++ b/apiserver/bin/worker @@ -2,4 +2,7 @@ set -e python manage.py wait_for_db +# Wait for migrations +python manage.py wait_for_migrations +# Run the processes celery -A plane worker -l info \ No newline at end of file diff --git a/apiserver/manage.py b/apiserver/manage.py index 83729721915..744086783bf 100644 --- a/apiserver/manage.py +++ b/apiserver/manage.py @@ -2,10 +2,10 @@ import os import sys -if __name__ == '__main__': +if __name__ == "__main__": os.environ.setdefault( - 'DJANGO_SETTINGS_MODULE', - 'plane.settings.production') + "DJANGO_SETTINGS_MODULE", "plane.settings.production" + ) try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/apiserver/package.json b/apiserver/package.json index a317b477680..120314ed398 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.14.0" + "version": "0.15.0" } diff --git a/apiserver/plane/__init__.py b/apiserver/plane/__init__.py index fb989c4e63d..53f4ccb1d8e 100644 --- a/apiserver/plane/__init__.py +++ b/apiserver/plane/__init__.py @@ -1,3 +1,3 @@ from .celery import app as celery_app -__all__ = ('celery_app',) +__all__ = ("celery_app",) diff --git a/apiserver/plane/analytics/apps.py b/apiserver/plane/analytics/apps.py index 3537799832a..52a59f31383 100644 --- a/apiserver/plane/analytics/apps.py +++ b/apiserver/plane/analytics/apps.py @@ -2,4 +2,4 @@ class AnalyticsConfig(AppConfig): - name = 'plane.analytics' + name = "plane.analytics" diff --git a/apiserver/plane/api/apps.py b/apiserver/plane/api/apps.py index 292ad934476..6ba36e7e558 100644 --- a/apiserver/plane/api/apps.py +++ b/apiserver/plane/api/apps.py @@ -2,4 +2,4 @@ class ApiConfig(AppConfig): - name = "plane.api" \ No newline at end of file + name = "plane.api" diff --git a/apiserver/plane/api/middleware/api_authentication.py b/apiserver/plane/api/middleware/api_authentication.py index 1b2c033182d..893df7f840d 100644 --- a/apiserver/plane/api/middleware/api_authentication.py +++ b/apiserver/plane/api/middleware/api_authentication.py @@ -25,7 +25,10 @@ def get_api_token(self, request): def validate_api_token(self, token): try: api_token = APIToken.objects.get( - Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + Q( + Q(expired_at__gt=timezone.now()) + | Q(expired_at__isnull=True) + ), token=token, is_active=True, ) @@ -44,4 +47,4 @@ def authenticate(self, request): # Validate the API token user, token = self.validate_api_token(token) - return user, token \ No newline at end of file + return user, token diff --git a/apiserver/plane/api/rate_limit.py b/apiserver/plane/api/rate_limit.py index f91e2d65d84..b62936d8e59 100644 --- a/apiserver/plane/api/rate_limit.py +++ b/apiserver/plane/api/rate_limit.py @@ -1,17 +1,18 @@ from rest_framework.throttling import SimpleRateThrottle + class ApiKeyRateThrottle(SimpleRateThrottle): - scope = 'api_key' - rate = '60/minute' + scope = "api_key" + rate = "60/minute" def get_cache_key(self, request, view): # Retrieve the API key from the request header - api_key = request.headers.get('X-Api-Key') + api_key = request.headers.get("X-Api-Key") if not api_key: return None # Allow the request if there's no API key # Use the API key as part of the cache key - return f'{self.scope}:{api_key}' + return f"{self.scope}:{api_key}" def allow_request(self, request, view): allowed = super().allow_request(request, view) @@ -24,7 +25,7 @@ def allow_request(self, request, view): # Remove old histories while history and history[-1] <= now - self.duration: history.pop() - + # Calculate the requests num_requests = len(history) @@ -35,7 +36,7 @@ def allow_request(self, request, view): reset_time = int(now + self.duration) # Add headers - request.META['X-RateLimit-Remaining'] = max(0, available) - request.META['X-RateLimit-Reset'] = reset_time + request.META["X-RateLimit-Remaining"] = max(0, available) + request.META["X-RateLimit-Reset"] = reset_time - return allowed \ No newline at end of file + return allowed diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 1fd1bce7816..10b0182d6c4 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -13,5 +13,9 @@ ) from .state import StateLiteSerializer, StateSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer -from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer -from .inbox import InboxIssueSerializer \ No newline at end of file +from .module import ( + ModuleSerializer, + ModuleIssueSerializer, + ModuleLiteSerializer, +) +from .inbox import InboxIssueSerializer diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py index b964225011d..da8b9696460 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -97,9 +97,11 @@ def to_representation(self, instance): exp_serializer = expansion[expand]( getattr(instance, expand) ) - response[expand] = exp_serializer.data + response[expand] = exp_serializer.data else: # You might need to handle this case differently - response[expand] = getattr(instance, f"{expand}_id", None) + response[expand] = getattr( + instance, f"{expand}_id", None + ) - return response \ No newline at end of file + return response diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index eaff8181a3b..6fc73a4bc7e 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -23,7 +23,9 @@ def validate(self, data): 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") + raise serializers.ValidationError( + "Start date cannot exceed end date" + ) return data class Meta: @@ -55,7 +57,6 @@ class Meta: class CycleLiteSerializer(BaseSerializer): - class Meta: model = Cycle - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py index 17ae8c1ed3a..78bb74d13e4 100644 --- a/apiserver/plane/api/serializers/inbox.py +++ b/apiserver/plane/api/serializers/inbox.py @@ -2,8 +2,8 @@ from .base import BaseSerializer from plane.db.models import InboxIssue -class InboxIssueSerializer(BaseSerializer): +class InboxIssueSerializer(BaseSerializer): class Meta: model = InboxIssue fields = "__all__" @@ -16,4 +16,4 @@ class Meta: "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 75396e9bb7b..4c8d6e815b1 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -27,6 +27,7 @@ from .user import UserLiteSerializer from .state import StateLiteSerializer + class IssueSerializer(BaseSerializer): assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField( @@ -66,14 +67,16 @@ def validate(self, data): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") - + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) + try: - if(data.get("description_html", None) is not None): + if data.get("description_html", None) is not None: parsed = html.fromstring(data["description_html"]) - parsed_str = html.tostring(parsed, encoding='unicode') + parsed_str = html.tostring(parsed, encoding="unicode") data["description_html"] = parsed_str - + except Exception as e: raise serializers.ValidationError(f"Invalid HTML: {str(e)}") @@ -96,7 +99,8 @@ def validate(self, data): if ( data.get("state") and not State.objects.filter( - project_id=self.context.get("project_id"), pk=data.get("state").id + project_id=self.context.get("project_id"), + pk=data.get("state").id, ).exists() ): raise serializers.ValidationError( @@ -107,7 +111,8 @@ def validate(self, data): if ( data.get("parent") and not Issue.objects.filter( - workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id + workspace_id=self.context.get("workspace_id"), + pk=data.get("parent").id, ).exists() ): raise serializers.ValidationError( @@ -238,9 +243,13 @@ def to_representation(self, instance): ] if "labels" in self.fields: if "labels" in self.expand: - data["labels"] = LabelSerializer(instance.labels.all(), many=True).data + data["labels"] = LabelSerializer( + instance.labels.all(), many=True + ).data else: - data["labels"] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [ + str(label.id) for label in instance.labels.all() + ] return data @@ -278,7 +287,8 @@ class Meta: # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + url=validated_data.get("url"), + issue_id=validated_data.get("issue_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -324,11 +334,11 @@ class Meta: def validate(self, data): try: - if(data.get("comment_html", None) is not None): + if data.get("comment_html", None) is not None: parsed = html.fromstring(data["comment_html"]) - parsed_str = html.tostring(parsed, encoding='unicode') + parsed_str = html.tostring(parsed, encoding="unicode") data["comment_html"] = parsed_str - + except Exception as e: raise serializers.ValidationError(f"Invalid HTML: {str(e)}") return data @@ -362,7 +372,6 @@ class Meta: class LabelLiteSerializer(BaseSerializer): - class Meta: model = Label fields = [ diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index a96a9b54d21..01a20106460 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -52,7 +52,9 @@ def validate(self, data): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) if data.get("members", []): data["members"] = ProjectMember.objects.filter( @@ -146,16 +148,16 @@ class Meta: # Validation if url already exists def create(self, validated_data): if ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=validated_data.get("module_id") + url=validated_data.get("url"), + module_id=validated_data.get("module_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} ) return ModuleLink.objects.create(**validated_data) - -class ModuleLiteSerializer(BaseSerializer): +class ModuleLiteSerializer(BaseSerializer): class Meta: model = Module - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index c394a080dd9..342cc1a81da 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -2,12 +2,17 @@ from rest_framework import serializers # Module imports -from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate +from plane.db.models import ( + Project, + ProjectIdentifier, + WorkspaceMember, + State, + Estimate, +) from .base import BaseSerializer class ProjectSerializer(BaseSerializer): - total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True) @@ -21,7 +26,7 @@ class Meta: fields = "__all__" read_only_fields = [ "id", - 'emoji', + "emoji", "workspace", "created_at", "updated_at", @@ -59,12 +64,16 @@ def validate(self, data): def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") + raise serializers.ValidationError( + detail="Project Identifier is required" + ) if ProjectIdentifier.objects.filter( name=identifier, workspace_id=self.context["workspace_id"] ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") + raise serializers.ValidationError( + detail="Project Identifier is taken" + ) project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] @@ -89,4 +98,4 @@ class Meta: "emoji", "description", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index 9d08193d85c..1649a7bcfcf 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -7,9 +7,9 @@ class StateSerializer(BaseSerializer): def validate(self, data): # If the default is being provided then make all other states default False if data.get("default", False): - State.objects.filter(project_id=self.context.get("project_id")).update( - default=False - ) + State.objects.filter( + project_id=self.context.get("project_id") + ).update(default=False) return data class Meta: @@ -35,4 +35,4 @@ class Meta: "color", "group", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index 42b6c39671f..fe50021b556 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -13,4 +13,4 @@ class Meta: "avatar", "display_name", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index c4c5caceb3b..a47de3d3165 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -5,6 +5,7 @@ class WorkspaceLiteSerializer(BaseSerializer): """Lite serializer with only required fields""" + class Meta: model = Workspace fields = [ @@ -12,4 +13,4 @@ class Meta: "slug", "id", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index a5ef0f5f187..84927439e2e 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -12,4 +12,4 @@ *cycle_patterns, *module_patterns, *inbox_patterns, -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index f557f8af0a8..593e501bf98 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -32,4 +32,4 @@ TransferCycleIssueAPIEndpoint.as_view(), name="transfer-issues", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/inbox.py b/apiserver/plane/api/urls/inbox.py index 3a2a57786ae..95eb68f3f2b 100644 --- a/apiserver/plane/api/urls/inbox.py +++ b/apiserver/plane/api/urls/inbox.py @@ -14,4 +14,4 @@ InboxIssueAPIEndpoint.as_view(), name="inbox-issue", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 7117a9e8b86..4309f44e968 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -23,4 +23,4 @@ ModuleIssueAPIEndpoint.as_view(), name="module-issues", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index c73e84c89da..1ed450c8614 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -3,7 +3,7 @@ from plane.api.views import ProjectAPIEndpoint urlpatterns = [ - path( + path( "workspaces//projects/", ProjectAPIEndpoint.as_view(), name="project", @@ -13,4 +13,4 @@ ProjectAPIEndpoint.as_view(), name="project", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/urls/state.py b/apiserver/plane/api/urls/state.py index 0676ac5ade2..b03f386e648 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -13,4 +13,4 @@ StateAPIEndpoint.as_view(), name="states", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 84d8dcabb19..0da79566f45 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -18,4 +18,4 @@ from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint -from .inbox import InboxIssueAPIEndpoint \ No newline at end of file +from .inbox import InboxIssueAPIEndpoint diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index abde4e8b013..b069ef78c1a 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -41,7 +41,9 @@ class WebhookMixin: bulk = False def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response(request, response, *args, **kwargs) + response = super().finalize_response( + request, response, *args, **kwargs + ) # Check for the case should webhook be sent if ( @@ -104,15 +106,14 @@ def handle_exception(self, exc): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): return Response( - {"error": f"key {e} does not exist"}, + {"error": f" The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) @@ -140,7 +141,9 @@ def dispatch(self, request, *args, **kwargs): def finalize_response(self, request, response, *args, **kwargs): # Call super to get the default response - response = super().finalize_response(request, response, *args, **kwargs) + response = super().finalize_response( + request, response, *args, **kwargs + ) # Add custom headers if they exist in the request META ratelimit_remaining = request.META.get("X-RateLimit-Remaining") @@ -164,13 +167,17 @@ def project_id(self): @property def fields(self): fields = [ - field for field in self.request.GET.get("fields", "").split(",") if field + field + for field in self.request.GET.get("fields", "").split(",") + if field ] return fields if fields else None @property def expand(self): expand = [ - expand for expand in self.request.GET.get("expand", "").split(",") if expand + expand + for expand in self.request.GET.get("expand", "").split(",") + if expand ] return expand if expand else None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 310332333f0..c296bb11180 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -12,7 +12,13 @@ # Module imports from .base import BaseAPIView, WebhookMixin -from plane.db.models import Cycle, Issue, CycleIssue, IssueLink, IssueAttachment +from plane.db.models import ( + Cycle, + Issue, + CycleIssue, + IssueLink, + IssueAttachment, +) from plane.app.permissions import ProjectEntityPermission from plane.api.serializers import ( CycleSerializer, @@ -102,7 +108,9 @@ def get_queryset(self): ), ) ) - .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) .annotate( completed_estimates=Sum( "issue_cycle__issue__estimate_point", @@ -201,7 +209,8 @@ def get(self, request, slug, project_id, pk=None): # Incomplete Cycles if cycle_view == "incomplete": queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), + Q(end_date__gte=timezone.now().date()) + | Q(end_date__isnull=True), ) return self.paginate( request=request, @@ -238,8 +247,12 @@ def post(self, request, slug, project_id): project_id=project_id, owned_by=request.user, ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) else: return Response( { @@ -249,15 +262,22 @@ def post(self, request, slug, project_id): ) def patch(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) request_data = request.data - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + if ( + cycle.end_date is not None + and cycle.end_date < timezone.now().date() + ): if "sort_order" in request_data: # Can only change sort order request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) + "sort_order": request_data.get( + "sort_order", cycle.sort_order + ) } else: return Response( @@ -275,11 +295,13 @@ def patch(self, request, slug, project_id, pk): def delete(self, request, slug, project_id, pk): cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) + CycleIssue.objects.filter( + cycle_id=self.kwargs.get("pk") + ).values_list("issue", flat=True) + ) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk ) - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) issue_activity.delay( type="cycle.activity.deleted", @@ -319,7 +341,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( CycleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("issue_id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -342,7 +366,9 @@ def get(self, request, slug, project_id, cycle_id): issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -364,7 +390,9 @@ def get(self, request, slug, project_id, cycle_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -387,14 +415,18 @@ def post(self, request, slug, project_id, cycle_id): if not issues: return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, ) cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=cycle_id ) - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + if ( + cycle.end_date is not None + and cycle.end_date < timezone.now().date() + ): return Response( { "error": "The Cycle has already been completed so no new issues can be added" @@ -479,7 +511,10 @@ def post(self, request, slug, project_id, cycle_id): def delete(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( - issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, ) issue_id = cycle_issue.issue_id cycle_issue.delete() @@ -550,4 +585,4 @@ def post(self, request, slug, project_id, cycle_id): updated_cycles, ["cycle_id"], batch_size=100 ) - return Response({"message": "Success"}, status=status.HTTP_200_OK) \ No newline at end of file + return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 4f4cdc4ef5a..c1079345ac2 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -14,7 +14,14 @@ from .base import BaseAPIView from plane.app.permissions import ProjectLitePermission from plane.api.serializers import InboxIssueSerializer, IssueSerializer -from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox +from plane.db.models import ( + InboxIssue, + Issue, + State, + ProjectMember, + Project, + Inbox, +) from plane.bgtasks.issue_activites_task import issue_activity @@ -43,7 +50,8 @@ def get_queryset(self): ).first() project = Project.objects.get( - workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") + workspace__slug=self.kwargs.get("slug"), + pk=self.kwargs.get("project_id"), ) if inbox is None and not project.inbox_view: @@ -51,7 +59,8 @@ def get_queryset(self): return ( InboxIssue.objects.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), inbox_id=inbox.id, @@ -87,7 +96,8 @@ def get(self, request, slug, project_id, issue_id=None): def post(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) inbox = Inbox.objects.filter( @@ -117,7 +127,8 @@ def post(self, request, slug, project_id): "none", ]: return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Invalid priority"}, + status=status.HTTP_400_BAD_REQUEST, ) # Create or get state @@ -222,10 +233,14 @@ def patch(self, request, slug, project_id, issue_id): "description_html": issue_data.get( "description_html", issue.description_html ), - "description": issue_data.get("description", issue.description), + "description": issue_data.get( + "description", issue.description + ), } - issue_serializer = IssueSerializer(issue, data=issue_data, partial=True) + issue_serializer = IssueSerializer( + issue, data=issue_data, partial=True + ) if issue_serializer.is_valid(): current_instance = issue @@ -266,7 +281,9 @@ def patch(self, request, slug, project_id, issue_id): project_id=project_id, ) state = State.objects.filter( - group="cancelled", workspace__slug=slug, project_id=project_id + group="cancelled", + workspace__slug=slug, + project_id=project_id, ).first() if state is not None: issue.state = state @@ -284,17 +301,22 @@ def patch(self, request, slug, project_id, issue_id): if issue.state.name == "Triage": # Move to default state state = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True + workspace__slug=slug, + project_id=project_id, + default=True, ).first() if state is not None: issue.state = state issue.save() return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) else: return Response( - InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK + InboxIssueSerializer(inbox_issue).data, + status=status.HTTP_200_OK, ) def delete(self, request, slug, project_id, issue_id): diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1ac8ddcff95..e91f2a5f66f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -67,7 +67,9 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -86,7 +88,9 @@ def get_queryset(self): def get(self, request, slug, project_id, pk=None): if pk: issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -102,7 +106,13 @@ def get(self, request, slug, project_id, pk=None): # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") @@ -117,7 +127,9 @@ def get(self, request, slug, project_id, pk=None): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -127,7 +139,9 @@ def get(self, request, slug, project_id, pk=None): # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -175,7 +189,9 @@ def get(self, request, slug, project_id, pk=None): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -209,7 +225,9 @@ def post(self, request, slug, project_id): # Track the issue issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), @@ -220,7 +238,9 @@ def post(self, request, slug, project_id): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def patch(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) project = Project.objects.get(pk=project_id) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder @@ -250,7 +270,9 @@ def patch(self, request, slug, project_id, pk=None): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) @@ -297,11 +319,17 @@ def post(self, request, slug, project_id): serializer = LabelSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError: return Response( - {"error": "Label with the same name already exists in the project"}, + { + "error": "Label with the same name already exists in the project" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -318,7 +346,11 @@ def get(self, request, slug, project_id, pk=None): ).data, ) label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,) + serializer = LabelSerializer( + label, + fields=self.fields, + expand=self.expand, + ) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, pk=None): @@ -328,7 +360,6 @@ def patch(self, request, slug, project_id, pk=None): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, slug, project_id, pk=None): label = self.get_queryset().get(pk=pk) @@ -395,7 +426,9 @@ def post(self, request, slug, project_id, issue_id): ) issue_activity.delay( type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -407,14 +440,19 @@ def post(self, request, slug, project_id, issue_id): def patch(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder, ) - serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + serializer = IssueLinkSerializer( + issue_link, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -431,7 +469,10 @@ def patch(self, request, slug, project_id, issue_id, pk): def delete(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, @@ -466,7 +507,9 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( - IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) + IssueComment.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) @@ -518,7 +561,9 @@ def post(self, request, slug, project_id, issue_id): ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), @@ -530,7 +575,10 @@ def post(self, request, slug, project_id, issue_id): def patch(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( @@ -556,7 +604,10 @@ def patch(self, request, slug, project_id, issue_id, pk): def delete(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) current_instance = json.dumps( IssueCommentSerializer(issue_comment).data, @@ -591,7 +642,7 @@ def get(self, request, slug, project_id, issue_id, pk=None): ) .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) - + if pk: issue_activities = issue_activities.get(pk=pk) serializer = IssueActivitySerializer(issue_activities) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 959b7ccc349..1a9a21a3c19 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -55,7 +55,9 @@ def get_queryset(self): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related("module", "created_by"), + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), ) ) .annotate( @@ -122,17 +124,30 @@ def get_queryset(self): def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) - serializer = ModuleSerializer(data=request.data, context={"project_id": project_id, "workspace_id": project.workspace_id}) + serializer = ModuleSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + }, + ) if serializer.is_valid(): serializer.save() module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + def patch(self, request, slug, project_id, pk): - module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) - serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) + module = Module.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + serializer = ModuleSerializer( + module, + data=request.data, + context={"project_id": project_id}, + partial=True, + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -162,9 +177,13 @@ def get(self, request, slug, project_id, pk=None): ) def delete(self, request, slug, project_id, pk): - module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + module = Module.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) + ModuleIssue.objects.filter(module_id=pk).values_list( + "issue", flat=True + ) ) issue_activity.delay( type="module.activity.deleted", @@ -204,7 +223,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( ModuleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("issue") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -228,7 +249,9 @@ def get(self, request, slug, project_id, module_id): issues = ( Issue.issue_objects.filter(issue_module__module_id=module_id) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -250,7 +273,9 @@ def get(self, request, slug, project_id, module_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -271,7 +296,8 @@ def post(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) if not len(issues): return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, ) module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=module_id @@ -354,7 +380,10 @@ def post(self, request, slug, project_id, module_id): def delete(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + issue_id=issue_id, ) module_issue.delete() issue_activity.delay( @@ -371,4 +400,4 @@ def delete(self, request, slug, project_id, module_id, issue_id): current_instance=None, epoch=int(timezone.now().timestamp()), ) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index e8dc9f5a96b..cb1f7dc7bc0 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -39,9 +39,15 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): def get_queryset(self): return ( Project.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) + .filter( + Q(project_projectmember__member=self.request.user) + | Q(network=2) + ) .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" + "workspace", + "workspace__owner", + "default_assignee", + "project_lead", ) .annotate( is_member=Exists( @@ -120,11 +126,18 @@ def get(self, request, slug, project_id=None): request=request, queryset=(projects), on_results=lambda projects: ProjectSerializer( - projects, many=True, fields=self.fields, expand=self.expand, + projects, + many=True, + fields=self.fields, + expand=self.expand, ).data, ) project = self.get_queryset().get(workspace__slug=slug, pk=project_id) - serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,) + serializer = ProjectSerializer( + project, + fields=self.fields, + expand=self.expand, + ) return Response(serializer.data, status=status.HTTP_200_OK) def post(self, request, slug): @@ -138,7 +151,9 @@ def post(self, request, slug): # Add the user as Administrator to the project project_member = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + project_id=serializer.data["id"], + member=request.user, + role=20, ) # Also create the issue property for the user _ = IssueProperty.objects.create( @@ -211,9 +226,15 @@ def post(self, request, slug): ] ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) serializer = ProjectSerializer(project) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST, @@ -226,7 +247,8 @@ def post(self, request, slug): ) except Workspace.DoesNotExist as e: return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Workspace does not exist"}, + status=status.HTTP_404_NOT_FOUND, ) except ValidationError as e: return Response( @@ -250,7 +272,9 @@ def patch(self, request, slug, project_id=None): serializer.save() if serializer.data["inbox_view"]: Inbox.objects.get_or_create( - name=f"{project.name} Inbox", project=project, is_default=True + name=f"{project.name} Inbox", + project=project, + is_default=True, ) # Create the triage state in Backlog group @@ -262,10 +286,16 @@ def patch(self, request, slug, project_id=None): color="#ff7700", ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) serializer = ProjectSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError as e: if "already exists" in str(e): return Response( @@ -274,7 +304,8 @@ def patch(self, request, slug, project_id=None): ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Project does not exist"}, + status=status.HTTP_404_NOT_FOUND, ) except ValidationError as e: return Response( @@ -285,4 +316,4 @@ def patch(self, request, slug, project_id=None): def delete(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 3d2861778ac..f931c2ed264 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -34,7 +34,9 @@ def get_queryset(self): ) def post(self, request, slug, project_id): - serializer = StateSerializer(data=request.data, context={"project_id": project_id}) + serializer = StateSerializer( + data=request.data, context={"project_id": project_id} + ) if serializer.is_valid(): serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_200_OK) @@ -64,14 +66,19 @@ def delete(self, request, slug, project_id, state_id): ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Default state cannot be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=state_id).exists() if issue_exist: return Response( - {"error": "The state is not empty, only empty states can be deleted"}, + { + "error": "The state is not empty, only empty states can be deleted" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -79,9 +86,11 @@ def delete(self, request, slug, project_id, state_id): return Response(status=status.HTTP_204_NO_CONTENT) def patch(self, request, slug, project_id, state_id=None): - state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id) + state = State.objects.get( + workspace__slug=slug, project_id=project_id, pk=state_id + ) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/middleware/api_authentication.py b/apiserver/plane/app/middleware/api_authentication.py index ddabb4132da..893df7f840d 100644 --- a/apiserver/plane/app/middleware/api_authentication.py +++ b/apiserver/plane/app/middleware/api_authentication.py @@ -25,7 +25,10 @@ def get_api_token(self, request): def validate_api_token(self, token): try: api_token = APIToken.objects.get( - Q(Q(expired_at__gt=timezone.now()) | Q(expired_at__isnull=True)), + Q( + Q(expired_at__gt=timezone.now()) + | Q(expired_at__isnull=True) + ), token=token, is_active=True, ) diff --git a/apiserver/plane/app/permissions/__init__.py b/apiserver/plane/app/permissions/__init__.py index 2298f34428d..8e879350476 100644 --- a/apiserver/plane/app/permissions/__init__.py +++ b/apiserver/plane/app/permissions/__init__.py @@ -1,4 +1,3 @@ - from .workspace import ( WorkSpaceBasePermission, WorkspaceOwnerPermission, @@ -13,5 +12,3 @@ ProjectMemberPermission, ProjectLitePermission, ) - - diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index c406453b728..0d72f919241 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -17,6 +17,7 @@ WorkspaceThemeSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, + WorkspaceUserPropertiesSerializer, ) from .project import ( ProjectSerializer, @@ -31,14 +32,20 @@ ProjectDeployBoardSerializer, ProjectMemberAdminSerializer, ProjectPublicMemberSerializer, + ProjectMemberRoleSerializer, ) from .state import StateSerializer, StateLiteSerializer -from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer +from .view import ( + GlobalViewSerializer, + IssueViewSerializer, + IssueViewFavoriteSerializer, +) from .cycle import ( CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer, + CycleUserPropertiesSerializer, ) from .asset import FileAssetSerializer from .issue import ( @@ -69,6 +76,7 @@ ModuleIssueSerializer, ModuleLinkSerializer, ModuleFavoriteSerializer, + ModuleUserPropertiesSerializer, ) from .api import APITokenSerializer, APITokenReadSerializer @@ -85,20 +93,33 @@ from .importer import ImporterSerializer -from .page import PageSerializer, PageLogSerializer, SubPageSerializer, PageFavoriteSerializer +from .page import ( + PageSerializer, + PageLogSerializer, + SubPageSerializer, + PageFavoriteSerializer, +) from .estimate import ( EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer, + WorkspaceEstimateSerializer, ) -from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer +from .inbox import ( + InboxSerializer, + InboxIssueSerializer, + IssueStateInboxSerializer, + InboxIssueLiteSerializer, +) from .analytic import AnalyticViewSerializer -from .notification import NotificationSerializer +from .notification import NotificationSerializer, UserNotificationPreferenceSerializer from .exporter import ExporterHistorySerializer -from .webhook import WebhookSerializer, WebhookLogSerializer \ No newline at end of file +from .webhook import WebhookSerializer, WebhookLogSerializer + +from .dashboard import DashboardSerializer, WidgetSerializer diff --git a/apiserver/plane/app/serializers/api.py b/apiserver/plane/app/serializers/api.py index 08bb747d9bc..264a58f92b9 100644 --- a/apiserver/plane/app/serializers/api.py +++ b/apiserver/plane/app/serializers/api.py @@ -3,7 +3,6 @@ class APITokenSerializer(BaseSerializer): - class Meta: model = APIToken fields = "__all__" @@ -18,14 +17,12 @@ class Meta: class APITokenReadSerializer(BaseSerializer): - class Meta: model = APIToken - exclude = ('token',) + exclude = ("token",) class APIActivityLogSerializer(BaseSerializer): - class Meta: model = APIActivityLog fields = "__all__" diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 89c9725d951..446fdb6d537 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -4,16 +4,17 @@ class BaseSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(read_only=True) -class DynamicBaseSerializer(BaseSerializer): +class DynamicBaseSerializer(BaseSerializer): def __init__(self, *args, **kwargs): # If 'fields' is provided in the arguments, remove it and store it separately. # This is done so as not to pass this custom argument up to the superclass. - fields = kwargs.pop("fields", None) + fields = kwargs.pop("fields", []) + self.expand = kwargs.pop("expand", []) or [] + fields = self.expand # Call the initialization of the superclass. super().__init__(*args, **kwargs) - # If 'fields' was provided, filter the fields of the serializer accordingly. if fields is not None: self.fields = self._filter_fields(fields) @@ -31,7 +32,7 @@ def _filter_fields(self, fields): # loop through its keys and values. if isinstance(field_name, dict): for key, value in field_name.items(): - # If the value of this nested field is a list, + # If the value of this nested field is a list, # perform a recursive filter on it. if isinstance(value, list): self._filter_fields(self.fields[key], value) @@ -47,12 +48,101 @@ def _filter_fields(self, fields): elif isinstance(item, dict): allowed.append(list(item.keys())[0]) - # Convert the current serializer's fields and the allowed fields to sets. - existing = set(self.fields) - allowed = set(allowed) + for field in allowed: + if field not in self.fields: + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueFlatSerializer, + IssueRelationSerializer, + InboxIssueLiteSerializer + ) - # Remove fields from the serializer that aren't in the 'allowed' list. - for field_name in (existing - allowed): - self.fields.pop(field_name) + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueSerializer, + "issue_relation": IssueRelationSerializer, + "issue_inbox" : InboxIssueLiteSerializer, + } + + self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False) return self.fields + + def to_representation(self, instance): + response = super().to_representation(instance) + + # Ensure 'expand' is iterable before processing + if self.expand: + for expand in self.expand: + if expand in self.fields: + # Import all the expandable serializers + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueRelationSerializer, + InboxIssueLiteSerializer + ) + + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueSerializer, + "issue_relation": IssueRelationSerializer, + "issue_inbox" : InboxIssueLiteSerializer, + } + # Check if field in expansion then expand the field + if expand in expansion: + if isinstance(response.get(expand), list): + exp_serializer = expansion[expand]( + getattr(instance, expand), many=True + ) + else: + exp_serializer = expansion[expand]( + getattr(instance, expand) + ) + response[expand] = exp_serializer.data + else: + # You might need to handle this case differently + response[expand] = getattr( + instance, f"{expand}_id", None + ) + + return response diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 63abf3a033f..77c3f16cc75 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -7,7 +7,12 @@ from .issue import IssueStateSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import Cycle, CycleIssue, CycleFavorite +from plane.db.models import ( + Cycle, + CycleIssue, + CycleFavorite, + CycleUserProperties, +) class CycleWriteSerializer(BaseSerializer): @@ -17,7 +22,9 @@ def validate(self, data): 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") + raise serializers.ValidationError( + "Start date cannot exceed end date" + ) return data class Meta: @@ -26,7 +33,6 @@ class Meta: class CycleSerializer(BaseSerializer): - owned_by = UserLiteSerializer(read_only=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) @@ -38,7 +44,9 @@ class CycleSerializer(BaseSerializer): 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") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) project_detail = ProjectLiteSerializer(read_only=True, source="project") status = serializers.CharField(read_only=True) @@ -48,7 +56,9 @@ def validate(self, data): 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") + raise serializers.ValidationError( + "Start date cannot exceed end date" + ) return data def get_assignees(self, obj): @@ -106,3 +116,14 @@ class Meta: "project", "user", ] + + +class CycleUserPropertiesSerializer(BaseSerializer): + class Meta: + model = CycleUserProperties + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "cycle" "user", + ] diff --git a/apiserver/plane/app/serializers/dashboard.py b/apiserver/plane/app/serializers/dashboard.py new file mode 100644 index 00000000000..8fca3c9064b --- /dev/null +++ b/apiserver/plane/app/serializers/dashboard.py @@ -0,0 +1,26 @@ +# Module imports +from .base import BaseSerializer +from plane.db.models import Dashboard, Widget + +# Third party frameworks +from rest_framework import serializers + + +class DashboardSerializer(BaseSerializer): + class Meta: + model = Dashboard + fields = "__all__" + + +class WidgetSerializer(BaseSerializer): + is_visible = serializers.BooleanField(read_only=True) + widget_filters = serializers.JSONField(read_only=True) + + class Meta: + model = Widget + fields = [ + "id", + "key", + "is_visible", + "widget_filters" + ] \ No newline at end of file diff --git a/apiserver/plane/app/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py index 2c2f26e4e2a..6753900803e 100644 --- a/apiserver/plane/app/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -2,12 +2,18 @@ from .base import BaseSerializer from plane.db.models import Estimate, EstimatePoint -from plane.app.serializers import WorkspaceLiteSerializer, ProjectLiteSerializer +from plane.app.serializers import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, +) from rest_framework import serializers + class EstimateSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: @@ -20,13 +26,14 @@ class Meta: class EstimatePointSerializer(BaseSerializer): - def validate(self, data): if not data: raise serializers.ValidationError("Estimate points are required") value = data.get("value") if value and len(value) > 20: - raise serializers.ValidationError("Value can't be more than 20 characters") + raise serializers.ValidationError( + "Value can't be more than 20 characters" + ) return data class Meta: @@ -41,7 +48,9 @@ class Meta: class EstimateReadSerializer(BaseSerializer): points = EstimatePointSerializer(read_only=True, many=True) - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: @@ -52,3 +61,18 @@ class Meta: "name", "description", ] + + +class WorkspaceEstimateSerializer(BaseSerializer): + points = EstimatePointSerializer(read_only=True, many=True) + + class Meta: + model = Estimate + fields = "__all__" + read_only_fields = [ + "points", + "name", + "description", + ] + + diff --git a/apiserver/plane/app/serializers/exporter.py b/apiserver/plane/app/serializers/exporter.py index 5c78cfa6945..2dd850fd32e 100644 --- a/apiserver/plane/app/serializers/exporter.py +++ b/apiserver/plane/app/serializers/exporter.py @@ -5,7 +5,9 @@ class ExporterHistorySerializer(BaseSerializer): - initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) + initiated_by_detail = UserLiteSerializer( + source="initiated_by", read_only=True + ) class Meta: model = ExporterHistory diff --git a/apiserver/plane/app/serializers/importer.py b/apiserver/plane/app/serializers/importer.py index 8997f639202..c058994d691 100644 --- a/apiserver/plane/app/serializers/importer.py +++ b/apiserver/plane/app/serializers/importer.py @@ -7,9 +7,13 @@ class ImporterSerializer(BaseSerializer): - initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True) + initiated_by_detail = UserLiteSerializer( + source="initiated_by", read_only=True + ) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = Importer diff --git a/apiserver/plane/app/serializers/inbox.py b/apiserver/plane/app/serializers/inbox.py index f52a90660be..1dc6f1f4ac6 100644 --- a/apiserver/plane/app/serializers/inbox.py +++ b/apiserver/plane/app/serializers/inbox.py @@ -46,10 +46,13 @@ class Meta: class IssueStateInboxSerializer(BaseSerializer): state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + 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) - bridge_id = serializers.UUIDField(read_only=True) issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) class Meta: diff --git a/apiserver/plane/app/serializers/integration/base.py b/apiserver/plane/app/serializers/integration/base.py index 6f6543b9ee6..01e484ed027 100644 --- a/apiserver/plane/app/serializers/integration/base.py +++ b/apiserver/plane/app/serializers/integration/base.py @@ -13,7 +13,9 @@ class Meta: class WorkspaceIntegrationSerializer(BaseSerializer): - integration_detail = IntegrationSerializer(read_only=True, source="integration") + integration_detail = IntegrationSerializer( + read_only=True, source="integration" + ) class Meta: model = WorkspaceIntegration diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index b13d03e35a4..be98bc312eb 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -30,6 +30,8 @@ CommentReaction, IssueVote, IssueRelation, + State, + Project, ) @@ -69,22 +71,29 @@ class Meta: ##TODO: Find a better way to write this serializer ## Find a better approach to save manytomany? class IssueCreateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - - assignees = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, + # ids + state_id = serializers.PrimaryKeyRelatedField( + source="state", + queryset=State.objects.all(), required=False, + allow_null=True, ) - - labels = serializers.ListField( + parent_id = serializers.PrimaryKeyRelatedField( + source="parent", + queryset=Issue.objects.all(), + required=False, + allow_null=True, + ) + label_ids = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) class Meta: model = Issue @@ -100,8 +109,10 @@ class Meta: def to_representation(self, instance): data = super().to_representation(instance) - data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] - data['labels'] = [str(label.id) for label in instance.labels.all()] + assignee_ids = self.initial_data.get("assignee_ids") + data["assignee_ids"] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get("label_ids") + data["label_ids"] = label_ids if label_ids else [] return data def validate(self, data): @@ -110,12 +121,14 @@ def validate(self, data): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) return data def create(self, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -173,8 +186,8 @@ def create(self, validated_data): return issue def update(self, instance, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) # Related models project_id = instance.project_id @@ -225,14 +238,15 @@ class IssueActivitySerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) class Meta: model = IssueActivity fields = "__all__" - class IssuePropertySerializer(BaseSerializer): class Meta: model = IssueProperty @@ -245,12 +259,17 @@ class Meta: class LabelSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - class Meta: model = Label - fields = "__all__" + fields = [ + "parent", + "name", + "color", + "id", + "project_id", + "workspace_id", + "sort_order", + ] read_only_fields = [ "workspace", "project", @@ -268,7 +287,6 @@ class Meta: class IssueLabelSerializer(BaseSerializer): - class Meta: model = IssueLabel fields = "__all__" @@ -279,33 +297,50 @@ class Meta: class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + id = serializers.UUIDField(source="related_issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField( + source="related_issue.project_id", read_only=True + ) + sequence_id = serializers.IntegerField( + source="related_issue.sequence_id", read_only=True + ) + name = serializers.CharField(source="related_issue.name", read_only=True) + relation_type = serializers.CharField(read_only=True) class Meta: model = IssueRelation fields = [ - "issue_detail", + "id", + "project_id", + "sequence_id", "relation_type", - "related_issue", - "issue", - "id" + "name", ] read_only_fields = [ "workspace", "project", ] + class RelatedIssueSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") + id = serializers.UUIDField(source="issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField( + source="issue.project_id", read_only=True + ) + sequence_id = serializers.IntegerField( + source="issue.sequence_id", read_only=True + ) + name = serializers.CharField(source="issue.name", read_only=True) + relation_type = serializers.CharField(read_only=True) class Meta: model = IssueRelation fields = [ - "issue_detail", + "id", + "project_id", + "sequence_id", "relation_type", - "related_issue", - "issue", - "id" + "name", ] read_only_fields = [ "workspace", @@ -400,7 +435,8 @@ class Meta: # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + url=validated_data.get("url"), + issue_id=validated_data.get("issue_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -424,9 +460,8 @@ class Meta: class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -438,19 +473,6 @@ class Meta: ] -class CommentReactionLiteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = CommentReaction - fields = [ - "id", - "reaction", - "comment", - "actor_detail", - ] - - class CommentReactionSerializer(BaseSerializer): class Meta: model = CommentReaction @@ -459,12 +481,18 @@ class Meta: class IssueVoteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + fields = [ + "issue", + "vote", + "workspace", + "project", + "actor", + "actor_detail", + ] read_only_fields = fields @@ -472,8 +500,12 @@ class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) + comment_reactions = CommentReactionSerializer( + read_only=True, many=True + ) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -507,12 +539,15 @@ class Meta: # Issue Serializer with state details class IssueStateSerializer(DynamicBaseSerializer): - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + label_details = LabelLiteSerializer( + read_only=True, source="labels", many=True + ) state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + assignee_details = UserLiteSerializer( + read_only=True, source="assignees", many=True + ) sub_issues_count = serializers.IntegerField(read_only=True) - bridge_id = serializers.UUIDField(read_only=True) attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) @@ -521,40 +556,80 @@ class Meta: fields = "__all__" -class IssueSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateSerializer(read_only=True, source="state") - parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") - label_details = LabelSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) - issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) - issue_cycle = IssueCycleDetailSerializer(read_only=True) - issue_module = IssueModuleDetailSerializer(read_only=True) - issue_link = IssueLinkSerializer(read_only=True, many=True) - issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) +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() + + # Many to many + label_ids = serializers.PrimaryKeyRelatedField( + read_only=True, many=True, source="labels" + ) + assignee_ids = serializers.PrimaryKeyRelatedField( + read_only=True, many=True, source="assignees" + ) + + # Count items sub_issues_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) + 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 = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", + fields = [ + "id", + "name", + "state_id", + "description_html", + "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_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 IssueLiteSerializer(DynamicBaseSerializer): - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + 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) + 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) @@ -581,7 +656,9 @@ class Meta: class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") - reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") + reactions = IssueReactionSerializer( + read_only=True, many=True, source="issue_reactions" + ) votes = IssueVoteSerializer(read_only=True, many=True) class Meta: @@ -604,7 +681,6 @@ class Meta: read_only_fields = fields - class IssueSubscriberSerializer(BaseSerializer): class Meta: model = IssueSubscriber diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 48f773b0f81..e9419567182 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -2,7 +2,7 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer from .project import ProjectLiteSerializer from .workspace import WorkspaceLiteSerializer @@ -14,6 +14,7 @@ ModuleIssue, ModuleLink, ModuleFavorite, + ModuleUserProperties, ) @@ -25,7 +26,9 @@ class ModuleWriteSerializer(BaseSerializer): ) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = Module @@ -38,16 +41,22 @@ class Meta: "created_at", "updated_at", ] - + def to_representation(self, instance): data = super().to_representation(instance) - data['members'] = [str(member.id) for member in instance.members.all()] + data["members"] = [str(member.id) for member in instance.members.all()] return data def validate(self, data): - if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None): - raise serializers.ValidationError("Start date cannot exceed target date") - return data + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) + return data def create(self, validated_data): members = validated_data.pop("members", None) @@ -151,7 +160,8 @@ class Meta: # Validation if url already exists def create(self, validated_data): if ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=validated_data.get("module_id") + url=validated_data.get("url"), + module_id=validated_data.get("module_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -159,10 +169,12 @@ def create(self, validated_data): return ModuleLink.objects.create(**validated_data) -class ModuleSerializer(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") + members_detail = UserLiteSerializer( + read_only=True, many=True, source="members" + ) link_module = ModuleLinkSerializer(read_only=True, many=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) @@ -196,3 +208,10 @@ class Meta: "project", "user", ] + + +class ModuleUserPropertiesSerializer(BaseSerializer): + class Meta: + model = ModuleUserProperties + fields = "__all__" + read_only_fields = ["workspace", "project", "module", "user"] diff --git a/apiserver/plane/app/serializers/notification.py b/apiserver/plane/app/serializers/notification.py index b6a4f3e4a0d..2152fcf0f9c 100644 --- a/apiserver/plane/app/serializers/notification.py +++ b/apiserver/plane/app/serializers/notification.py @@ -1,12 +1,21 @@ # Module imports from .base import BaseSerializer from .user import UserLiteSerializer -from plane.db.models import Notification +from plane.db.models import Notification, UserNotificationPreference + class NotificationSerializer(BaseSerializer): - triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") + triggered_by_details = UserLiteSerializer( + read_only=True, source="triggered_by" + ) class Meta: model = Notification fields = "__all__" + +class UserNotificationPreferenceSerializer(BaseSerializer): + + class Meta: + model = UserNotificationPreference + fields = "__all__" diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index ff152627a62..a0f5986d69f 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -6,19 +6,31 @@ from .issue import IssueFlatSerializer, LabelLiteSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer -from plane.db.models import Page, PageLog, PageFavorite, PageLabel, Label, Issue, Module +from plane.db.models import ( + Page, + PageLog, + PageFavorite, + PageLabel, + Label, + Issue, + Module, +) class PageSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + label_details = LabelLiteSerializer( + read_only=True, source="labels", many=True + ) labels = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = Page @@ -28,9 +40,10 @@ class Meta: "project", "owned_by", ] + def to_representation(self, instance): data = super().to_representation(instance) - data['labels'] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [str(label.id) for label in instance.labels.all()] return data def create(self, validated_data): @@ -94,7 +107,7 @@ class Meta: def get_entity_details(self, obj): entity_name = obj.entity_name - if entity_name == 'forward_link' or entity_name == 'back_link': + if entity_name == "forward_link" or entity_name == "back_link": try: page = Page.objects.get(pk=obj.entity_identifier) return PageSerializer(page).data @@ -104,7 +117,6 @@ def get_entity_details(self, obj): class PageLogSerializer(BaseSerializer): - class Meta: model = PageLog fields = "__all__" diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index aef715e33a8..999233442a4 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -4,7 +4,10 @@ # Module imports from .base import BaseSerializer, DynamicBaseSerializer from plane.app.serializers.workspace import WorkspaceLiteSerializer -from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer +from plane.app.serializers.user import ( + UserLiteSerializer, + UserAdminLiteSerializer, +) from plane.db.models import ( Project, ProjectMember, @@ -17,7 +20,9 @@ class ProjectSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = Project @@ -29,12 +34,16 @@ class Meta: def create(self, validated_data): identifier = validated_data.get("identifier", "").strip().upper() if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") + raise serializers.ValidationError( + detail="Project Identifier is required" + ) if ProjectIdentifier.objects.filter( name=identifier, workspace_id=self.context["workspace_id"] ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") + raise serializers.ValidationError( + detail="Project Identifier is taken" + ) project = Project.objects.create( **validated_data, workspace_id=self.context["workspace_id"] ) @@ -73,7 +82,9 @@ def update(self, instance, validated_data): return project # If not same fail update - raise serializers.ValidationError(detail="Project Identifier is already taken") + raise serializers.ValidationError( + detail="Project Identifier is already taken" + ) class ProjectLiteSerializer(BaseSerializer): @@ -160,6 +171,12 @@ class Meta: fields = "__all__" +class ProjectMemberRoleSerializer(DynamicBaseSerializer): + class Meta: + model = ProjectMember + fields = ("id", "role", "member", "project") + + class ProjectMemberInviteSerializer(BaseSerializer): project = ProjectLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True) @@ -197,7 +214,9 @@ class Meta: class ProjectDeployBoardSerializer(BaseSerializer): project_details = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) class Meta: model = ProjectDeployBoard @@ -217,4 +236,4 @@ class Meta: "workspace", "project", "member", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/app/serializers/state.py b/apiserver/plane/app/serializers/state.py index 323254f2696..773d8e461f6 100644 --- a/apiserver/plane/app/serializers/state.py +++ b/apiserver/plane/app/serializers/state.py @@ -6,10 +6,19 @@ class StateSerializer(BaseSerializer): - class Meta: model = State - fields = "__all__" + fields = [ + "id", + "project_id", + "workspace_id", + "name", + "color", + "group", + "default", + "description", + "sequence", + ] read_only_fields = [ "workspace", "project", @@ -25,4 +34,4 @@ class Meta: "color", "group", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 1b94758e82e..8cd48827e13 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -99,7 +99,9 @@ def get_workspace(self, obj): ).first() return { "last_workspace_id": obj.last_workspace_id, - "last_workspace_slug": workspace.slug if workspace is not None else "", + "last_workspace_slug": workspace.slug + if workspace is not None + else "", "fallback_workspace_id": obj.last_workspace_id, "fallback_workspace_slug": workspace.slug if workspace is not None @@ -109,7 +111,8 @@ def get_workspace(self, obj): else: fallback_workspace = ( Workspace.objects.filter( - workspace_member__member_id=obj.id, workspace_member__is_active=True + workspace_member__member_id=obj.id, + workspace_member__is_active=True, ) .order_by("created_at") .first() @@ -180,7 +183,9 @@ def validate(self, data): if data.get("new_password") != data.get("confirm_password"): raise serializers.ValidationError( - {"error": "Confirm password should be same as the new password."} + { + "error": "Confirm password should be same as the new password." + } ) return data @@ -190,4 +195,5 @@ class ResetPasswordSerializer(serializers.Serializer): """ Serializer for password change endpoint. """ + new_password = serializers.CharField(required=True, min_length=8) diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py index e7502609a72..f864f2b6c90 100644 --- a/apiserver/plane/app/serializers/view.py +++ b/apiserver/plane/app/serializers/view.py @@ -2,7 +2,7 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer from plane.db.models import GlobalView, IssueView, IssueViewFavorite @@ -10,7 +10,9 @@ class GlobalViewSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = GlobalView @@ -38,10 +40,12 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) -class IssueViewSerializer(BaseSerializer): +class IssueViewSerializer(DynamicBaseSerializer): is_favorite = serializers.BooleanField(read_only=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) class Meta: model = IssueView diff --git a/apiserver/plane/app/serializers/webhook.py b/apiserver/plane/app/serializers/webhook.py index 961466d285b..95ca149ffa4 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -10,78 +10,113 @@ # Module imports from .base import DynamicBaseSerializer from plane.db.models import Webhook, WebhookLog -from plane.db.models.webhook import validate_domain, validate_schema +from plane.db.models.webhook import validate_domain, validate_schema + class WebhookSerializer(DynamicBaseSerializer): url = serializers.URLField(validators=[validate_schema, validate_domain]) - + def create(self, validated_data): url = validated_data.get("url", None) # Extract the hostname from the URL hostname = urlparse(url).hostname if not hostname: - raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + raise serializers.ValidationError( + {"url": "Invalid URL: No hostname found."} + ) # Resolve the hostname to IP addresses try: ip_addresses = socket.getaddrinfo(hostname, None) except socket.gaierror: - raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + raise serializers.ValidationError( + {"url": "Hostname could not be resolved."} + ) if not ip_addresses: - raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + raise serializers.ValidationError( + {"url": "No IP addresses found for the hostname."} + ) for addr in ip_addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_private or ip.is_loopback: - raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + raise serializers.ValidationError( + {"url": "URL resolves to a blocked IP address."} + ) # Additional validation for multiple request domains and their subdomains - request = self.context.get('request') - disallowed_domains = ['plane.so',] # Add your disallowed domains here + request = self.context.get("request") + disallowed_domains = [ + "plane.so", + ] # Add your disallowed domains here if request: - request_host = request.get_host().split(':')[0] # Remove port if present + request_host = request.get_host().split(":")[ + 0 + ] # Remove port if present disallowed_domains.append(request_host) # Check if hostname is a subdomain or exact match of any disallowed domain - if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): - raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + if any( + hostname == domain or hostname.endswith("." + domain) + for domain in disallowed_domains + ): + raise serializers.ValidationError( + {"url": "URL domain or its subdomain is not allowed."} + ) return Webhook.objects.create(**validated_data) - + def update(self, instance, validated_data): url = validated_data.get("url", None) if url: # Extract the hostname from the URL hostname = urlparse(url).hostname if not hostname: - raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) + raise serializers.ValidationError( + {"url": "Invalid URL: No hostname found."} + ) # Resolve the hostname to IP addresses try: ip_addresses = socket.getaddrinfo(hostname, None) except socket.gaierror: - raise serializers.ValidationError({"url": "Hostname could not be resolved."}) + raise serializers.ValidationError( + {"url": "Hostname could not be resolved."} + ) if not ip_addresses: - raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) + raise serializers.ValidationError( + {"url": "No IP addresses found for the hostname."} + ) for addr in ip_addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_private or ip.is_loopback: - raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) + raise serializers.ValidationError( + {"url": "URL resolves to a blocked IP address."} + ) # Additional validation for multiple request domains and their subdomains - request = self.context.get('request') - disallowed_domains = ['plane.so',] # Add your disallowed domains here + request = self.context.get("request") + disallowed_domains = [ + "plane.so", + ] # Add your disallowed domains here if request: - request_host = request.get_host().split(':')[0] # Remove port if present + request_host = request.get_host().split(":")[ + 0 + ] # Remove port if present disallowed_domains.append(request_host) # Check if hostname is a subdomain or exact match of any disallowed domain - if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains): - raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) + if any( + hostname == domain or hostname.endswith("." + domain) + for domain in disallowed_domains + ): + raise serializers.ValidationError( + {"url": "URL domain or its subdomain is not allowed."} + ) return super().update(instance, validated_data) @@ -95,12 +130,7 @@ class Meta: class WebhookLogSerializer(DynamicBaseSerializer): - class Meta: model = WebhookLog fields = "__all__" - read_only_fields = [ - "workspace", - "webhook" - ] - + read_only_fields = ["workspace", "webhook"] diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index f0ad4b4ab65..69f827c2472 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -2,7 +2,7 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer +from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer from plane.db.models import ( @@ -13,10 +13,11 @@ TeamMember, WorkspaceMemberInvite, WorkspaceTheme, + WorkspaceUserProperties, ) -class WorkSpaceSerializer(BaseSerializer): +class WorkSpaceSerializer(DynamicBaseSerializer): owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) @@ -50,6 +51,7 @@ class Meta: "owner", ] + class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace @@ -61,8 +63,7 @@ class Meta: read_only_fields = fields - -class WorkSpaceMemberSerializer(BaseSerializer): +class WorkSpaceMemberSerializer(DynamicBaseSerializer): member = UserLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True) @@ -72,13 +73,12 @@ class Meta: class WorkspaceMemberMeSerializer(BaseSerializer): - class Meta: model = WorkspaceMember fields = "__all__" -class WorkspaceMemberAdminSerializer(BaseSerializer): +class WorkspaceMemberAdminSerializer(DynamicBaseSerializer): member = UserAdminLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True) @@ -108,7 +108,9 @@ class Meta: class TeamSerializer(BaseSerializer): - members_detail = UserLiteSerializer(read_only=True, source="members", many=True) + members_detail = UserLiteSerializer( + read_only=True, source="members", many=True + ) members = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, @@ -145,7 +147,9 @@ def update(self, instance, validated_data): members = validated_data.pop("members") TeamMember.objects.filter(team=instance).delete() team_members = [ - TeamMember(member=member, team=instance, workspace=instance.workspace) + TeamMember( + member=member, team=instance, workspace=instance.workspace + ) for member in members ] TeamMember.objects.bulk_create(team_members, batch_size=10) @@ -161,3 +165,13 @@ class Meta: "workspace", "actor", ] + + +class WorkspaceUserPropertiesSerializer(BaseSerializer): + class Meta: + model = WorkspaceUserProperties + fields = "__all__" + read_only_fields = [ + "workspace", + "user", + ] diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index d8334ed5705..f2b11f12761 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -3,6 +3,7 @@ from .authentication import urlpatterns as authentication_urls from .config import urlpatterns as configuration_urls from .cycle import urlpatterns as cycle_urls +from .dashboard import urlpatterns as dashboard_urls from .estimate import urlpatterns as estimate_urls from .external import urlpatterns as external_urls from .importer import urlpatterns as importer_urls @@ -28,6 +29,7 @@ *authentication_urls, *configuration_urls, *cycle_urls, + *dashboard_urls, *estimate_urls, *external_urls, *importer_urls, @@ -45,4 +47,4 @@ *workspace_urls, *api_urls, *webhook_urls, -] \ No newline at end of file +] diff --git a/apiserver/plane/app/urls/authentication.py b/apiserver/plane/app/urls/authentication.py index 39986f791e0..e91e5706bb9 100644 --- a/apiserver/plane/app/urls/authentication.py +++ b/apiserver/plane/app/urls/authentication.py @@ -31,8 +31,14 @@ path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), # magic sign in - path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"), - path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), + path( + "magic-generate/", + MagicGenerateEndpoint.as_view(), + name="magic-generate", + ), + path( + "magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in" + ), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), # Password Manipulation path( @@ -52,6 +58,8 @@ ), # API Tokens path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), - path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), + path( + "api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens" + ), ## End API Tokens ] diff --git a/apiserver/plane/app/urls/config.py b/apiserver/plane/app/urls/config.py index 12beb63aad6..3ea825eb29b 100644 --- a/apiserver/plane/app/urls/config.py +++ b/apiserver/plane/app/urls/config.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.app.views import ConfigurationEndpoint +from plane.app.views import ConfigurationEndpoint, MobileConfigurationEndpoint urlpatterns = [ path( @@ -9,4 +9,9 @@ ConfigurationEndpoint.as_view(), name="configuration", ), -] \ No newline at end of file + path( + "mobile-configs/", + MobileConfigurationEndpoint.as_view(), + name="configuration", + ), +] diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py index 46e6a5e847c..740b0ab4386 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -7,6 +7,7 @@ CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, ) @@ -44,7 +45,7 @@ name="project-issue-cycle", ), path( - "workspaces//projects//cycles//cycle-issues//", + "workspaces//projects//cycles//cycle-issues//", CycleIssueViewSet.as_view( { "get": "retrieve", @@ -84,4 +85,9 @@ TransferCycleIssueEndpoint.as_view(), name="transfer-issues", ), + path( + "workspaces//projects//cycles//user-properties/", + CycleUserPropertiesEndpoint.as_view(), + name="cycle-user-filters", + ), ] diff --git a/apiserver/plane/app/urls/dashboard.py b/apiserver/plane/app/urls/dashboard.py new file mode 100644 index 00000000000..0dc24a8081f --- /dev/null +++ b/apiserver/plane/app/urls/dashboard.py @@ -0,0 +1,23 @@ +from django.urls import path + + +from plane.app.views import DashboardEndpoint, WidgetsEndpoint + + +urlpatterns = [ + path( + "workspaces//dashboard/", + DashboardEndpoint.as_view(), + name="dashboard", + ), + path( + "workspaces//dashboard//", + DashboardEndpoint.as_view(), + name="dashboard", + ), + path( + "dashboard//widgets//", + WidgetsEndpoint.as_view(), + name="widgets", + ), +] diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py index 16ea40b21a9..e9ec4e335a1 100644 --- a/apiserver/plane/app/urls/inbox.py +++ b/apiserver/plane/app/urls/inbox.py @@ -40,7 +40,7 @@ name="inbox-issue", ), path( - "workspaces//projects//inboxes//inbox-issues//", + "workspaces//projects//inboxes//inbox-issues//", InboxIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 971fbc395df..234c2824dd7 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -235,7 +235,7 @@ ## End Comment Reactions ## IssueProperty path( - "workspaces//projects//issue-display-properties/", + "workspaces//projects//user-properties/", IssueUserDisplayPropertyEndpoint.as_view(), name="project-issue-display-properties", ), @@ -275,16 +275,17 @@ "workspaces//projects//issues//issue-relation/", IssueRelationViewSet.as_view( { + "get": "list", "post": "create", } ), name="issue-relation", ), path( - "workspaces//projects//issues//issue-relation//", + "workspaces//projects//issues//remove-relation/", IssueRelationViewSet.as_view( { - "delete": "destroy", + "post": "remove_relation", } ), name="issue-relation", diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py index 5507b3a379d..5e9f4f1230c 100644 --- a/apiserver/plane/app/urls/module.py +++ b/apiserver/plane/app/urls/module.py @@ -7,6 +7,7 @@ ModuleLinkViewSet, ModuleFavoriteViewSet, BulkImportModulesEndpoint, + ModuleUserPropertiesEndpoint, ) @@ -34,17 +35,26 @@ name="project-modules", ), path( - "workspaces//projects//modules//module-issues/", + "workspaces//projects//issues//modules/", ModuleIssueViewSet.as_view( { + "post": "create_issue_modules", + } + ), + name="issue-module", + ), + path( + "workspaces//projects//modules//issues/", + ModuleIssueViewSet.as_view( + { + "post": "create_module_issues", "get": "list", - "post": "create", } ), name="project-module-issues", ), path( - "workspaces//projects//modules//module-issues//", + "workspaces//projects//modules//issues//", ModuleIssueViewSet.as_view( { "get": "retrieve", @@ -101,4 +111,9 @@ BulkImportModulesEndpoint.as_view(), name="bulk-modules-create", ), + path( + "workspaces//projects//modules//user-properties/", + ModuleUserPropertiesEndpoint.as_view(), + name="cycle-user-filters", + ), ] diff --git a/apiserver/plane/app/urls/notification.py b/apiserver/plane/app/urls/notification.py index 0c96e5f15bd..0bbf4f3c796 100644 --- a/apiserver/plane/app/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -5,6 +5,7 @@ NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) @@ -63,4 +64,9 @@ ), name="mark-all-read-notifications", ), + path( + "users/me/notification-preferences/", + UserNotificationPreferenceEndpoint.as_view(), + name="user-notification-preferences", + ), ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 39456a83050..f8ecac4c068 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -175,4 +175,4 @@ ), name="project-deploy-board", ), -] \ No newline at end of file +] diff --git a/apiserver/plane/app/urls/views.py b/apiserver/plane/app/urls/views.py index 3d45b627a60..36372c03ad2 100644 --- a/apiserver/plane/app/urls/views.py +++ b/apiserver/plane/app/urls/views.py @@ -5,7 +5,7 @@ IssueViewViewSet, GlobalViewViewSet, GlobalViewIssuesViewSet, - IssueViewFavoriteViewSet, + IssueViewFavoriteViewSet, ) diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 2c3638842c9..7e64e586aaf 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -18,6 +18,10 @@ WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceStatesEndpoint, + WorkspaceEstimatesEndpoint, ) @@ -92,6 +96,11 @@ WorkSpaceMemberViewSet.as_view({"get": "list"}), name="workspace-member", ), + path( + "workspaces//project-members/", + WorkspaceProjectMemberEndpoint.as_view(), + name="workspace-member-roles", + ), path( "workspaces//members//", WorkSpaceMemberViewSet.as_view( @@ -195,4 +204,19 @@ WorkspaceLabelsEndpoint.as_view(), name="workspace-labels", ), + path( + "workspaces//user-properties/", + WorkspaceUserPropertiesEndpoint.as_view(), + name="workspace-user-filters", + ), + path( + "workspaces//states/", + WorkspaceStatesEndpoint.as_view(), + name="workspace-state", + ), + path( + "workspaces//estimates/", + WorkspaceEstimatesEndpoint.as_view(), + name="workspace-estimate", + ), ] diff --git a/apiserver/plane/app/urls_deprecated.py b/apiserver/plane/app/urls_deprecated.py index c6e6183fa6a..2a47285aa21 100644 --- a/apiserver/plane/app/urls_deprecated.py +++ b/apiserver/plane/app/urls_deprecated.py @@ -192,7 +192,7 @@ ) -#TODO: Delete this file +# TODO: Delete this file # This url file has been deprecated use apiserver/plane/urls folder to create new urls urlpatterns = [ @@ -204,10 +204,14 @@ path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), # Magic Sign In/Up path( - "magic-generate/", MagicSignInGenerateEndpoint.as_view(), name="magic-generate" + "magic-generate/", + MagicSignInGenerateEndpoint.as_view(), + name="magic-generate", ), - path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"), - path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path( + "magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in" + ), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), # Email verification path("email-verify/", VerifyEmailEndpoint.as_view(), name="email-verify"), path( @@ -272,7 +276,9 @@ # user workspace invitations path( "users/me/invitations/workspaces/", - UserWorkspaceInvitationsEndpoint.as_view({"get": "list", "post": "create"}), + UserWorkspaceInvitationsEndpoint.as_view( + {"get": "list", "post": "create"} + ), name="user-workspace-invitations", ), # user workspace invitation @@ -311,7 +317,9 @@ # user project invitations path( "users/me/invitations/projects/", - UserProjectInvitationsViewset.as_view({"get": "list", "post": "create"}), + UserProjectInvitationsViewset.as_view( + {"get": "list", "post": "create"} + ), name="user-project-invitaions", ), ## Workspaces ## @@ -1238,7 +1246,7 @@ "post": "unarchive", } ), - name="project-page-unarchive" + name="project-page-unarchive", ), path( "workspaces//projects//archived-pages/", @@ -1264,19 +1272,22 @@ { "post": "unlock", } - ) + ), ), path( "workspaces//projects//pages//transactions/", - PageLogEndpoint.as_view(), name="page-transactions" + PageLogEndpoint.as_view(), + name="page-transactions", ), path( "workspaces//projects//pages//transactions//", - PageLogEndpoint.as_view(), name="page-transactions" + PageLogEndpoint.as_view(), + name="page-transactions", ), path( "workspaces//projects//pages//sub-pages/", - SubPagesEndpoint.as_view(), name="sub-page" + SubPagesEndpoint.as_view(), + name="sub-page", ), path( "workspaces//projects//estimates/", @@ -1326,7 +1337,9 @@ ## End Pages # API Tokens path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), - path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), + path( + "api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens" + ), ## End API Tokens # Integrations path( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index c122dce9f8f..0a959a667b7 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -45,6 +45,10 @@ WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceStatesEndpoint, + WorkspaceEstimatesEndpoint, ) from .state import StateViewSet from .view import ( @@ -59,6 +63,7 @@ CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, ) from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue import ( @@ -103,6 +108,7 @@ ModuleIssueViewSet, ModuleLinkViewSet, ModuleFavoriteViewSet, + ModuleUserPropertiesEndpoint, ) from .api import ApiTokenEndpoint @@ -136,7 +142,11 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint -from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint +from .external import ( + GPTIntegrationEndpoint, + ReleaseNotesEndpoint, + UnsplashEndpoint, +) from .estimate import ( ProjectEstimatePointEndpoint, @@ -157,14 +167,20 @@ NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) from .exporter import ExportIssuesEndpoint -from .config import ConfigurationEndpoint +from .config import ConfigurationEndpoint, MobileConfigurationEndpoint from .webhook import ( WebhookEndpoint, WebhookLogsEndpoint, WebhookSecretRegenerateEndpoint, ) + +from .dashboard import ( + DashboardEndpoint, + WidgetsEndpoint +) \ No newline at end of file diff --git a/apiserver/plane/app/views/analytic.py b/apiserver/plane/app/views/analytic.py index c1deb0d8f66..04a77f789e3 100644 --- a/apiserver/plane/app/views/analytic.py +++ b/apiserver/plane/app/views/analytic.py @@ -61,7 +61,9 @@ def get(self, request, slug): ) # If segment is present it cannot be same as x-axis - if segment and (segment not in valid_xaxis_segment or x_axis == segment): + if segment and ( + segment not in valid_xaxis_segment or x_axis == segment + ): return Response( { "error": "Both segment and x axis cannot be same and segment should be valid" @@ -110,7 +112,9 @@ def get(self, request, slug): if x_axis in ["assignees__id"] or segment in ["assignees__id"]: assignee_details = ( Issue.issue_objects.filter( - workspace__slug=slug, **filters, assignees__avatar__isnull=False + workspace__slug=slug, + **filters, + assignees__avatar__isnull=False, ) .order_by("assignees__id") .distinct("assignees__id") @@ -124,7 +128,9 @@ def get(self, request, slug): ) cycle_details = {} - if x_axis in ["issue_cycle__cycle_id"] or segment in ["issue_cycle__cycle_id"]: + if x_axis in ["issue_cycle__cycle_id"] or segment in [ + "issue_cycle__cycle_id" + ]: cycle_details = ( Issue.issue_objects.filter( workspace__slug=slug, @@ -186,7 +192,9 @@ def perform_create(self, serializer): def get_queryset(self): return self.filter_queryset( - super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) ) @@ -196,7 +204,9 @@ class SavedAnalyticEndpoint(BaseAPIView): ] def get(self, request, slug, analytic_id): - analytic_view = AnalyticView.objects.get(pk=analytic_id, workspace__slug=slug) + analytic_view = AnalyticView.objects.get( + pk=analytic_id, workspace__slug=slug + ) filter = analytic_view.query queryset = Issue.issue_objects.filter(**filter) @@ -266,7 +276,9 @@ def post(self, request, slug): ) # If segment is present it cannot be same as x-axis - if segment and (segment not in valid_xaxis_segment or x_axis == segment): + if segment and ( + segment not in valid_xaxis_segment or x_axis == segment + ): return Response( { "error": "Both segment and x axis cannot be same and segment should be valid" @@ -293,7 +305,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): def get(self, request, slug): filters = issue_filters(request.GET, "GET") - base_issues = Issue.issue_objects.filter(workspace__slug=slug, **filters) + base_issues = Issue.issue_objects.filter( + workspace__slug=slug, **filters + ) total_issues = base_issues.count() @@ -306,7 +320,9 @@ def get(self, request, slug): ) open_issues_groups = ["backlog", "unstarted", "started"] - open_issues_queryset = state_groups.filter(state__group__in=open_issues_groups) + open_issues_queryset = state_groups.filter( + state__group__in=open_issues_groups + ) open_issues = open_issues_queryset.count() open_issues_classified = ( @@ -361,10 +377,12 @@ def get(self, request, slug): .order_by("-count") ) - open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("estimate_point"))[ + open_estimate_sum = open_issues_queryset.aggregate( + sum=Sum("estimate_point") + )["sum"] + total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[ "sum" ] - total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))["sum"] return Response( { diff --git a/apiserver/plane/app/views/api.py b/apiserver/plane/app/views/api.py index ce2d4bd09d7..86a29c7fa50 100644 --- a/apiserver/plane/app/views/api.py +++ b/apiserver/plane/app/views/api.py @@ -71,7 +71,9 @@ def patch(self, request, slug, pk): user=request.user, pk=pk, ) - serializer = APITokenSerializer(api_token, data=request.data, partial=True) + serializer = APITokenSerializer( + api_token, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/asset.py b/apiserver/plane/app/views/asset.py index 17d70d93664..fb559061011 100644 --- a/apiserver/plane/app/views/asset.py +++ b/apiserver/plane/app/views/asset.py @@ -10,7 +10,11 @@ class FileAssetEndpoint(BaseAPIView): - parser_classes = (MultiPartParser, FormParser, JSONParser,) + parser_classes = ( + MultiPartParser, + FormParser, + JSONParser, + ) """ A viewset for viewing and editing task instances. @@ -20,10 +24,18 @@ def get(self, request, workspace_id, asset_key): asset_key = str(workspace_id) + "/" + asset_key files = FileAsset.objects.filter(asset=asset_key) if files.exists(): - serializer = FileAssetSerializer(files, context={"request": request}, many=True) - return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + serializer = FileAssetSerializer( + files, context={"request": request}, many=True + ) + return Response( + {"data": serializer.data, "status": True}, + status=status.HTTP_200_OK, + ) else: - return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) + return Response( + {"error": "Asset key does not exist", "status": False}, + status=status.HTTP_200_OK, + ) def post(self, request, slug): serializer = FileAssetSerializer(data=request.data) @@ -33,7 +45,7 @@ def post(self, request, slug): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + def delete(self, request, workspace_id, asset_key): asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) @@ -43,7 +55,6 @@ def delete(self, request, workspace_id, asset_key): class FileAssetViewSet(BaseViewSet): - def restore(self, request, workspace_id, asset_key): asset_key = str(workspace_id) + "/" + asset_key file_asset = FileAsset.objects.get(asset=asset_key) @@ -56,12 +67,22 @@ class UserAssetsEndpoint(BaseAPIView): parser_classes = (MultiPartParser, FormParser) def get(self, request, asset_key): - files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) + files = FileAsset.objects.filter( + asset=asset_key, created_by=request.user + ) if files.exists(): - serializer = FileAssetSerializer(files, context={"request": request}) - return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) + serializer = FileAssetSerializer( + files, context={"request": request} + ) + return Response( + {"data": serializer.data, "status": True}, + status=status.HTTP_200_OK, + ) else: - return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK) + return Response( + {"error": "Asset key does not exist", "status": False}, + status=status.HTTP_200_OK, + ) def post(self, request): serializer = FileAssetSerializer(data=request.data) @@ -70,9 +91,10 @@ def post(self, request): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, asset_key): - file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user) + file_asset = FileAsset.objects.get( + asset=asset_key, created_by=request.user + ) file_asset.is_deleted = True file_asset.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 049e5aab919..501f4765788 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -128,7 +128,8 @@ def post(self, request): status=status.HTTP_200_OK, ) return Response( - {"error": "Please check the email"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Please check the email"}, + status=status.HTTP_400_BAD_REQUEST, ) @@ -167,7 +168,9 @@ def post(self, request, uidb64, token): } return Response(data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except DjangoUnicodeDecodeError as indentifier: return Response( @@ -191,7 +194,8 @@ def post(self, request): user.is_password_autoset = False user.save() return Response( - {"message": "Password updated successfully"}, status=status.HTTP_200_OK + {"message": "Password updated successfully"}, + status=status.HTTP_200_OK, ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -213,7 +217,8 @@ def post(self, request): # Check password validation if not password and len(str(password)) < 8: return Response( - {"error": "Password is not valid"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Password is not valid"}, + status=status.HTTP_400_BAD_REQUEST, ) # Set the user password @@ -281,7 +286,9 @@ def post(self, request): if data["current_attempt"] > 2: return Response( - {"error": "Max attempts exhausted. Please try again later."}, + { + "error": "Max attempts exhausted. Please try again later." + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -339,7 +346,8 @@ def post(self, request): if not email: return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Email is required"}, + status=status.HTTP_400_BAD_REQUEST, ) # validate the email @@ -347,7 +355,8 @@ def post(self, request): validate_email(email) except ValidationError: return Response( - {"error": "Email is not valid"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Email is not valid"}, + status=status.HTTP_400_BAD_REQUEST, ) # Check if the user exists @@ -399,13 +408,18 @@ def post(self, request): key, token, current_attempt = generate_magic_token(email=email) if not current_attempt: return Response( - {"error": "Max attempts exhausted. Please try again later."}, + { + "error": "Max attempts exhausted. Please try again later." + }, status=status.HTTP_400_BAD_REQUEST, ) # Trigger the email magic_link.delay(email, "magic_" + str(email), token, current_site) return Response( - {"is_password_autoset": user.is_password_autoset, "is_existing": False}, + { + "is_password_autoset": user.is_password_autoset, + "is_existing": False, + }, status=status.HTTP_200_OK, ) @@ -433,7 +447,9 @@ def post(self, request): key, token, current_attempt = generate_magic_token(email=email) if not current_attempt: return Response( - {"error": "Max attempts exhausted. Please try again later."}, + { + "error": "Max attempts exhausted. Please try again later." + }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 2564463132b..a41200d61a0 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -73,7 +73,7 @@ def post(self, request): # get configuration values # Get configuration values - ENABLE_SIGNUP, = get_configuration_value( + (ENABLE_SIGNUP,) = get_configuration_value( [ { "key": "ENABLE_SIGNUP", @@ -173,7 +173,7 @@ def post(self, request): # Create the user else: - ENABLE_SIGNUP, = get_configuration_value( + (ENABLE_SIGNUP,) = get_configuration_value( [ { "key": "ENABLE_SIGNUP", @@ -325,7 +325,7 @@ def post(self, request): ) user_token = request.data.get("token", "").strip() - key = request.data.get("key", False).strip().lower() + key = request.data.get("key", "").strip().lower() if not key or user_token == "": return Response( @@ -364,8 +364,10 @@ def post(self, request): user.save() # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True + workspace_member_invites = ( + WorkspaceMemberInvite.objects.filter( + email=user.email, accepted=True + ) ) WorkspaceMember.objects.bulk_create( @@ -431,7 +433,9 @@ def post(self, request): else: return Response( - {"error": "Your login code was incorrect. Please try again."}, + { + "error": "Your login code was incorrect. Please try again." + }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 32449597bd7..e07cb811cc8 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -46,7 +46,9 @@ class WebhookMixin: bulk = False def finalize_response(self, request, response, *args, **kwargs): - response = super().finalize_response(request, response, *args, **kwargs) + response = super().finalize_response( + request, response, *args, **kwargs + ) # Check for the case should webhook be sent if ( @@ -88,7 +90,9 @@ def get_queryset(self): return self.model.objects.all() except Exception as e: capture_exception(e) - raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + raise APIException( + "Please check the view", status.HTTP_400_BAD_REQUEST + ) def handle_exception(self, exc): """ @@ -99,6 +103,7 @@ def handle_exception(self, exc): response = super().handle_exception(exc) return response except Exception as e: + print(e) if settings.DEBUG else print("Server Error") if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, @@ -112,23 +117,23 @@ def handle_exception(self, exc): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): capture_exception(e) return Response( - {"error": f"key {e} does not exist"}, + {"error": f"The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - - print(e) if settings.DEBUG else print("Server Error") - capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) def dispatch(self, request, *args, **kwargs): try: @@ -159,6 +164,24 @@ def project_id(self): if resolve(self.request.path_info).url_name == "project": return self.kwargs.get("pk", None) + @property + def fields(self): + fields = [ + field + for field in self.request.GET.get("fields", "").split(",") + if field + ] + return fields if fields else None + + @property + def expand(self): + expand = [ + expand + for expand in self.request.GET.get("expand", "").split(",") + if expand + ] + return expand if expand else None + class BaseAPIView(TimezoneMixin, APIView, BasePaginator): permission_classes = [ @@ -201,20 +224,24 @@ def handle_exception(self, exc): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) - + if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": f"The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) if settings.DEBUG: print(e) capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) def dispatch(self, request, *args, **kwargs): try: @@ -239,3 +266,21 @@ def workspace_slug(self): @property def project_id(self): return self.kwargs.get("project_id", None) + + @property + def fields(self): + fields = [ + field + for field in self.request.GET.get("fields", "").split(",") + if field + ] + return fields if fields else None + + @property + def expand(self): + expand = [ + expand + for expand in self.request.GET.get("expand", "").split(",") + if expand + ] + return expand if expand else None diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index c53b304956c..29b4bbf8bb5 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -20,7 +20,6 @@ class ConfigurationEndpoint(BaseAPIView): ] def get(self, request): - # Get all the configuration ( GOOGLE_CLIENT_ID, @@ -90,8 +89,16 @@ def get(self, request): data = {} # Authentication - data["google_client_id"] = GOOGLE_CLIENT_ID if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != "\"\"" else None - data["github_client_id"] = GITHUB_CLIENT_ID if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != "\"\"" else None + data["google_client_id"] = ( + GOOGLE_CLIENT_ID + if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""' + else None + ) + data["github_client_id"] = ( + GITHUB_CLIENT_ID + if GITHUB_CLIENT_ID and GITHUB_CLIENT_ID != '""' + else None + ) data["github_app_name"] = GITHUB_APP_NAME data["magic_login"] = ( bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) @@ -112,9 +119,129 @@ def get(self, request): data["has_openai_configured"] = bool(OPENAI_API_KEY) # File size settings - data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + data["file_size_limit"] = float( + os.environ.get("FILE_SIZE_LIMIT", 5242880) + ) + + # is smtp configured + data["is_smtp_configured"] = bool(EMAIL_HOST_USER) and bool( + EMAIL_HOST_PASSWORD + ) + + return Response(data, status=status.HTTP_200_OK) + + +class MobileConfigurationEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + ( + GOOGLE_CLIENT_ID, + GOOGLE_SERVER_CLIENT_ID, + GOOGLE_IOS_CLIENT_ID, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + OPENAI_API_KEY, + ) = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID", None), + }, + { + "key": "GOOGLE_SERVER_CLIENT_ID", + "default": os.environ.get("GOOGLE_SERVER_CLIENT_ID", None), + }, + { + "key": "GOOGLE_IOS_CLIENT_ID", + "default": os.environ.get("GOOGLE_IOS_CLIENT_ID", None), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER", None), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD", None), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + }, + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", "1"), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", "1"), + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), + }, + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", "1"), + }, + ] + ) + data = {} + # Authentication + data["google_client_id"] = ( + GOOGLE_CLIENT_ID + if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_ID != '""' + else None + ) + data["google_server_client_id"] = ( + GOOGLE_SERVER_CLIENT_ID + if GOOGLE_SERVER_CLIENT_ID and GOOGLE_SERVER_CLIENT_ID != '""' + else None + ) + data["google_ios_client_id"] = ( + (GOOGLE_IOS_CLIENT_ID)[::-1] + if GOOGLE_IOS_CLIENT_ID is not None + else None + ) + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + data["magic_login"] = ( + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) and ENABLE_MAGIC_LINK_LOGIN == "1" + + data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" + + # Posthog + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST + + # Unsplash + data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) + + # Open AI settings + data["has_openai_configured"] = bool(OPENAI_API_KEY) + + # File size settings + data["file_size_limit"] = float( + os.environ.get("FILE_SIZE_LIMIT", 5242880) + ) - # is self managed - data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) + # is smtp configured + data["is_smtp_configured"] = not ( + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 02f259de31d..23a227fefb7 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -14,7 +14,7 @@ Case, When, Value, - CharField + CharField, ) from django.core import serializers from django.utils import timezone @@ -31,10 +31,15 @@ CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, + IssueSerializer, IssueStateSerializer, CycleWriteSerializer, + CycleUserPropertiesSerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, ) -from plane.app.permissions import ProjectEntityPermission from plane.db.models import ( User, Cycle, @@ -44,9 +49,10 @@ IssueLink, IssueAttachment, Label, + CycleUserProperties, + IssueSubscriber, ) 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 @@ -61,7 +67,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): def perform_create(self, serializer): serializer.save( - project_id=self.kwargs.get("project_id"), owned_by=self.request.user + project_id=self.kwargs.get("project_id"), + owned_by=self.request.user, ) def get_queryset(self): @@ -140,7 +147,9 @@ def get_queryset(self): ), ) ) - .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) .annotate( completed_estimates=Sum( "issue_cycle__issue__estimate_point", @@ -164,35 +173,36 @@ def get_queryset(self): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), - then=Value("CURRENT") - ), - When( - start_date__gt=timezone.now(), - then=Value("UPCOMING") + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), ), When( - end_date__lt=timezone.now(), - then=Value("COMPLETED") + start_date__gt=timezone.now(), then=Value("UPCOMING") ), + When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), - then=Value("DRAFT") + then=Value("DRAFT"), ), - default=Value("DRAFT"), - output_field=CharField(), + default=Value("DRAFT"), + output_field=CharField(), ) ) .prefetch_related( Prefetch( "issue_cycle__issue__assignees", - queryset=User.objects.only("avatar", "first_name", "id").distinct(), + queryset=User.objects.only( + "avatar", "first_name", "id" + ).distinct(), ) ) .prefetch_related( Prefetch( "issue_cycle__issue__labels", - queryset=Label.objects.only("name", "color", "id").distinct(), + queryset=Label.objects.only( + "name", "color", "id" + ).distinct(), ) ) .order_by("-is_favorite", "name") @@ -202,6 +212,11 @@ def get_queryset(self): def list(self, request, slug, project_id): queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] queryset = queryset.order_by("-is_favorite", "-created_at") @@ -298,7 +313,9 @@ def list(self, request, slug, project_id): "completion_chart": {}, } if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"]["completion_chart"] = burndown_plot( + data[0]["distribution"][ + "completion_chart" + ] = burndown_plot( queryset=queryset.first(), slug=slug, project_id=project_id, @@ -307,44 +324,8 @@ def list(self, request, slug, project_id): return Response(data, status=status.HTTP_200_OK) - # Upcoming Cycles - if cycle_view == "upcoming": - queryset = queryset.filter(start_date__gt=timezone.now()) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Completed Cycles - if cycle_view == "completed": - queryset = queryset.filter(end_date__lt=timezone.now()) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Draft Cycles - if cycle_view == "draft": - queryset = queryset.filter( - end_date=None, - start_date=None, - ) - - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # Incomplete Cycles - if cycle_view == "incomplete": - queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) | Q(end_date__isnull=True), - ) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) - - # If no matching view is found return all cycles - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) + cycles = CycleSerializer(queryset, many=True).data + return Response(cycles, status=status.HTTP_200_OK) def create(self, request, slug, project_id): if ( @@ -360,8 +341,18 @@ def create(self, request, slug, project_id): project_id=project_id, owned_by=request.user, ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + cycle = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) + serializer = CycleSerializer(cycle) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) else: return Response( { @@ -371,15 +362,22 @@ def create(self, request, slug, project_id): ) def partial_update(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) request_data = request.data - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + if ( + cycle.end_date is not None + and cycle.end_date < timezone.now().date() + ): if "sort_order" in request_data: # Can only change sort order request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) + "sort_order": request_data.get( + "sort_order", cycle.sort_order + ) } else: return Response( @@ -389,7 +387,9 @@ def partial_update(self, request, slug, project_id, pk): status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) + serializer = CycleWriteSerializer( + cycle, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @@ -410,7 +410,13 @@ def retrieve(self, request, slug, project_id, pk): .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) .annotate(display_name=F("assignees__display_name")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) .annotate( total_issues=Count( "assignee_id", @@ -489,7 +495,10 @@ def retrieve(self, request, slug, project_id, pk): if queryset.start_date and queryset.end_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk + queryset=queryset, + slug=slug, + project_id=project_id, + cycle_id=pk, ) return Response( @@ -499,11 +508,13 @@ def retrieve(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk): cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) + CycleIssue.objects.filter( + cycle_id=self.kwargs.get("pk") + ).values_list("issue", flat=True) + ) + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk ) - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) issue_activity.delay( type="cycle.activity.deleted", @@ -519,6 +530,8 @@ def destroy(self, request, slug, project_id, pk): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) # Delete the cycle cycle.delete() @@ -546,7 +559,9 @@ def get_queryset(self): super() .get_queryset() .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("issue_id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -565,28 +580,30 @@ def get_queryset(self): @method_decorator(gzip_page) def list(self, request, slug, project_id, cycle_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] order_by = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate(bridge_id=F("issue_cycle__id")) .filter(project_id=project_id) .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .order_by(order_by) .filter(**filters) + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -594,32 +611,43 @@ def list(self, request, slug, project_id, cycle_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + subscriber=self.request.user, issue_id=OuterRef("id") + ) + ) + ) ) - - issues = IssueStateSerializer( + serializer = IssueSerializer( issues, many=True, fields=fields if fields else None - ).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) + ) + return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) if not len(issues): return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, ) cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=cycle_id ) - if cycle.end_date is not None and cycle.end_date < timezone.now().date(): + if ( + cycle.end_date is not None + and cycle.end_date < timezone.now().date() + ): return Response( { "error": "The Cycle has already been completed so no new issues can be added" @@ -690,19 +718,27 @@ def create(self, request, slug, project_id, cycle_id): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) # Return all Cycle Issues + issues = self.get_queryset().values_list("issue_id", flat=True) + return Response( - CycleIssueSerializer(self.get_queryset(), many=True).data, + IssueSerializer( + Issue.objects.filter(pk__in=issues), many=True + ).data, status=status.HTTP_200_OK, ) - def destroy(self, request, slug, project_id, cycle_id, pk): + def destroy(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, ) - issue_id = cycle_issue.issue_id issue_activity.delay( type="cycle.activity.deleted", requested_data=json.dumps( @@ -712,10 +748,12 @@ def destroy(self, request, slug, project_id, cycle_id, pk): } ), actor_id=str(self.request.user.id), - issue_id=str(cycle_issue.issue_id), + issue_id=str(issue_id), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) cycle_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -834,3 +872,41 @@ def post(self, request, slug, project_id, cycle_id): ) return Response({"message": "Success"}, status=status.HTTP_200_OK) + + +class CycleUserPropertiesEndpoint(BaseAPIView): + permission_classes = [ + ProjectLitePermission, + ] + + def patch(self, request, slug, project_id, cycle_id): + cycle_properties = CycleUserProperties.objects.get( + user=request.user, + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + ) + + cycle_properties.filters = request.data.get( + "filters", cycle_properties.filters + ) + cycle_properties.display_filters = request.data.get( + "display_filters", cycle_properties.display_filters + ) + cycle_properties.display_properties = request.data.get( + "display_properties", cycle_properties.display_properties + ) + cycle_properties.save() + + serializer = CycleUserPropertiesSerializer(cycle_properties) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, slug, project_id, cycle_id): + cycle_properties, _ = CycleUserProperties.objects.get_or_create( + user=request.user, + project_id=project_id, + cycle_id=cycle_id, + workspace__slug=slug, + ) + serializer = CycleUserPropertiesSerializer(cycle_properties) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py new file mode 100644 index 00000000000..47fae2c9ca1 --- /dev/null +++ b/apiserver/plane/app/views/dashboard.py @@ -0,0 +1,656 @@ +# Django imports +from django.db.models import ( + Q, + Case, + When, + Value, + CharField, + Count, + F, + Exists, + OuterRef, + Max, + Subquery, + JSONField, + Func, + Prefetch, +) +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from . import BaseAPIView +from plane.db.models import ( + Issue, + IssueActivity, + ProjectMember, + Widget, + DashboardWidget, + Dashboard, + Project, + IssueLink, + IssueAttachment, + IssueRelation, +) +from plane.app.serializers import ( + IssueActivitySerializer, + IssueSerializer, + DashboardSerializer, + WidgetSerializer, +) +from plane.utils.issue_filters import issue_filters + + +def dashboard_overview_stats(self, request, slug): + assigned_issues = Issue.issue_objects.filter( + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + pending_issues_count = Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ).count() + + created_issues_count = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + created_by_id=request.user.id, + ).count() + + completed_issues_count = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + state__group="completed", + ).count() + + return Response( + { + "assigned_issues_count": assigned_issues, + "pending_issues_count": pending_issues_count, + "completed_issues_count": completed_issues_count, + "created_issues_count": created_issues_count, + }, + status=status.HTTP_200_OK, + ) + + +def dashboard_assigned_issues(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issue_type = request.GET.get("issue_type", None) + + # get all the assigned issues + assigned_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + assignees__in=[request.user], + ) + .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related( + "related_issue" + ).select_related("issue"), + ) + ) + .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") + ) + .order_by("created_at") + ) + + # Priority Ordering + priority_order = ["urgent", "high", "medium", "low", "none"] + assigned_issues = assigned_issues.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + if issue_type == "completed": + completed_issues_count = assigned_issues.filter( + state__group__in=["completed"] + ).count() + completed_issues = assigned_issues.filter( + state__group__in=["completed"] + )[:5] + return Response( + { + "issues": IssueSerializer( + completed_issues, many=True, expand=self.expand + ).data, + "count": completed_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "overdue": + overdue_issues_count = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + ).count() + overdue_issues = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer( + overdue_issues, many=True, expand=self.expand + ).data, + "count": overdue_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "upcoming": + upcoming_issues_count = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + ).count() + upcoming_issues = assigned_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer( + upcoming_issues, many=True, expand=self.expand + ).data, + "count": upcoming_issues_count, + }, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "Please specify a valid issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def dashboard_created_issues(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issue_type = request.GET.get("issue_type", None) + + # get all the assigned issues + created_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + created_by=request.user, + ) + .filter(**filters) + .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") + ) + .order_by("created_at") + ) + + # Priority Ordering + priority_order = ["urgent", "high", "medium", "low", "none"] + created_issues = created_issues.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + if issue_type == "completed": + completed_issues_count = created_issues.filter( + state__group__in=["completed"] + ).count() + completed_issues = created_issues.filter( + state__group__in=["completed"] + )[:5] + return Response( + { + "issues": IssueSerializer(completed_issues, many=True).data, + "count": completed_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "overdue": + overdue_issues_count = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + ).count() + overdue_issues = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__lt=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer(overdue_issues, many=True).data, + "count": overdue_issues_count, + }, + status=status.HTTP_200_OK, + ) + + if issue_type == "upcoming": + upcoming_issues_count = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + ).count() + upcoming_issues = created_issues.filter( + state__group__in=["backlog", "unstarted", "started"], + target_date__gte=timezone.now() + )[:5] + return Response( + { + "issues": IssueSerializer(upcoming_issues, many=True).data, + "count": upcoming_issues_count, + }, + status=status.HTTP_200_OK, + ) + + return Response( + {"error": "Please specify a valid issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +def dashboard_issues_by_state_groups(self, request, slug): + filters = issue_filters(request.query_params, "GET") + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + issues_by_state_groups = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + ) + .filter(**filters) + .values("state__group") + .annotate(count=Count("id")) + ) + + # default state + all_groups = {state: 0 for state in state_order} + + # Update counts for existing groups + for entry in issues_by_state_groups: + all_groups[entry["state__group"]] = entry["count"] + + # Prepare output including all groups with their counts + output_data = [ + {"state": group, "count": count} for group, count in all_groups.items() + ] + + return Response(output_data, status=status.HTTP_200_OK) + + +def dashboard_issues_by_priority(self, request, slug): + filters = issue_filters(request.query_params, "GET") + priority_order = ["urgent", "high", "medium", "low", "none"] + + issues_by_priority = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + ) + .filter(**filters) + .values("priority") + .annotate(count=Count("id")) + ) + + # default priority + all_groups = {priority: 0 for priority in priority_order} + + # Update counts for existing groups + for entry in issues_by_priority: + all_groups[entry["priority"]] = entry["count"] + + # Prepare output including all groups with their counts + output_data = [ + {"priority": group, "count": count} + for group, count in all_groups.items() + ] + + return Response(output_data, status=status.HTTP_200_OK) + + +def dashboard_recent_activity(self, request, slug): + queryset = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + actor=request.user, + ).select_related("actor", "workspace", "issue", "project")[:8] + + return Response( + IssueActivitySerializer(queryset, many=True).data, + status=status.HTTP_200_OK, + ) + + +def dashboard_recent_projects(self, request, slug): + project_ids = ( + IssueActivity.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + actor=request.user, + ) + .values_list("project_id", flat=True) + .distinct() + ) + + # Extract project IDs from the recent projects + unique_project_ids = set(project_id for project_id in project_ids) + + # Fetch additional projects only if needed + if len(unique_project_ids) < 4: + additional_projects = Project.objects.filter( + project_projectmember__member=request.user, + project_projectmember__is_active=True, + workspace__slug=slug, + ).exclude(id__in=unique_project_ids) + + # Append additional project IDs to the existing list + unique_project_ids.update(additional_projects.values_list("id", flat=True)) + + return Response( + list(unique_project_ids)[:4], + status=status.HTTP_200_OK, + ) + + +def dashboard_recent_collaborators(self, request, slug): + # Fetch all project IDs where the user belongs to + user_projects = Project.objects.filter( + project_projectmember__member=request.user, + project_projectmember__is_active=True, + workspace__slug=slug, + ).values_list("id", flat=True) + + # Fetch all users who have performed an activity in the projects where the user exists + users_with_activities = ( + IssueActivity.objects.filter( + workspace__slug=slug, + project_id__in=user_projects, + ) + .values("actor") + .exclude(actor=request.user) + .annotate(num_activities=Count("actor")) + .order_by("-num_activities") + )[:7] + + # Get the count of active issues for each user in users_with_activities + users_with_active_issues = [] + for user_activity in users_with_activities: + user_id = user_activity["actor"] + active_issue_count = Issue.objects.filter( + assignees__in=[user_id], + state__group__in=["unstarted", "started"], + ).count() + users_with_active_issues.append( + {"user_id": user_id, "active_issue_count": active_issue_count} + ) + + # Insert the logged-in user's ID and their active issue count at the beginning + active_issue_count = Issue.objects.filter( + assignees__in=[request.user], + state__group__in=["unstarted", "started"], + ).count() + + if users_with_activities.count() < 7: + # Calculate the additional collaborators needed + additional_collaborators_needed = 7 - users_with_activities.count() + + # Fetch additional collaborators from the project_member table + additional_collaborators = list( + set( + ProjectMember.objects.filter( + ~Q(member=request.user), + project_id__in=user_projects, + workspace__slug=slug, + ) + .exclude( + member__in=[ + user["actor"] for user in users_with_activities + ] + ) + .values_list("member", flat=True) + ) + ) + + additional_collaborators = additional_collaborators[ + :additional_collaborators_needed + ] + + # Append additional collaborators to the list + for collaborator_id in additional_collaborators: + active_issue_count = Issue.objects.filter( + assignees__in=[collaborator_id], + state__group__in=["unstarted", "started"], + ).count() + users_with_active_issues.append( + { + "user_id": str(collaborator_id), + "active_issue_count": active_issue_count, + } + ) + + users_with_active_issues.insert( + 0, + {"user_id": request.user.id, "active_issue_count": active_issue_count}, + ) + + return Response(users_with_active_issues, status=status.HTTP_200_OK) + + +class DashboardEndpoint(BaseAPIView): + def create(self, request, slug): + serializer = DashboardSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, pk): + serializer = DashboardSerializer(data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, pk): + serializer = DashboardSerializer(data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def get(self, request, slug, dashboard_id=None): + if not dashboard_id: + dashboard_type = request.GET.get("dashboard_type", None) + if dashboard_type == "home": + dashboard, created = Dashboard.objects.get_or_create( + type_identifier=dashboard_type, owned_by=request.user, is_default=True + ) + + if created: + widgets_to_fetch = [ + "overview_stats", + "assigned_issues", + "created_issues", + "issues_by_state_groups", + "issues_by_priority", + "recent_activity", + "recent_projects", + "recent_collaborators", + ] + + updated_dashboard_widgets = [] + for widget_key in widgets_to_fetch: + widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True) + if widget: + updated_dashboard_widgets.append( + DashboardWidget( + widget_id=widget, + dashboard_id=dashboard.id, + ) + ) + + DashboardWidget.objects.bulk_create( + updated_dashboard_widgets, batch_size=100 + ) + + widgets = ( + Widget.objects.annotate( + is_visible=Exists( + DashboardWidget.objects.filter( + widget_id=OuterRef("pk"), + dashboard_id=dashboard.id, + is_visible=True, + ) + ) + ) + .annotate( + dashboard_filters=Subquery( + DashboardWidget.objects.filter( + widget_id=OuterRef("pk"), + dashboard_id=dashboard.id, + filters__isnull=False, + ) + .exclude(filters={}) + .values("filters")[:1] + ) + ) + .annotate( + widget_filters=Case( + When( + dashboard_filters__isnull=False, + then=F("dashboard_filters"), + ), + default=F("filters"), + output_field=JSONField(), + ) + ) + ) + return Response( + { + "dashboard": DashboardSerializer(dashboard).data, + "widgets": WidgetSerializer(widgets, many=True).data, + }, + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Please specify a valid dashboard type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + widget_key = request.GET.get("widget_key", "overview_stats") + + WIDGETS_MAPPER = { + "overview_stats": dashboard_overview_stats, + "assigned_issues": dashboard_assigned_issues, + "created_issues": dashboard_created_issues, + "issues_by_state_groups": dashboard_issues_by_state_groups, + "issues_by_priority": dashboard_issues_by_priority, + "recent_activity": dashboard_recent_activity, + "recent_projects": dashboard_recent_projects, + "recent_collaborators": dashboard_recent_collaborators, + } + + func = WIDGETS_MAPPER.get(widget_key) + if func is not None: + response = func( + self, + request=request, + slug=slug, + ) + if isinstance(response, Response): + return response + + return Response( + {"error": "Please specify a valid widget key"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WidgetsEndpoint(BaseAPIView): + def patch(self, request, dashboard_id, widget_id): + dashboard_widget = DashboardWidget.objects.filter( + widget_id=widget_id, + dashboard_id=dashboard_id, + ).first() + dashboard_widget.is_visible = request.data.get( + "is_visible", dashboard_widget.is_visible + ) + dashboard_widget.sort_order = request.data.get( + "sort_order", dashboard_widget.sort_order + ) + dashboard_widget.filters = request.data.get( + "filters", dashboard_widget.filters + ) + dashboard_widget.save() + return Response( + {"message": "successfully updated"}, status=status.HTTP_200_OK + ) diff --git a/apiserver/plane/app/views/estimate.py b/apiserver/plane/app/views/estimate.py index 8f14b230b83..3402bb06864 100644 --- a/apiserver/plane/app/views/estimate.py +++ b/apiserver/plane/app/views/estimate.py @@ -19,16 +19,16 @@ class ProjectEstimatePointEndpoint(BaseAPIView): ] def get(self, request, slug, project_id): - project = Project.objects.get(workspace__slug=slug, pk=project_id) - if project.estimate_id is not None: - estimate_points = EstimatePoint.objects.filter( - estimate_id=project.estimate_id, - project_id=project_id, - workspace__slug=slug, - ) - serializer = EstimatePointSerializer(estimate_points, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response([], status=status.HTTP_200_OK) + project = Project.objects.get(workspace__slug=slug, pk=project_id) + if project.estimate_id is not None: + estimate_points = EstimatePoint.objects.filter( + estimate_id=project.estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer(estimate_points, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response([], status=status.HTTP_200_OK) class BulkEstimatePointEndpoint(BaseViewSet): @@ -39,9 +39,13 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer_class = EstimateSerializer def list(self, request, slug, project_id): - estimates = Estimate.objects.filter( - workspace__slug=slug, project_id=project_id - ).prefetch_related("points").select_related("workspace", "project") + estimates = ( + Estimate.objects.filter( + workspace__slug=slug, project_id=project_id + ) + .prefetch_related("points") + .select_related("workspace", "project") + ) serializer = EstimateReadSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -53,14 +57,18 @@ def create(self, request, slug, project_id): ) estimate_points = request.data.get("estimate_points", []) - - serializer = EstimatePointSerializer(data=request.data.get("estimate_points"), many=True) + + serializer = EstimatePointSerializer( + data=request.data.get("estimate_points"), many=True + ) if not serializer.is_valid(): return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - estimate_serializer = EstimateSerializer(data=request.data.get("estimate")) + estimate_serializer = EstimateSerializer( + data=request.data.get("estimate") + ) if not estimate_serializer.is_valid(): return Response( estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST @@ -135,7 +143,8 @@ def partial_update(self, request, slug, project_id, estimate_id): estimate_points = EstimatePoint.objects.filter( pk__in=[ - estimate_point.get("id") for estimate_point in estimate_points_data + estimate_point.get("id") + for estimate_point in estimate_points_data ], workspace__slug=slug, project_id=project_id, @@ -157,10 +166,14 @@ def partial_update(self, request, slug, project_id, estimate_id): updated_estimate_points.append(estimate_point) EstimatePoint.objects.bulk_update( - updated_estimate_points, ["value"], batch_size=10, + updated_estimate_points, + ["value"], + batch_size=10, ) - estimate_point_serializer = EstimatePointSerializer(estimate_points, many=True) + estimate_point_serializer = EstimatePointSerializer( + estimate_points, many=True + ) return Response( { "estimate": estimate_serializer.data, diff --git a/apiserver/plane/app/views/exporter.py b/apiserver/plane/app/views/exporter.py index b709a599d6f..179de81f97e 100644 --- a/apiserver/plane/app/views/exporter.py +++ b/apiserver/plane/app/views/exporter.py @@ -21,11 +21,11 @@ class ExportIssuesEndpoint(BaseAPIView): def post(self, request, slug): # Get the workspace workspace = Workspace.objects.get(slug=slug) - + provider = request.data.get("provider", False) multiple = request.data.get("multiple", False) project_ids = request.data.get("project", []) - + if provider in ["csv", "xlsx", "json"]: if not project_ids: project_ids = Project.objects.filter( @@ -63,9 +63,11 @@ def post(self, request, slug): def get(self, request, slug): exporter_history = ExporterHistory.objects.filter( workspace__slug=slug - ).select_related("workspace","initiated_by") + ).select_related("workspace", "initiated_by") - if request.GET.get("per_page", False) and request.GET.get("cursor", False): + if request.GET.get("per_page", False) and request.GET.get( + "cursor", False + ): return self.paginate( request=request, queryset=exporter_history, diff --git a/apiserver/plane/app/views/external.py b/apiserver/plane/app/views/external.py index 97d509c1e58..618c65e3ccb 100644 --- a/apiserver/plane/app/views/external.py +++ b/apiserver/plane/app/views/external.py @@ -14,7 +14,10 @@ from .base import BaseAPIView from plane.app.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project -from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.app.serializers import ( + ProjectLiteSerializer, + WorkspaceLiteSerializer, +) from plane.utils.integrations.github import get_release_notes from plane.license.utils.instance_value import get_configuration_value @@ -51,7 +54,8 @@ def post(self, request, slug, project_id): if not task: return Response( - {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Task is required"}, + status=status.HTTP_400_BAD_REQUEST, ) final_text = task + "\n" + prompt @@ -89,7 +93,7 @@ def get(self, request): class UnsplashEndpoint(BaseAPIView): def get(self, request): - UNSPLASH_ACCESS_KEY, = get_configuration_value( + (UNSPLASH_ACCESS_KEY,) = get_configuration_value( [ { "key": "UNSPLASH_ACCESS_KEY", diff --git a/apiserver/plane/app/views/importer.py b/apiserver/plane/app/views/importer.py index b99d663e21d..a15ed36b761 100644 --- a/apiserver/plane/app/views/importer.py +++ b/apiserver/plane/app/views/importer.py @@ -35,14 +35,16 @@ ModuleSerializer, ) from plane.utils.integrations.github import get_github_repo_details -from plane.utils.importers.jira import jira_project_issue_summary +from plane.utils.importers.jira import ( + jira_project_issue_summary, + is_allowed_hostname, +) from plane.bgtasks.importer_task import service_importer from plane.utils.html_processor import strip_tags from plane.app.permissions import WorkSpaceAdminPermission class ServiceIssueImportSummaryEndpoint(BaseAPIView): - def get(self, request, slug, service): if service == "github": owner = request.GET.get("owner", False) @@ -94,7 +96,8 @@ def get(self, request, slug, service): for key, error_message in params.items(): if not request.GET.get(key, False): return Response( - {"error": error_message}, status=status.HTTP_400_BAD_REQUEST + {"error": error_message}, + status=status.HTTP_400_BAD_REQUEST, ) project_key = request.GET.get("project_key", "") @@ -122,6 +125,7 @@ class ImportServiceEndpoint(BaseAPIView): permission_classes = [ WorkSpaceAdminPermission, ] + def post(self, request, slug, service): project_id = request.data.get("project_id", False) @@ -174,6 +178,21 @@ def post(self, request, slug, service): data = request.data.get("data", False) metadata = request.data.get("metadata", False) config = request.data.get("config", False) + + cloud_hostname = metadata.get("cloud_hostname", False) + + if not cloud_hostname: + return Response( + {"error": "Cloud hostname is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not is_allowed_hostname(cloud_hostname): + return Response( + {"error": "Hostname is not a valid hostname."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not data or not metadata: return Response( {"error": "Data, config and metadata are required"}, @@ -244,7 +263,9 @@ def patch(self, request, slug, service, pk): importer = Importer.objects.get( pk=pk, service=service, workspace__slug=slug ) - serializer = ImporterSerializer(importer, data=request.data, partial=True) + serializer = ImporterSerializer( + importer, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @@ -280,9 +301,9 @@ def post(self, request, slug, project_id, service): ).first() # Get the maximum sequence_id - last_id = IssueSequence.objects.filter(project_id=project_id).aggregate( - largest=Max("sequence") - )["largest"] + last_id = IssueSequence.objects.filter( + project_id=project_id + ).aggregate(largest=Max("sequence"))["largest"] last_id = 1 if last_id is None else last_id + 1 @@ -315,7 +336,9 @@ def post(self, request, slug, project_id, service): if issue_data.get("state", False) else default_state.id, name=issue_data.get("name", "Issue Created through Bulk"), - description_html=issue_data.get("description_html", "

"), + description_html=issue_data.get( + "description_html", "

" + ), description_stripped=( None if ( @@ -427,15 +450,21 @@ def post(self, request, slug, project_id, service): for comment in comments_list ] - _ = IssueComment.objects.bulk_create(bulk_issue_comments, batch_size=100) + _ = IssueComment.objects.bulk_create( + bulk_issue_comments, batch_size=100 + ) # Attach Links _ = IssueLink.objects.bulk_create( [ IssueLink( issue=issue, - url=issue_data.get("link", {}).get("url", "https://github.com"), - title=issue_data.get("link", {}).get("title", "Original Issue"), + url=issue_data.get("link", {}).get( + "url", "https://github.com" + ), + title=issue_data.get("link", {}).get( + "title", "Original Issue" + ), project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, @@ -472,7 +501,9 @@ def post(self, request, slug, project_id, service): ignore_conflicts=True, ) - modules = Module.objects.filter(id__in=[module.id for module in modules]) + modules = Module.objects.filter( + id__in=[module.id for module in modules] + ) if len(modules) == len(modules_data): _ = ModuleLink.objects.bulk_create( @@ -520,6 +551,8 @@ def post(self, request, slug, project_id, service): else: return Response( - {"message": "Modules created but issues could not be imported"}, + { + "message": "Modules created but issues could not be imported" + }, status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index 331ee21753e..01eee78e393 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -62,7 +62,9 @@ def perform_create(self, serializer): serializer.save(project_id=self.kwargs.get("project_id")) def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + inbox = Inbox.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) # Handle default inbox delete if inbox.is_default: return Response( @@ -86,59 +88,51 @@ class InboxIssueViewSet(BaseViewSet): ] def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - inbox_id=self.kwargs.get("inbox_id"), - ) - .select_related("issue", "workspace", "project") - ) - - def list(self, request, slug, project_id, inbox_id): - filters = issue_filters(request.query_params, "GET") - issues = ( + return ( Issue.objects.filter( - issue_inbox__inbox_id=inbox_id, - workspace__slug=slug, - project_id=project_id, + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + issue_inbox__inbox_id=self.kwargs.get("inbox_id") ) - .filter(**filters) - .annotate(bridge_id=F("issue_inbox__id")) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") - .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_inbox", + queryset=InboxIssue.objects.only( + "status", "duplicate_to", "snoozed_till", "source" + ), + ) + ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .prefetch_related( - Prefetch( - "issue_inbox", - queryset=InboxIssue.objects.only( - "status", "duplicate_to", "snoozed_till", "source" - ), - ) - ) - ) - issues_data = IssueStateInboxSerializer(issues, many=True).data + ).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 return Response( issues_data, status=status.HTTP_200_OK, @@ -147,7 +141,8 @@ def list(self, request, slug, project_id, inbox_id): def create(self, request, slug, project_id, inbox_id): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) # Check for valid priority @@ -159,7 +154,8 @@ def create(self, request, slug, project_id, inbox_id): "none", ]: return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Invalid priority"}, + status=status.HTTP_400_BAD_REQUEST, ) # Create or get state @@ -192,6 +188,8 @@ def create(self, request, slug, project_id, inbox_id): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) # create an inbox issue InboxIssue.objects.create( @@ -201,12 +199,16 @@ def create(self, request, slug, project_id, inbox_id): source=request.data.get("source", "in-app"), ) - serializer = IssueStateInboxSerializer(issue) + 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, pk): + def partial_update(self, request, slug, project_id, inbox_id, issue_id): inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) # Get the project member project_member = ProjectMember.objects.get( @@ -229,7 +231,9 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): if bool(issue_data): issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, ) # Only allow guests and viewers to edit name and description if project_member.role <= 10: @@ -239,7 +243,9 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): "description_html": issue_data.get( "description_html", issue.description_html ), - "description": issue_data.get("description", issue.description), + "description": issue_data.get( + "description", issue.description + ), } issue_serializer = IssueCreateSerializer( @@ -262,6 +268,8 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_serializer.save() else: @@ -285,7 +293,9 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): project_id=project_id, ) state = State.objects.filter( - group="cancelled", workspace__slug=slug, project_id=project_id + group="cancelled", + workspace__slug=slug, + project_id=project_id, ).first() if state is not None: issue.state = state @@ -303,32 +313,35 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): if issue.state.name == "Triage": # Move to default state state = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True + workspace__slug=slug, + project_id=project_id, + default=True, ).first() 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(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: return Response( - InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK + serializer.errors, status=status.HTTP_400_BAD_REQUEST ) + else: + 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, pk): - inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id - ) - issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id - ) - serializer = IssueStateInboxSerializer(issue) + def retrieve(self, request, slug, project_id, inbox_id, issue_id): + 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 destroy(self, request, slug, project_id, inbox_id, pk): + def destroy(self, request, slug, project_id, inbox_id, issue_id): inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) # Get the project member project_member = ProjectMember.objects.get( @@ -350,9 +363,8 @@ def destroy(self, request, slug, project_id, inbox_id, pk): if inbox_issue.status in [-2, -1, 0, 2]: # Delete the issue also Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id + workspace__slug=slug, project_id=project_id, pk=issue_id ).delete() inbox_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) - diff --git a/apiserver/plane/app/views/integration/base.py b/apiserver/plane/app/views/integration/base.py index b82957dfb81..d757fe47126 100644 --- a/apiserver/plane/app/views/integration/base.py +++ b/apiserver/plane/app/views/integration/base.py @@ -1,6 +1,7 @@ # Python improts import uuid import requests + # Django imports from django.contrib.auth.hashers import make_password @@ -19,7 +20,10 @@ WorkspaceMember, APIToken, ) -from plane.app.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer +from plane.app.serializers import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, +) from plane.utils.integrations.github import ( get_github_metadata, delete_github_installation, @@ -27,6 +31,7 @@ from plane.app.permissions import WorkSpaceAdminPermission from plane.utils.integrations.slack import slack_oauth + class IntegrationViewSet(BaseViewSet): serializer_class = IntegrationSerializer model = Integration @@ -101,7 +106,10 @@ def create(self, request, slug, provider): code = request.data.get("code", False) if not code: - return Response({"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Code is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) slack_response = slack_oauth(code=code) @@ -110,7 +118,9 @@ def create(self, request, slug, provider): team_id = metadata.get("team", {}).get("id", False) if not metadata or not access_token or not team_id: return Response( - {"error": "Slack could not be installed. Please try again later"}, + { + "error": "Slack could not be installed. Please try again later" + }, status=status.HTTP_400_BAD_REQUEST, ) config = {"team_id": team_id, "access_token": access_token} diff --git a/apiserver/plane/app/views/integration/github.py b/apiserver/plane/app/views/integration/github.py index 29b7a9b2f9b..2d37c64b078 100644 --- a/apiserver/plane/app/views/integration/github.py +++ b/apiserver/plane/app/views/integration/github.py @@ -21,7 +21,10 @@ GithubCommentSyncSerializer, ) from plane.utils.integrations.github import get_github_repos -from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission +from plane.app.permissions import ( + ProjectBasePermission, + ProjectEntityPermission, +) class GithubRepositoriesEndpoint(BaseAPIView): @@ -185,11 +188,10 @@ def post(self, request, slug, project_id, repo_sync_id): class GithubCommentSyncViewSet(BaseViewSet): - permission_classes = [ ProjectEntityPermission, ] - + serializer_class = GithubCommentSyncSerializer model = GithubCommentSync diff --git a/apiserver/plane/app/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py index 3f18a2ab277..410e6b332c3 100644 --- a/apiserver/plane/app/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -8,9 +8,16 @@ # Module imports from plane.app.views import BaseViewSet, BaseAPIView -from plane.db.models import SlackProjectSync, WorkspaceIntegration, ProjectMember +from plane.db.models import ( + SlackProjectSync, + WorkspaceIntegration, + ProjectMember, +) from plane.app.serializers import SlackProjectSyncSerializer -from plane.app.permissions import ProjectBasePermission, ProjectEntityPermission +from plane.app.permissions import ( + ProjectBasePermission, + ProjectEntityPermission, +) from plane.utils.integrations.slack import slack_oauth @@ -38,7 +45,8 @@ def create(self, request, slug, project_id, workspace_integration_id): if not code: return Response( - {"error": "Code is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Code is required"}, + status=status.HTTP_400_BAD_REQUEST, ) slack_response = slack_oauth(code=code) @@ -54,7 +62,9 @@ def create(self, request, slug, project_id, workspace_integration_id): access_token=slack_response.get("access_token"), scopes=slack_response.get("scope"), bot_user_id=slack_response.get("bot_user_id"), - webhook_url=slack_response.get("incoming_webhook", {}).get("url"), + webhook_url=slack_response.get("incoming_webhook", {}).get( + "url" + ), data=slack_response, team_id=slack_response.get("team", {}).get("id"), team_name=slack_response.get("team", {}).get("name"), @@ -62,7 +72,9 @@ def create(self, request, slug, project_id, workspace_integration_id): project_id=project_id, ) _ = ProjectMember.objects.get_or_create( - member=workspace_integration.actor, role=20, project_id=project_id + member=workspace_integration.actor, + role=20, + project_id=project_id, ) serializer = SlackProjectSyncSerializer(slack_project_sync) return Response(serializer.data, status=status.HTTP_200_OK) @@ -74,6 +86,8 @@ def create(self, request, slug, project_id, workspace_integration_id): ) capture_exception(e) return Response( - {"error": "Slack could not be installed. Please try again later"}, + { + "error": "Slack could not be installed. Please try again later" + }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index d489629bada..0b5c612d399 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -34,11 +34,11 @@ # Module imports from . import BaseViewSet, BaseAPIView, WebhookMixin from plane.app.serializers import ( - IssueCreateSerializer, IssueActivitySerializer, IssueCommentSerializer, IssuePropertySerializer, IssueSerializer, + IssueCreateSerializer, LabelSerializer, IssueFlatSerializer, IssueLinkSerializer, @@ -48,10 +48,8 @@ ProjectMemberLiteSerializer, IssueReactionSerializer, CommentReactionSerializer, - IssueVoteSerializer, IssueRelationSerializer, RelatedIssueSerializer, - IssuePublicSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -81,6 +79,7 @@ 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 collections import defaultdict class IssueViewSet(WebhookMixin, BaseViewSet): @@ -109,44 +108,19 @@ def get_serializer_class(self): def get_queryset(self): return ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id") ) - .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", queryset=IssueReaction.objects.select_related("actor"), ) ) - ).distinct() - - @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") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -154,17 +128,47 @@ def list(self, request, slug, project_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ).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 + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -212,14 +216,17 @@ def list(self, request, slug, project_id): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) + issues = IssueSerializer( + issue_queryset, many=True, fields=self.fields, expand=self.expand + ).data + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -239,32 +246,44 @@ def create(self, request, slug, project_id): # Track the issue issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue = ( + self.get_queryset().filter(pk=serializer.data["id"]).first() ) + serializer = IssueSerializer(issue) return Response(serializer.data, 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 = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get(workspace__slug=slug, project_id=project_id, pk=pk) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + issue = self.get_queryset().filter(pk=pk).first() + return Response( + IssueSerializer( + issue, fields=self.fields, expand=self.expand + ).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 = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) 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) + serializer = IssueCreateSerializer( + issue, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -275,12 +294,19 @@ def partial_update(self, request, slug, project_id, pk=None): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + 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(serializer.data, status=status.HTTP_200_OK) 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) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) @@ -293,6 +319,8 @@ def destroy(self, request, slug, project_id, pk=None): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -303,7 +331,13 @@ def get(self, request, slug): filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") @@ -317,7 +351,9 @@ def get(self, request, slug): workspace__slug=slug, ) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -336,7 +372,9 @@ def get(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -353,7 +391,9 @@ def get(self, request, slug): # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -401,7 +441,9 @@ def get(self, request, slug): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -450,17 +492,27 @@ class IssueActivityEndpoint(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug, project_id, issue_id): + filters = {} + if request.GET.get("created_at__gt", None) is not None: + filters = {"created_at__gt": request.GET.get("created_at__gt")} + issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + workspace__slug=slug, ) + .filter(**filters) .select_related("actor", "workspace", "issue", "project") ).order_by("created_at") issue_comments = ( IssueComment.objects.filter(issue_id=issue_id) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + workspace__slug=slug, + ) + .filter(**filters) .order_by("created_at") .select_related("actor", "issue", "project", "workspace") .prefetch_related( @@ -470,9 +522,17 @@ def get(self, request, slug, project_id, issue_id): ) ) ) - issue_activities = IssueActivitySerializer(issue_activities, many=True).data + issue_activities = IssueActivitySerializer( + issue_activities, many=True + ).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data + if request.GET.get("activity_type", None) == "issue-property": + return Response(issue_activities, status=status.HTTP_200_OK) + + if request.GET.get("activity_type", None) == "issue-comment": + return Response(issue_comments, status=status.HTTP_200_OK) + result_list = sorted( chain(issue_activities, issue_comments), key=lambda instance: instance["created_at"], @@ -528,19 +588,26 @@ def create(self, request, slug, project_id, issue_id): ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( @@ -560,13 +627,18 @@ def partial_update(self, request, slug, project_id, issue_id, pk): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) current_instance = json.dumps( IssueCommentSerializer(issue_comment).data, @@ -581,6 +653,8 @@ def destroy(self, request, slug, project_id, issue_id, pk): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -590,16 +664,21 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView): ProjectLitePermission, ] - def post(self, request, slug, project_id): - issue_property, created = IssueProperty.objects.get_or_create( + def patch(self, request, slug, project_id): + issue_property = IssueProperty.objects.get( user=request.user, project_id=project_id, ) - if not created: - issue_property.properties = request.data.get("properties", {}) - issue_property.save() - issue_property.properties = request.data.get("properties", {}) + issue_property.filters = request.data.get( + "filters", issue_property.filters + ) + issue_property.display_filters = request.data.get( + "display_filters", issue_property.display_filters + ) + issue_property.display_properties = request.data.get( + "display_properties", issue_property.display_properties + ) issue_property.save() serializer = IssuePropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -624,11 +703,17 @@ def create(self, request, slug, project_id): serializer = LabelSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError: return Response( - {"error": "Label with the same name already exists in the project"}, + { + "error": "Label with the same name already exists in the project" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -683,7 +768,9 @@ class SubIssuesEndpoint(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug, project_id, issue_id): sub_issues = ( - Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) + Issue.issue_objects.filter( + parent_id=issue_id, workspace__slug=slug + ) .select_related("project") .select_related("workspace") .select_related("state") @@ -691,7 +778,9 @@ def get(self, request, slug, project_id, issue_id): .prefetch_related("assignees") .prefetch_related("labels") .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -703,7 +792,9 @@ def get(self, request, slug, project_id, issue_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -714,21 +805,15 @@ def get(self, request, slug, project_id, issue_id): queryset=IssueReaction.objects.select_related("actor"), ) ) + .annotate(state_group=F("state__group")) ) - state_distribution = ( - State.objects.filter(workspace__slug=slug, state_issue__parent_id=issue_id) - .annotate(state_group=F("group")) - .values("state_group") - .annotate(state_count=Count("state_group")) - .order_by("state_group") - ) - - result = { - item["state_group"]: item["state_count"] for item in state_distribution - } + # create's a dict with state group name with their respective issue id's + result = defaultdict(list) + for sub_issue in sub_issues: + result[sub_issue.state_group].append(str(sub_issue.id)) - serializer = IssueLiteSerializer( + serializer = IssueSerializer( sub_issues, many=True, ) @@ -758,7 +843,9 @@ def post(self, request, slug, project_id, issue_id): _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) + updated_sub_issues = Issue.issue_objects.filter( + id__in=sub_issue_ids + ).annotate(state_group=F("state__group")) # Track the issue _ = [ @@ -770,12 +857,26 @@ def post(self, request, slug, project_id, issue_id): project_id=str(project_id), current_instance=json.dumps({"parent": str(sub_issue_id)}), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) for sub_issue_id in sub_issue_ids ] + # create's a dict with state group name with their respective issue id's + result = defaultdict(list) + for sub_issue in updated_sub_issues: + result[sub_issue.state_group].append(str(sub_issue.id)) + + serializer = IssueSerializer( + updated_sub_issues, + many=True, + ) return Response( - IssueFlatSerializer(updated_sub_issues, many=True).data, + { + "sub_issues": serializer.data, + "state_distribution": result, + }, status=status.HTTP_200_OK, ) @@ -809,26 +910,35 @@ def create(self, request, slug, project_id, issue_id): ) issue_activity.delay( type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder, ) - serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + serializer = IssueLinkSerializer( + issue_link, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -839,13 +949,18 @@ def partial_update(self, request, slug, project_id, issue_id, pk): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, pk): issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + pk=pk, ) current_instance = json.dumps( IssueLinkSerializer(issue_link).data, @@ -859,6 +974,8 @@ def destroy(self, request, slug, project_id, issue_id, pk): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_link.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -915,6 +1032,8 @@ def post(self, request, slug, project_id, issue_id): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -931,6 +1050,8 @@ def delete(self, request, slug, project_id, issue_id, pk): project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -961,49 +1082,66 @@ def get_queryset(self): .filter(archived_at__isnull=False) .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + 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") + ) ) @method_decorator(gzip_page) def list(self, request, slug, project_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] + 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") # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -1051,7 +1189,9 @@ def list(self, request, slug, project_id): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -1062,9 +1202,10 @@ def list(self, request, slug, project_id): else issue_queryset.filter(parent__isnull=True) ) - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) + issues = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + return Response(issues, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk=None): issue = Issue.objects.get( @@ -1092,6 +1233,8 @@ def unarchive(self, request, slug, project_id, pk=None): IssueSerializer(issue).data, cls=DjangoJSONEncoder ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue.archived_at = None issue.save() @@ -1138,24 +1281,11 @@ def get_queryset(self): ) def list(self, request, slug, project_id, issue_id): - members = ( - ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - is_active=True, - ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - subscriber=OuterRef("member"), - ) - ) - ) - .select_related("member") - ) + members = ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ).select_related("member") serializer = ProjectMemberLiteSerializer(members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -1210,7 +1340,9 @@ def subscription_status(self, request, slug, project_id, issue_id): workspace__slug=slug, project=project_id, ).exists() - return Response({"subscribed": issue_subscriber}, status=status.HTTP_200_OK) + return Response( + {"subscribed": issue_subscriber}, status=status.HTTP_200_OK + ) class IssueReactionViewSet(BaseViewSet): @@ -1248,6 +1380,8 @@ def create(self, request, slug, project_id, issue_id): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1273,6 +1407,8 @@ def destroy(self, request, slug, project_id, issue_id, reaction_code): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1313,6 +1449,8 @@ def create(self, request, slug, project_id, comment_id): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1339,6 +1477,8 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1365,23 +1505,95 @@ def get_queryset(self): .distinct() ) + def list(self, request, slug, project_id, issue_id): + issue_relations = ( + IssueRelation.objects.filter( + Q(issue_id=issue_id) | Q(related_issue=issue_id) + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("issue") + .order_by("-created_at") + .distinct() + ) + + blocking_issues = issue_relations.filter( + relation_type="blocked_by", related_issue_id=issue_id + ) + blocked_by_issues = issue_relations.filter( + relation_type="blocked_by", issue_id=issue_id + ) + duplicate_issues = issue_relations.filter( + issue_id=issue_id, relation_type="duplicate" + ) + duplicate_issues_related = issue_relations.filter( + related_issue_id=issue_id, relation_type="duplicate" + ) + relates_to_issues = issue_relations.filter( + issue_id=issue_id, relation_type="relates_to" + ) + relates_to_issues_related = issue_relations.filter( + related_issue_id=issue_id, relation_type="relates_to" + ) + + blocked_by_issues_serialized = IssueRelationSerializer( + blocked_by_issues, many=True + ).data + duplicate_issues_serialized = IssueRelationSerializer( + duplicate_issues, many=True + ).data + relates_to_issues_serialized = IssueRelationSerializer( + relates_to_issues, many=True + ).data + + # revere relation for blocked by issues + blocking_issues_serialized = RelatedIssueSerializer( + blocking_issues, many=True + ).data + # reverse relation for duplicate issues + duplicate_issues_related_serialized = RelatedIssueSerializer( + duplicate_issues_related, many=True + ).data + # reverse relation for related issues + relates_to_issues_related_serialized = RelatedIssueSerializer( + relates_to_issues_related, many=True + ).data + + response_data = { + "blocking": blocking_issues_serialized, + "blocked_by": blocked_by_issues_serialized, + "duplicate": duplicate_issues_serialized + + duplicate_issues_related_serialized, + "relates_to": relates_to_issues_serialized + + relates_to_issues_related_serialized, + } + + return Response(response_data, status=status.HTTP_200_OK) + def create(self, request, slug, project_id, issue_id): - related_list = request.data.get("related_list", []) - relation = request.data.get("relation", None) + relation_type = request.data.get("relation_type", None) + issues = request.data.get("issues", []) project = Project.objects.get(pk=project_id) issue_relation = IssueRelation.objects.bulk_create( [ IssueRelation( - issue_id=related_issue["issue"], - related_issue_id=related_issue["related_issue"], - relation_type=related_issue["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, updated_by=request.user, ) - for related_issue in related_list + for issue in issues ], batch_size=10, ignore_conflicts=True, @@ -1395,9 +1607,11 @@ def create(self, request, slug, project_id, issue_id): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) - if relation == "blocking": + if relation_type == "blocking": return Response( RelatedIssueSerializer(issue_relation, many=True).data, status=status.HTTP_201_CREATED, @@ -1408,10 +1622,24 @@ def create(self, request, slug, project_id, issue_id): status=status.HTTP_201_CREATED, ) - def destroy(self, request, slug, project_id, issue_id, pk): - issue_relation = IssueRelation.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) + def remove_relation(self, request, slug, project_id, issue_id): + relation_type = request.data.get("relation_type", None) + related_issue = request.data.get("related_issue", None) + + if relation_type == "blocking": + issue_relation = IssueRelation.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=related_issue, + related_issue_id=issue_id, + ) + else: + issue_relation = IssueRelation.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + related_issue_id=related_issue, + ) current_instance = json.dumps( IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder, @@ -1419,12 +1647,14 @@ def destroy(self, request, slug, project_id, issue_id, pk): issue_relation.delete() issue_activity.delay( type="issue_relation.activity.deleted", - requested_data=json.dumps({"related_list": None}), + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1439,7 +1669,9 @@ class IssueDraftViewSet(BaseViewSet): def get_queryset(self): return ( Issue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -1447,54 +1679,71 @@ def get_queryset(self): .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .filter(is_draft=True) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .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") + ) ) @method_decorator(gzip_page) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") - fields = [field for field in request.GET.get("fields", "").split(",") if field] + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -1542,14 +1791,17 @@ def list(self, request, slug, project_id): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) + issues = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -1569,25 +1821,33 @@ def create(self, request, slug, project_id): # Track the issue issue_activity.delay( type="issue_draft.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.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) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) serializer = IssueSerializer(issue, data=request.data, partial=True) if serializer.is_valid(): - if request.data.get("is_draft") is not None and not request.data.get( + if request.data.get( "is_draft" - ): - serializer.save(created_at=timezone.now(), updated_at=timezone.now()) + ) is not None and not request.data.get("is_draft"): + serializer.save( + created_at=timezone.now(), updated_at=timezone.now() + ) else: serializer.save() issue_activity.delay( @@ -1601,6 +1861,8 @@ def partial_update(self, request, slug, project_id, pk): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1612,7 +1874,9 @@ def retrieve(self, request, slug, project_id, pk=None): return Response(IssueSerializer(issue).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) + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) @@ -1625,5 +1889,7 @@ def destroy(self, request, slug, project_id, pk=None): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index a8a8655c316..1f055129a90 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -7,6 +7,8 @@ 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 + # Third party imports from rest_framework.response import Response @@ -20,9 +22,13 @@ ModuleIssueSerializer, ModuleLinkSerializer, ModuleFavoriteSerializer, - IssueStateSerializer, + IssueSerializer, + ModuleUserPropertiesSerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, ) -from plane.app.permissions import ProjectEntityPermission from plane.db.models import ( Module, ModuleIssue, @@ -32,6 +38,8 @@ ModuleFavorite, IssueLink, IssueAttachment, + IssueSubscriber, + ModuleUserProperties, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -54,7 +62,6 @@ def get_serializer_class(self): ) def get_queryset(self): - subquery = ModuleFavorite.objects.filter( user=self.request.user, module_id=OuterRef("pk"), @@ -74,7 +81,9 @@ def get_queryset(self): .prefetch_related( Prefetch( "link_module", - queryset=ModuleLink.objects.select_related("module", "created_by"), + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), ) ) .annotate( @@ -136,7 +145,7 @@ def get_queryset(self): ), ) ) - .order_by("-is_favorite","-created_at") + .order_by("-is_favorite", "-created_at") ) def create(self, request, slug, project_id): @@ -153,6 +162,18 @@ def create(self, request, slug, project_id): return Response(serializer.data, 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 + return Response(modules, status=status.HTTP_200_OK) + def retrieve(self, request, slug, project_id, pk): queryset = self.get_queryset().get(pk=pk) @@ -167,7 +188,13 @@ def retrieve(self, request, slug, project_id, pk): .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) .annotate(avatar=F("assignees__avatar")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) .annotate( total_issues=Count( "assignee_id", @@ -251,7 +278,10 @@ def retrieve(self, request, slug, project_id, pk): if queryset.start_date and queryset.target_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, slug=slug, project_id=project_id, module_id=pk + queryset=queryset, + slug=slug, + project_id=project_id, + module_id=pk, ) return Response( @@ -260,25 +290,28 @@ def retrieve(self, request, slug, project_id, pk): ) def destroy(self, request, slug, project_id, pk): - module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) + module = Module.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk ) - issue_activity.delay( - type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(pk), - "module_name": str(module.name), - "issues": [str(issue_id) for issue_id in module_issues], - } - ), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), + module_issues = list( + ModuleIssue.objects.filter(module_id=pk).values_list( + "issue", flat=True + ) ) + _ = [ + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=json.dumps({"module_name": str(module.name)}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for issue in module_issues + ] module.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -289,7 +322,6 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): webhook_event = "module_issue" bulk = True - filterset_fields = [ "issue__labels__id", "issue__assignees__id", @@ -299,168 +331,163 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ProjectEntityPermission, ] + def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + return ( + 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") ) - .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) - .select_related("project") - .select_related("workspace") - .select_related("module") - .select_related("issue", "issue__state", "issue__project") - .prefetch_related("issue__assignees", "issue__labels") - .prefetch_related("module__members") - .distinct() - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id, module_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - order_by = request.GET.get("order_by", "created_at") - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.issue_objects.filter(issue_module__module_id=module_id) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("labels", "assignees") + .prefetch_related('issue_module__module') + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate(bridge_id=F("issue_module__id")) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .filter(**filters) .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, module_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + 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 ) - issues = IssueStateSerializer(issues, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) - def create(self, request, slug, project_id, module_id): + # 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): return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, ) - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=module_id - ) - - module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) - - update_module_issue_activity = [] - records_to_update = [] - record_to_create = [] - - for issue in issues: - module_issue = [ - module_issue - for module_issue in module_issues - if str(module_issue.issue_id) in issues - ] - - if len(module_issue): - if module_issue[0].module_id != module_id: - update_module_issue_activity.append( - { - "old_module_id": str(module_issue[0].module_id), - "new_module_id": str(module_id), - "issue_id": str(module_issue[0].issue_id), - } - ) - module_issue[0].module_id = module_id - records_to_update.append(module_issue[0]) - else: - record_to_create.append( - ModuleIssue( - module=module, - issue_id=issue, - project_id=project_id, - workspace=module.workspace, - created_by=request.user, - updated_by=request.user, - ) + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=str(issue), + module_id=module_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, ) - - ModuleIssue.objects.bulk_create( - record_to_create, + for issue in issues + ], batch_size=10, ignore_conflicts=True, ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module_id)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + 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) + + + # 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): + return Response( + {"error": "Modules are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) - ModuleIssue.objects.bulk_update( - records_to_update, - ["module"], + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=issue_id, + module_id=module, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for module in modules + ], batch_size=10, + ignore_conflicts=True, ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": module}), + actor_id=str(request.user.id), + issue_id=issue_id, + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for module in modules + ] - # Capture Issue Activity - issue_activity.delay( - type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_module_issues": update_module_issue_activity, - "created_module_issues": serializers.serialize( - "json", record_to_create - ), - } - ), - epoch=int(timezone.now().timestamp()), - ) + issue = (self.get_queryset().filter(pk=issue_id).first()) + serializer = IssueSerializer(issue) + return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response( - ModuleIssueSerializer(self.get_queryset(), many=True).data, - status=status.HTTP_200_OK, - ) - def destroy(self, request, slug, project_id, module_id, pk): + def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( - workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk + workspace__slug=slug, + project_id=project_id, + module_id=module_id, + issue_id=issue_id, ) issue_activity.delay( type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(module_id), - "issues": [str(module_issue.issue_id)], - } - ), + requested_data=json.dumps({"module_id": str(module_id)}), actor_id=str(request.user.id), - issue_id=str(module_issue.issue_id), + issue_id=str(issue_id), project_id=str(project_id), - current_instance=None, + current_instance=json.dumps({"module_name": module_issue.module.name}), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) module_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -521,4 +548,42 @@ def destroy(self, request, slug, project_id, module_id): module_id=module_id, ) module_favorite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleUserPropertiesEndpoint(BaseAPIView): + permission_classes = [ + ProjectLitePermission, + ] + + def patch(self, request, slug, project_id, module_id): + module_properties = ModuleUserProperties.objects.get( + user=request.user, + module_id=module_id, + project_id=project_id, + workspace__slug=slug, + ) + + module_properties.filters = request.data.get( + "filters", module_properties.filters + ) + module_properties.display_filters = request.data.get( + "display_filters", module_properties.display_filters + ) + module_properties.display_properties = request.data.get( + "display_properties", module_properties.display_properties + ) + module_properties.save() + + serializer = ModuleUserPropertiesSerializer(module_properties) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, slug, project_id, module_id): + module_properties, _ = ModuleUserProperties.objects.get_or_create( + user=request.user, + project_id=project_id, + module_id=module_id, + workspace__slug=slug, + ) + serializer = ModuleUserPropertiesSerializer(module_properties) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/notification.py b/apiserver/plane/app/views/notification.py index 9494ea86c03..ebe8e508220 100644 --- a/apiserver/plane/app/views/notification.py +++ b/apiserver/plane/app/views/notification.py @@ -1,5 +1,5 @@ # Django imports -from django.db.models import Q +from django.db.models import Q, OuterRef, Exists from django.utils import timezone # Third party imports @@ -15,8 +15,9 @@ IssueSubscriber, Issue, WorkspaceMember, + UserNotificationPreference, ) -from plane.app.serializers import NotificationSerializer +from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer class NotificationViewSet(BaseViewSet, BasePaginator): @@ -51,8 +52,10 @@ def list(self, request, slug): # Filters based on query parameters snoozed_filters = { - "true": Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False), - "false": Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + "true": Q(snoozed_till__lt=timezone.now()) + | Q(snoozed_till__isnull=False), + "false": Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), } notifications = notifications.filter(snoozed_filters[snoozed]) @@ -69,17 +72,39 @@ def list(self, request, slug): # Subscribed issues if type == "watching": - issue_ids = IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + issue_ids = ( + IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ) + .annotate( + created=Exists( + Issue.objects.filter( + created_by=request.user, pk=OuterRef("issue_id") + ) + ) + ) + .annotate( + assigned=Exists( + IssueAssignee.objects.filter( + pk=OuterRef("issue_id"), assignee=request.user + ) + ) + ) + .filter(created=False, assigned=False) + .values_list("issue_id", flat=True) + ) + notifications = notifications.filter( + entity_identifier__in=issue_ids, + ) # Assigned Issues if type == "assigned": issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Created issues if type == "created": @@ -94,10 +119,14 @@ def list(self, request, slug): issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Pagination - if request.GET.get("per_page", False) and request.GET.get("cursor", False): + if request.GET.get("per_page", False) and request.GET.get( + "cursor", False + ): return self.paginate( request=request, queryset=(notifications), @@ -227,11 +256,13 @@ def create(self, request, slug): # Filter for snoozed notifications if snoozed: notifications = notifications.filter( - Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) + Q(snoozed_till__lt=timezone.now()) + | Q(snoozed_till__isnull=False) ) else: notifications = notifications.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), ) # Filter for archived or unarchive @@ -245,14 +276,18 @@ def create(self, request, slug): issue_ids = IssueSubscriber.objects.filter( workspace__slug=slug, subscriber_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Assigned Issues if type == "assigned": issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) # Created issues if type == "created": @@ -267,7 +302,9 @@ def create(self, request, slug): issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + notifications = notifications.filter( + entity_identifier__in=issue_ids + ) updated_notifications = [] for notification in notifications: @@ -277,3 +314,31 @@ def create(self, request, slug): updated_notifications, ["read_at"], batch_size=100 ) return Response({"message": "Successful"}, status=status.HTTP_200_OK) + + +class UserNotificationPreferenceEndpoint(BaseAPIView): + model = UserNotificationPreference + serializer_class = UserNotificationPreferenceSerializer + + # request the object + def get(self, request): + user_notification_preference = UserNotificationPreference.objects.get( + user=request.user + ) + serializer = UserNotificationPreferenceSerializer( + user_notification_preference + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update the object + def patch(self, request): + user_notification_preference = UserNotificationPreference.objects.get( + user=request.user + ) + serializer = UserNotificationPreferenceSerializer( + user_notification_preference, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 9bd1f1dd4d5..1d8ff1fbb15 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -1,5 +1,5 @@ # Python imports -from datetime import timedelta, date, datetime +from datetime import date, datetime, timedelta # Django imports from django.db import connection @@ -7,30 +7,19 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page - # Third party imports from rest_framework import status from rest_framework.response import Response -# Module imports -from .base import BaseViewSet, BaseAPIView from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - Page, - PageFavorite, - Issue, - IssueAssignee, - IssueActivity, - PageLog, - ProjectMember, -) -from plane.app.serializers import ( - PageSerializer, - PageFavoriteSerializer, - PageLogSerializer, - IssueLiteSerializer, - SubPageSerializer, -) +from plane.app.serializers import (IssueLiteSerializer, PageFavoriteSerializer, + PageLogSerializer, PageSerializer, + SubPageSerializer) +from plane.db.models import (Issue, IssueActivity, IssueAssignee, Page, + PageFavorite, PageLog, ProjectMember) + +# Module imports +from .base import BaseAPIView, BaseViewSet def unarchive_archive_page_and_descendants(page_id, archived_at): @@ -97,7 +86,9 @@ def create(self, request, slug, project_id): def partial_update(self, request, slug, project_id, pk): try: - page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) if page.is_locked: return Response( @@ -127,7 +118,9 @@ def partial_update(self, request, slug, project_id, pk): if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except Page.DoesNotExist: return Response( { @@ -157,22 +150,26 @@ def unlock(self, request, slug, project_id, page_id): def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) - return Response( - PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) + pages = PageSerializer(queryset, many=True).data + return Response(pages, status=status.HTTP_200_OK) def archive(self, request, slug, project_id, page_id): - page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) + page = Page.objects.get( + pk=page_id, workspace__slug=slug, project_id=project_id + ) - # only the owner and admin can archive the page + # only the owner or admin can archive the page if ( ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__gt=20 + project_id=project_id, + member=request.user, + is_active=True, + role__lte=15, ).exists() - or request.user.id != page.owned_by_id + and request.user.id != page.owned_by_id ): return Response( - {"error": "Only the owner and admin can archive the page"}, + {"error": "Only the owner or admin can archive the page"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -181,17 +178,22 @@ def archive(self, request, slug, project_id, page_id): return Response(status=status.HTTP_204_NO_CONTENT) def unarchive(self, request, slug, project_id, page_id): - page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id) + page = Page.objects.get( + pk=page_id, workspace__slug=slug, project_id=project_id + ) - # only the owner and admin can un archive the page + # only the owner or admin can un archive the page if ( ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__gt=20 + project_id=project_id, + member=request.user, + is_active=True, + role__lte=15, ).exists() - or request.user.id != page.owned_by_id + and request.user.id != page.owned_by_id ): return Response( - {"error": "Only the owner and admin can un archive the page"}, + {"error": "Only the owner or admin can un archive the page"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -210,17 +212,21 @@ def archive_list(self, request, slug, project_id): workspace__slug=slug, ).filter(archived_at__isnull=False) - return Response( - PageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) + pages = PageSerializer(pages, many=True).data + return Response(pages, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) # only the owner and admin can delete the page if ( ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__gt=20 + project_id=project_id, + member=request.user, + is_active=True, + role__gt=20, ).exists() or request.user.id != page.owned_by_id ): diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index 5b88e3652d1..5d2f9567305 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -36,6 +36,7 @@ ProjectFavoriteSerializer, ProjectDeployBoardSerializer, ProjectMemberAdminSerializer, + ProjectMemberRoleSerializer, ) from plane.app.permissions import ( @@ -67,7 +68,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): - serializer_class = ProjectSerializer + serializer_class = ProjectListSerializer model = Project webhook_event = "project" @@ -75,19 +76,20 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ProjectBasePermission, ] - def get_serializer_class(self, *args, **kwargs): - if self.action in ["update", "partial_update"]: - return ProjectSerializer - return ProjectDetailSerializer - def get_queryset(self): return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) - .filter(Q(project_projectmember__member=self.request.user) | Q(network=2)) + .filter( + Q(project_projectmember__member=self.request.user) + | Q(network=2) + ) .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" + "workspace", + "workspace__owner", + "default_assignee", + "project_lead", ) .annotate( is_favorite=Exists( @@ -159,7 +161,11 @@ def get_queryset(self): ) def list(self, request, slug): - fields = [field for field in request.GET.get("fields", "").split(",") if field] + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] sort_order_query = ProjectMember.objects.filter( member=request.user, @@ -172,7 +178,9 @@ def list(self, request, slug): .annotate(sort_order=Subquery(sort_order_query)) .order_by("sort_order", "name") ) - if request.GET.get("per_page", False) and request.GET.get("cursor", False): + if request.GET.get("per_page", False) and request.GET.get( + "cursor", False + ): return self.paginate( request=request, queryset=(projects), @@ -180,12 +188,10 @@ def list(self, request, slug): projects, many=True ).data, ) - - return Response( - ProjectListSerializer( - projects, many=True, fields=fields if fields else None - ).data - ) + projects = ProjectListSerializer( + projects, many=True, fields=fields if fields else None + ).data + return Response(projects, status=status.HTTP_200_OK) def create(self, request, slug): try: @@ -199,7 +205,9 @@ def create(self, request, slug): # Add the user as Administrator to the project project_member = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + project_id=serializer.data["id"], + member=request.user, + role=20, ) # Also create the issue property for the user _ = IssueProperty.objects.create( @@ -272,9 +280,15 @@ def create(self, request, slug): ] ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) serializer = ProjectListSerializer(project) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST, @@ -287,7 +301,8 @@ def create(self, request, slug): ) except Workspace.DoesNotExist as e: return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Workspace does not exist"}, + status=status.HTTP_404_NOT_FOUND, ) except serializers.ValidationError as e: return Response( @@ -312,7 +327,9 @@ def partial_update(self, request, slug, pk=None): serializer.save() if serializer.data["inbox_view"]: Inbox.objects.get_or_create( - name=f"{project.name} Inbox", project=project, is_default=True + name=f"{project.name} Inbox", + project=project, + is_default=True, ) # Create the triage state in Backlog group @@ -324,10 +341,16 @@ def partial_update(self, request, slug, pk=None): color="#ff7700", ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .first() + ) serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError as e: if "already exists" in str(e): @@ -337,7 +360,8 @@ def partial_update(self, request, slug, pk=None): ) except (Project.DoesNotExist, Workspace.DoesNotExist): return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + {"error": "Project does not exist"}, + status=status.HTTP_404_NOT_FOUND, ) except serializers.ValidationError as e: return Response( @@ -372,11 +396,14 @@ def create(self, request, slug, project_id): # Check if email is provided if not emails: return Response( - {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Emails are required"}, + status=status.HTTP_400_BAD_REQUEST, ) requesting_user = ProjectMember.objects.get( - workspace__slug=slug, project_id=project_id, member_id=request.user.id + workspace__slug=slug, + project_id=project_id, + member_id=request.user.id, ) # Check if any invited user has an higher role @@ -550,7 +577,9 @@ def post(self, request, slug, project_id, pk): _ = WorkspaceMember.objects.create( workspace_id=project_invite.workspace_id, member=user, - role=15 if project_invite.role >= 15 else project_invite.role, + role=15 + if project_invite.role >= 15 + else project_invite.role, ) else: # Else make him active @@ -656,11 +685,25 @@ def create(self, request, slug, project_id): .order_by("sort_order") ) + bulk_project_members = [] + member_roles = {member.get("member_id"): member.get("role") for member in members} + # Update roles in the members array based on the member_roles dictionary + for project_member in ProjectMember.objects.filter(project_id=project_id, member_id__in=[member.get("member_id") for member in members]): + project_member.role = member_roles[str(project_member.member_id)] + project_member.is_active = True + bulk_project_members.append(project_member) + + # Update the roles of the existing members + ProjectMember.objects.bulk_update( + bulk_project_members, ["is_active", "role"], batch_size=100 + ) + for member in members: sort_order = [ project_member.get("sort_order") for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) + if str(project_member.get("member_id")) + == str(member.get("member_id")) ] bulk_project_members.append( ProjectMember( @@ -668,7 +711,9 @@ def create(self, request, slug, project_id): role=member.get("role", 10), project_id=project_id, workspace_id=project.workspace_id, - sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, + sort_order=sort_order[0] - 10000 + if len(sort_order) + else 65535, ) ) bulk_issue_props.append( @@ -679,25 +724,6 @@ def create(self, request, slug, project_id): ) ) - # Check if the user is already a member of the project and is inactive - if ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - member_id=member.get("member_id"), - is_active=False, - ).exists(): - member_detail = ProjectMember.objects.get( - workspace__slug=slug, - project_id=project_id, - member_id=member.get("member_id"), - is_active=False, - ) - # Check if the user has not deactivated the account - user = User.objects.filter(pk=member.get("member_id")).first() - if user.is_active: - member_detail.is_active = True - member_detail.save(update_fields=["is_active"]) - project_members = ProjectMember.objects.bulk_create( bulk_project_members, batch_size=10, @@ -708,18 +734,12 @@ def create(self, request, slug, project_id): bulk_issue_props, batch_size=10, ignore_conflicts=True ) - serializer = ProjectMemberSerializer(project_members, many=True) - + project_members = ProjectMember.objects.filter(project_id=project_id, member_id__in=[member.get("member_id") for member in members]) + serializer = ProjectMemberRoleSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) def list(self, request, slug, project_id): - project_member = ProjectMember.objects.get( - member=request.user, - workspace__slug=slug, - project_id=project_id, - is_active=True, - ) - + # Get the list of project members for the project project_members = ProjectMember.objects.filter( project_id=project_id, workspace__slug=slug, @@ -727,10 +747,9 @@ def list(self, request, slug, project_id): is_active=True, ).select_related("project", "member", "workspace") - if project_member.role > 10: - serializer = ProjectMemberAdminSerializer(project_members, many=True) - else: - serializer = ProjectMemberSerializer(project_members, many=True) + serializer = ProjectMemberRoleSerializer( + project_members, fields=("id", "member", "role"), many=True + ) return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, pk): @@ -758,7 +777,9 @@ def partial_update(self, request, slug, project_id, pk): > requested_project_member.role ): return Response( - {"error": "You cannot update a role that is higher than your own role"}, + { + "error": "You cannot update a role that is higher than your own role" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -797,7 +818,9 @@ def destroy(self, request, slug, project_id, pk): # User cannot deactivate higher role if requesting_project_member.role < project_member.role: return Response( - {"error": "You cannot remove a user having role higher than you"}, + { + "error": "You cannot remove a user having role higher than you" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -848,7 +871,8 @@ def post(self, request, slug, project_id): if len(team_members) == 0: return Response( - {"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST + {"error": "No such team exists"}, + status=status.HTTP_400_BAD_REQUEST, ) workspace = Workspace.objects.get(slug=slug) @@ -895,7 +919,8 @@ def get(self, request, slug): if name == "": return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) exists = ProjectIdentifier.objects.filter( @@ -912,16 +937,23 @@ def delete(self, request, slug): if name == "": return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) - if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): + if Project.objects.filter( + identifier=name, workspace__slug=slug + ).exists(): return Response( - {"error": "Cannot delete an identifier of an existing project"}, + { + "error": "Cannot delete an identifier of an existing project" + }, status=status.HTTP_400_BAD_REQUEST, ) - ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() + ProjectIdentifier.objects.filter( + name=name, workspace__slug=slug + ).delete() return Response( status=status.HTTP_204_NO_CONTENT, @@ -939,7 +971,9 @@ def post(self, request, slug, project_id): ).first() if project_member is None: - return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + return Response( + {"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN + ) view_props = project_member.view_props default_props = project_member.default_props @@ -947,8 +981,12 @@ def post(self, request, slug, project_id): sort_order = project_member.sort_order project_member.view_props = request.data.get("view_props", view_props) - project_member.default_props = request.data.get("default_props", default_props) - project_member.preferences = request.data.get("preferences", preferences) + project_member.default_props = request.data.get( + "default_props", default_props + ) + project_member.preferences = request.data.get( + "preferences", preferences + ) project_member.sort_order = request.data.get("sort_order", sort_order) project_member.save() @@ -1010,18 +1048,11 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): def get(self, request): files = [] - s3_client_params = { - "service_name": "s3", - "aws_access_key_id": settings.AWS_ACCESS_KEY_ID, - "aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY, - } - - # Use AWS_S3_ENDPOINT_URL if it is present in the settings - if hasattr(settings, "AWS_S3_ENDPOINT_URL") and settings.AWS_S3_ENDPOINT_URL: - s3_client_params["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL - - s3 = boto3.client(**s3_client_params) - + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) params = { "Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Prefix": "static/project-cover/", @@ -1034,19 +1065,9 @@ def get(self, request): if not content["Key"].endswith( "/" ): # This line ensures we're only getting files, not "sub-folders" - if ( - hasattr(settings, "AWS_S3_CUSTOM_DOMAIN") - and settings.AWS_S3_CUSTOM_DOMAIN - and hasattr(settings, "AWS_S3_URL_PROTOCOL") - and settings.AWS_S3_URL_PROTOCOL - ): - files.append( - f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/{content['Key']}" - ) - else: - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) return Response(files, status=status.HTTP_200_OK) @@ -1113,6 +1134,7 @@ def get(self, request, slug): ).values("project_id", "role") project_members = { - str(member["project_id"]): member["role"] for member in project_members + str(member["project_id"]): member["role"] + for member in project_members } return Response(project_members, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 4ecb711276d..13acabfe8ca 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -10,7 +10,15 @@ # Module imports from .base import BaseAPIView -from plane.db.models import Workspace, Project, Issue, Cycle, Module, Page, IssueView +from plane.db.models import ( + Workspace, + Project, + Issue, + Cycle, + Module, + Page, + IssueView, +) from plane.utils.issue_search import search_issues @@ -25,7 +33,9 @@ def filter_workspaces(self, query, slug, project_id, workspace_search): for field in fields: q |= Q(**{f"{field}__icontains": query}) return ( - Workspace.objects.filter(q, workspace_member__member=self.request.user) + Workspace.objects.filter( + q, workspace_member__member=self.request.user + ) .distinct() .values("name", "id", "slug") ) @@ -38,7 +48,8 @@ def filter_projects(self, query, slug, project_id, workspace_search): return ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) | Q(network=2), + Q(project_projectmember__member=self.request.user) + | Q(network=2), workspace__slug=slug, ) .distinct() @@ -169,7 +180,9 @@ def filter_views(self, query, slug, project_id, workspace_search): def get(self, request, slug): query = request.query_params.get("search", False) - workspace_search = request.query_params.get("workspace_search", "false") + workspace_search = request.query_params.get( + "workspace_search", "false" + ) project_id = request.query_params.get("project_id", False) if not query: @@ -209,11 +222,13 @@ def get(self, request, slug): class IssueSearchEndpoint(BaseAPIView): def get(self, request, slug, project_id): query = request.query_params.get("search", False) - workspace_search = request.query_params.get("workspace_search", "false") + workspace_search = request.query_params.get( + "workspace_search", "false" + ) parent = request.query_params.get("parent", "false") issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") - module = request.query_params.get("module", "false") + module = request.query_params.get("module", False) sub_issue = request.query_params.get("sub_issue", "false") issue_id = request.query_params.get("issue_id", False) @@ -234,9 +249,9 @@ def get(self, request, slug, project_id): issues = issues.filter( ~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True ).exclude( - pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list( - "parent_id", flat=True - ) + pk__in=Issue.issue_objects.filter( + parent__isnull=False + ).values_list("parent_id", flat=True) ) if issue_relation == "true" and issue_id: issue = Issue.issue_objects.get(pk=issue_id) @@ -254,8 +269,8 @@ def get(self, request, slug, project_id): if cycle == "true": issues = issues.exclude(issue_cycle__isnull=False) - if module == "true": - issues = issues.exclude(issue_module__isnull=False) + if module: + issues = issues.exclude(issue_module__module=module) return Response( issues.values( diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index f7226ba6e0a..242061e1878 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -9,9 +9,12 @@ from rest_framework import status # Module imports -from . import BaseViewSet +from . import BaseViewSet, BaseAPIView from plane.app.serializers import StateSerializer -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import ( + ProjectEntityPermission, + WorkspaceEntityPermission, +) from plane.db.models import State, Issue @@ -22,9 +25,6 @@ class StateViewSet(BaseViewSet): ProjectEntityPermission, ] - def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) - def get_queryset(self): return self.filter_queryset( super() @@ -77,16 +77,21 @@ def destroy(self, request, slug, project_id, pk): ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Default state cannot be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=pk).exists() if issue_exist: return Response( - {"error": "The state is not empty, only empty states can be deleted"}, + { + "error": "The state is not empty, only empty states can be deleted" + }, status=status.HTTP_400_BAD_REQUEST, ) state.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/user.py b/apiserver/plane/app/views/user.py index 008780526f6..7764e3b9753 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -43,7 +43,9 @@ def retrieve_instance_admin(self, request): is_admin = InstanceAdmin.objects.filter( instance=instance, user=request.user ).exists() - return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) + return Response( + {"is_instance_admin": is_admin}, status=status.HTTP_200_OK + ) def deactivate(self, request): # Check all workspace user is active @@ -51,7 +53,12 @@ def deactivate(self, request): # Instance admin check if InstanceAdmin.objects.filter(user=user).exists(): - return Response({"error": "You cannot deactivate your account since you are an instance admin"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + { + "error": "You cannot deactivate your account since you are an instance admin" + }, + status=status.HTTP_400_BAD_REQUEST, + ) projects_to_deactivate = [] workspaces_to_deactivate = [] @@ -61,7 +68,10 @@ def deactivate(self, request): ).annotate( other_admin_exists=Count( Case( - When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + When( + Q(role=20, is_active=True) & ~Q(member=request.user), + then=1, + ), default=0, output_field=IntegerField(), ) @@ -86,7 +96,10 @@ def deactivate(self, request): ).annotate( other_admin_exists=Count( Case( - When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), + When( + Q(role=20, is_active=True) & ~Q(member=request.user), + then=1, + ), default=0, output_field=IntegerField(), ) @@ -95,7 +108,9 @@ def deactivate(self, request): ) for workspace in workspaces: - if workspace.other_admin_exists > 0 or (workspace.total_members == 1): + if workspace.other_admin_exists > 0 or ( + workspace.total_members == 1 + ): workspace.is_active = False workspaces_to_deactivate.append(workspace) else: @@ -134,7 +149,9 @@ def patch(self, request): user = User.objects.get(pk=request.user.id, is_active=True) user.is_onboarded = request.data.get("is_onboarded", False) user.save() - return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) + return Response( + {"message": "Updated successfully"}, status=status.HTTP_200_OK + ) class UpdateUserTourCompletedEndpoint(BaseAPIView): @@ -142,14 +159,16 @@ def patch(self, request): user = User.objects.get(pk=request.user.id, is_active=True) user.is_tour_completed = request.data.get("is_tour_completed", False) user.save() - return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK) + return Response( + {"message": "Updated successfully"}, status=status.HTTP_200_OK + ) class UserActivityEndpoint(BaseAPIView, BasePaginator): def get(self, request): - queryset = IssueActivity.objects.filter(actor=request.user).select_related( - "actor", "workspace", "issue", "project" - ) + queryset = IssueActivity.objects.filter( + actor=request.user + ).select_related("actor", "workspace", "issue", "project") return self.paginate( request=request, @@ -158,4 +177,3 @@ def get(self, request): issue_activities, many=True ).data, ) - diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index eb76407b711..27f31f7a9ba 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -24,10 +24,15 @@ from plane.app.serializers import ( GlobalViewSerializer, IssueViewSerializer, - IssueLiteSerializer, + IssueSerializer, IssueViewFavoriteSerializer, ) -from plane.app.permissions import WorkspaceEntityPermission, ProjectEntityPermission +from plane.app.permissions import ( + WorkspaceEntityPermission, + ProjectEntityPermission, + WorkspaceViewerPermission, + ProjectLitePermission, +) from plane.db.models import ( Workspace, GlobalView, @@ -37,14 +42,15 @@ IssueReaction, IssueLink, IssueAttachment, + IssueSubscriber, ) from plane.utils.issue_filters import issue_filters from plane.utils.grouper import group_results class GlobalViewViewSet(BaseViewSet): - serializer_class = GlobalViewSerializer - model = GlobalView + serializer_class = IssueViewSerializer + model = IssueView permission_classes = [ WorkspaceEntityPermission, ] @@ -58,6 +64,7 @@ def get_queryset(self): super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__isnull=True) .select_related("workspace") .order_by(self.request.GET.get("order_by", "-created_at")) .distinct() @@ -72,18 +79,16 @@ class GlobalViewIssuesViewSet(BaseViewSet): def get_queryset(self): return ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", @@ -95,11 +100,21 @@ def get_queryset(self): @method_decorator(gzip_page) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") - fields = [field for field in request.GET.get("fields", "").split(",") if field] + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") @@ -108,7 +123,6 @@ def list(self, request, slug): .filter(**filters) .filter(project__project_projectmember__member=self.request.user) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -116,7 +130,17 @@ def list(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -126,7 +150,9 @@ def list(self, request, slug): # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -174,17 +200,17 @@ def list(self, request, slug): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response( - issue_dict, - status=status.HTTP_200_OK, + serializer = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None ) + return Response(serializer.data, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): @@ -217,6 +243,18 @@ def get_queryset(self): .distinct() ) + def list(self, request, slug, project_id): + queryset = self.get_queryset() + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + views = IssueViewSerializer( + queryset, many=True, fields=fields if fields else None + ).data + return Response(views, status=status.HTTP_200_OK) + class IssueViewFavoriteViewSet(BaseViewSet): serializer_class = IssueViewFavoriteSerializer @@ -246,4 +284,4 @@ def destroy(self, request, slug, project_id, view_id): view_id=view_id, ) view_favourite.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/webhook.py b/apiserver/plane/app/views/webhook.py index 48608d5830c..fe69cd7e64e 100644 --- a/apiserver/plane/app/views/webhook.py +++ b/apiserver/plane/app/views/webhook.py @@ -26,8 +26,12 @@ def post(self, request, slug): ) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) except IntegrityError as e: if "already exists" in str(e): return Response( diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 11170114aaa..f4d3dbbb5e0 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -41,13 +41,19 @@ ProjectMemberSerializer, WorkspaceThemeSerializer, IssueActivitySerializer, - IssueLiteSerializer, + IssueSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, + ProjectMemberRoleSerializer, + WorkspaceUserPropertiesSerializer, + WorkspaceEstimateSerializer, + StateSerializer, + LabelSerializer, ) from plane.app.views.base import BaseAPIView from . import BaseViewSet from plane.db.models import ( + State, User, Workspace, WorkspaceMemberInvite, @@ -64,6 +70,9 @@ WorkspaceMember, CycleIssue, IssueReaction, + WorkspaceUserProperties, + Estimate, + EstimatePoint, ) from plane.app.permissions import ( WorkSpaceBasePermission, @@ -71,11 +80,13 @@ WorkspaceEntityPermission, WorkspaceViewerPermission, WorkspaceUserPermission, + ProjectLitePermission, ) 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 + class WorkSpaceViewSet(BaseViewSet): model = Workspace serializer_class = WorkSpaceSerializer @@ -111,7 +122,9 @@ def get_queryset(self): .values("count") ) return ( - self.filter_queryset(super().get_queryset().select_related("owner")) + self.filter_queryset( + super().get_queryset().select_related("owner") + ) .order_by("name") .filter( workspace_member__member=self.request.user, @@ -137,7 +150,9 @@ def create(self, request): if len(name) > 80 or len(slug) > 48: return Response( - {"error": "The maximum length for name is 80 and for slug is 48"}, + { + "error": "The maximum length for name is 80 and for slug is 48" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -150,7 +165,9 @@ def create(self, request): role=20, company_role=request.data.get("company_role", ""), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + serializer.data, status=status.HTTP_201_CREATED + ) return Response( [serializer.errors[error][0] for error in serializer.errors], status=status.HTTP_400_BAD_REQUEST, @@ -173,6 +190,11 @@ class UserWorkSpacesEndpoint(BaseAPIView): ] def get(self, request): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] member_count = ( WorkspaceMember.objects.filter( workspace=OuterRef("id"), @@ -204,13 +226,17 @@ def get(self, request): .annotate(total_members=member_count) .annotate(total_issues=issue_count) .filter( - workspace_member__member=request.user, workspace_member__is_active=True + workspace_member__member=request.user, + workspace_member__is_active=True, ) .distinct() ) - - serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + workspaces = WorkSpaceSerializer( + self.filter_queryset(workspace), + fields=fields if fields else None, + many=True, + ).data + return Response(workspaces, status=status.HTTP_200_OK) class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): @@ -250,7 +276,8 @@ def create(self, request, slug): # Check if email is provided if not emails: return Response( - {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Emails are required"}, + status=status.HTTP_400_BAD_REQUEST, ) # check for role level of the requesting user @@ -407,7 +434,7 @@ def post(self, request, slug, pk): # Delete the invitation workspace_invite.delete() - + # Send event workspace_invite_event.delay( user=user.id if user is not None else None, @@ -537,10 +564,15 @@ def list(self, request, slug): workspace_members = self.get_queryset() if workspace_member.role > 10: - serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True) + serializer = WorkspaceMemberAdminSerializer( + workspace_members, + fields=("id", "member", "role"), + many=True, + ) else: serializer = WorkSpaceMemberSerializer( workspace_members, + fields=("id", "member", "role"), many=True, ) return Response(serializer.data, status=status.HTTP_200_OK) @@ -572,7 +604,9 @@ def partial_update(self, request, slug, pk): > requested_workspace_member.role ): return Response( - {"error": "You cannot update a role that is higher than your own role"}, + { + "error": "You cannot update a role that is higher than your own role" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -611,7 +645,9 @@ def destroy(self, request, slug, pk): if requesting_workspace_member.role < workspace_member.role: return Response( - {"error": "You cannot remove a user having role higher than you"}, + { + "error": "You cannot remove a user having role higher than you" + }, status=status.HTTP_400_BAD_REQUEST, ) @@ -705,6 +741,49 @@ def leave(self, request, slug): return Response(status=status.HTTP_204_NO_CONTENT) +class WorkspaceProjectMemberEndpoint(BaseAPIView): + serializer_class = ProjectMemberRoleSerializer + model = ProjectMember + + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get(self, request, slug): + # Fetch all project IDs where the user is involved + project_ids = ( + ProjectMember.objects.filter( + member=request.user, + member__is_bot=False, + is_active=True, + ) + .values_list("project_id", flat=True) + .distinct() + ) + + # 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") + project_members = ProjectMemberRoleSerializer( + project_members, many=True + ).data + + project_members_dict = dict() + + # Construct a dictionary with project_id as key and project_members as value + for project_member in project_members: + project_id = project_member.pop("project") + if str(project_id) not in project_members_dict: + project_members_dict[str(project_id)] = [] + project_members_dict[str(project_id)].append(project_member) + + return Response(project_members_dict, status=status.HTTP_200_OK) + + class TeamMemberViewSet(BaseViewSet): serializer_class = TeamSerializer model = Team @@ -739,7 +818,9 @@ def create(self, request, slug): ) if len(members) != len(request.data.get("members", [])): - users = list(set(request.data.get("members", [])).difference(members)) + users = list( + set(request.data.get("members", [])).difference(members) + ) users = User.objects.filter(pk__in=users) serializer = UserLiteSerializer(users, many=True) @@ -753,7 +834,9 @@ def create(self, request, slug): workspace = Workspace.objects.get(slug=slug) - serializer = TeamSerializer(data=request.data, context={"workspace": workspace}) + serializer = TeamSerializer( + data=request.data, context={"workspace": workspace} + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -782,7 +865,9 @@ def get(self, request): workspace_id=last_workspace_id, member=request.user ).select_related("workspace", "project", "member", "workspace__owner") - project_member_serializer = ProjectMemberSerializer(project_member, many=True) + project_member_serializer = ProjectMemberSerializer( + project_member, many=True + ) return Response( { @@ -966,7 +1051,11 @@ class WorkspaceThemeViewSet(BaseViewSet): serializer_class = WorkspaceThemeSerializer def get_queryset(self): - return super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + ) def create(self, request, slug): workspace = Workspace.objects.get(slug=slug) @@ -1229,12 +1318,22 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ] def get(self, request, slug, user_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -1246,39 +1345,40 @@ def get(self, request, slug, user_id): project__project_projectmember__member=request.user, ) .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .select_related("project", "workspace", "state", "parent") - .prefetch_related("assignees", "labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - .order_by("-created_at") .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .order_by("created_at") ).distinct() # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -1326,16 +1426,17 @@ def get(self, request, slug, user_id): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueLiteSerializer( + issues = IssueSerializer( issue_queryset, many=True, fields=fields if fields else None ).data - issue_dict = {str(issue["id"]): issue for issue in issues} - return Response(issue_dict, status=status.HTTP_200_OK) + return Response(issues, status=status.HTTP_200_OK) class WorkspaceLabelsEndpoint(BaseAPIView): @@ -1347,5 +1448,79 @@ def get(self, request, slug): labels = Label.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, - ).values("parent", "name", "color", "id", "project_id", "workspace__slug") - return Response(labels, status=status.HTTP_200_OK) + ) + serializer = LabelSerializer(labels, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + +class WorkspaceStatesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get(self, request, slug): + states = State.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + ) + serializer = StateSerializer(states, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + +class WorkspaceEstimatesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get(self, request, slug): + estimate_ids = Project.objects.filter( + workspace__slug=slug, estimate__isnull=False + ).values_list("estimate_id", flat=True) + estimates = Estimate.objects.filter( + pk__in=estimate_ids + ).prefetch_related( + Prefetch( + "points", + queryset=EstimatePoint.objects.select_related( + "estimate", "workspace", "project" + ), + ) + ) + serializer = WorkspaceEstimateSerializer(estimates, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class WorkspaceUserPropertiesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def patch(self, request, slug): + workspace_properties = WorkspaceUserProperties.objects.get( + user=request.user, + workspace__slug=slug, + ) + + workspace_properties.filters = request.data.get( + "filters", workspace_properties.filters + ) + workspace_properties.display_filters = request.data.get( + "display_filters", workspace_properties.display_filters + ) + workspace_properties.display_properties = request.data.get( + "display_properties", workspace_properties.display_properties + ) + workspace_properties.save() + + serializer = WorkspaceUserPropertiesSerializer(workspace_properties) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, slug): + ( + workspace_properties, + _, + ) = WorkspaceUserProperties.objects.get_or_create( + user=request.user, workspace__slug=slug + ) + serializer = WorkspaceUserPropertiesSerializer(workspace_properties) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index a4f5b194c84..7789562293f 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -101,7 +101,9 @@ def get_assignee_details(slug, filters): def get_label_details(slug, filters): """Fetch label details if required""" return ( - Issue.objects.filter(workspace__slug=slug, **filters, labels__id__isnull=False) + Issue.objects.filter( + workspace__slug=slug, **filters, labels__id__isnull=False + ) .distinct("labels__id") .order_by("labels__id") .values("labels__id", "labels__color", "labels__name") @@ -174,7 +176,9 @@ def generate_segmented_rows( ): segment_zero = list( set( - item.get("segment") for sublist in distribution.values() for item in sublist + item.get("segment") + for sublist in distribution.values() + for item in sublist ) ) @@ -193,7 +197,9 @@ def generate_segmented_rows( ] for segment in segment_zero: - value = next((x.get(key) for x in data if x.get("segment") == segment), "0") + value = next( + (x.get(key) for x in data if x.get("segment") == segment), "0" + ) generated_row.append(value) if x_axis == ASSIGNEE_ID: @@ -212,7 +218,11 @@ def generate_segmented_rows( if x_axis == LABEL_ID: label = next( - (lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), + ( + lab + for lab in label_details + if str(lab[LABEL_ID]) == str(item) + ), None, ) @@ -221,7 +231,11 @@ def generate_segmented_rows( if x_axis == STATE_ID: state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), + ( + sta + for sta in state_details + if str(sta[STATE_ID]) == str(item) + ), None, ) @@ -230,7 +244,11 @@ def generate_segmented_rows( if x_axis == CYCLE_ID: cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), + ( + cyc + for cyc in cycle_details + if str(cyc[CYCLE_ID]) == str(item) + ), None, ) @@ -239,7 +257,11 @@ def generate_segmented_rows( if x_axis == MODULE_ID: module = next( - (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), + ( + mod + for mod in module_details + if str(mod[MODULE_ID]) == str(item) + ), None, ) @@ -266,7 +288,11 @@ def generate_segmented_rows( if segmented == LABEL_ID: for index, segm in enumerate(row_zero[2:]): label = next( - (lab for lab in label_details if str(lab[LABEL_ID]) == str(segm)), + ( + lab + for lab in label_details + if str(lab[LABEL_ID]) == str(segm) + ), None, ) if label: @@ -275,7 +301,11 @@ def generate_segmented_rows( if segmented == STATE_ID: for index, segm in enumerate(row_zero[2:]): state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(segm)), + ( + sta + for sta in state_details + if str(sta[STATE_ID]) == str(segm) + ), None, ) if state: @@ -284,7 +314,11 @@ def generate_segmented_rows( if segmented == MODULE_ID: for index, segm in enumerate(row_zero[2:]): module = next( - (mod for mod in label_details if str(mod[MODULE_ID]) == str(segm)), + ( + mod + for mod in label_details + if str(mod[MODULE_ID]) == str(segm) + ), None, ) if module: @@ -293,7 +327,11 @@ def generate_segmented_rows( if segmented == CYCLE_ID: for index, segm in enumerate(row_zero[2:]): cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(segm)), + ( + cyc + for cyc in cycle_details + if str(cyc[CYCLE_ID]) == str(segm) + ), None, ) if cycle: @@ -315,7 +353,10 @@ def generate_non_segmented_rows( ): rows = [] for item, data in distribution.items(): - row = [item, data[0].get("count" if y_axis == "issue_count" else "estimate")] + row = [ + item, + data[0].get("count" if y_axis == "issue_count" else "estimate"), + ] if x_axis == ASSIGNEE_ID: assignee = next( @@ -333,7 +374,11 @@ def generate_non_segmented_rows( if x_axis == LABEL_ID: label = next( - (lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), + ( + lab + for lab in label_details + if str(lab[LABEL_ID]) == str(item) + ), None, ) @@ -342,7 +387,11 @@ def generate_non_segmented_rows( if x_axis == STATE_ID: state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), + ( + sta + for sta in state_details + if str(sta[STATE_ID]) == str(item) + ), None, ) @@ -351,7 +400,11 @@ def generate_non_segmented_rows( if x_axis == CYCLE_ID: cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), + ( + cyc + for cyc in cycle_details + if str(cyc[CYCLE_ID]) == str(item) + ), None, ) @@ -360,7 +413,11 @@ def generate_non_segmented_rows( if x_axis == MODULE_ID: module = next( - (mod for mod in module_details if str(mod[MODULE_ID]) == str(item)), + ( + mod + for mod in module_details + if str(mod[MODULE_ID]) == str(item) + ), None, ) @@ -369,7 +426,10 @@ def generate_non_segmented_rows( rows.append(tuple(row)) - row_zero = [row_mapping.get(x_axis, "X-Axis"), row_mapping.get(y_axis, "Y-Axis")] + row_zero = [ + row_mapping.get(x_axis, "X-Axis"), + row_mapping.get(y_axis, "Y-Axis"), + ] return [tuple(row_zero)] + rows diff --git a/apiserver/plane/bgtasks/apps.py b/apiserver/plane/bgtasks/apps.py index 03d29f3e085..7f6ca38f0c5 100644 --- a/apiserver/plane/bgtasks/apps.py +++ b/apiserver/plane/bgtasks/apps.py @@ -2,4 +2,4 @@ class BgtasksConfig(AppConfig): - name = 'plane.bgtasks' + name = "plane.bgtasks" diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py new file mode 100644 index 00000000000..713835033f6 --- /dev/null +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -0,0 +1,242 @@ +import json +from datetime import datetime + +# Third party imports +from celery import shared_task + +# 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 + +@shared_task +def stack_email_notification(): + # get all email notifications + email_notifications = ( + EmailNotificationLog.objects.filter(processed_at__isnull=True) + .order_by("receiver") + .values() + ) + + # Create the below format for each of the issues + # {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }} + + # Convert to unique receivers list + receivers = list( + set( + [ + str(notification.get("receiver_id")) + for notification in email_notifications + ] + ) + ) + processed_notifications = [] + # Loop through all the issues to create the emails + for receiver_id in receivers: + # Notifcation triggered for the receiver + receiver_notifications = [ + notification + for notification in email_notifications + if str(notification.get("receiver_id")) == receiver_id + ] + # create payload for all issues + payload = {} + email_notification_ids = [] + for receiver_notification in receiver_notifications: + payload.setdefault( + receiver_notification.get("entity_identifier"), {} + ).setdefault( + str(receiver_notification.get("triggered_by_id")), [] + ).append( + receiver_notification.get("data") + ) + # append processed notifications + processed_notifications.append(receiver_notification.get("id")) + email_notification_ids.append(receiver_notification.get("id")) + + # Create emails for all the issues + for issue_id, notification_data in payload.items(): + send_email_notification.delay( + issue_id=issue_id, + notification_data=notification_data, + receiver_id=receiver_id, + email_notification_ids=email_notification_ids, + ) + + # Update the email notification log + EmailNotificationLog.objects.filter(pk__in=processed_notifications).update( + processed_at=timezone.now() + ) + + +def create_payload(notification_data): + # return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }} + data = {} + for actor_id, changes in notification_data.items(): + for change in changes: + issue_activity = change.get("issue_activity") + if issue_activity: # Ensure issue_activity is not None + field = issue_activity.get("field") + old_value = str(issue_activity.get("old_value")) + new_value = str(issue_activity.get("new_value")) + + # Append old_value if it's not empty and not already in the list + if old_value: + data.setdefault(actor_id, {}).setdefault( + field, {} + ).setdefault("old_value", []).append( + old_value + ) if old_value not in data.setdefault( + actor_id, {} + ).setdefault( + field, {} + ).get( + "old_value", [] + ) else None + + # Append new_value if it's not empty and not already in the list + if new_value: + data.setdefault(actor_id, {}).setdefault( + field, {} + ).setdefault("new_value", []).append( + new_value + ) if new_value not in data.setdefault( + actor_id, {} + ).setdefault( + field, {} + ).get( + "new_value", [] + ) else None + + if not data.get("actor_id", {}).get("activity_time", False): + data[actor_id]["activity_time"] = str( + datetime.fromisoformat( + issue_activity.get("activity_time").rstrip("Z") + ).strftime("%Y-%m-%d %H:%M:%S") + ) + + return data + + +@shared_task +def send_email_notification( + issue_id, notification_data, receiver_id, email_notification_ids +): + 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() + + 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) + 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, + }, + } + ) + 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" + + # 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)}", + "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", + ) + + 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()) + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/bgtasks/event_tracking_task.py b/apiserver/plane/bgtasks/event_tracking_task.py index 7d26dd4aba2..82a8281a95b 100644 --- a/apiserver/plane/bgtasks/event_tracking_task.py +++ b/apiserver/plane/bgtasks/event_tracking_task.py @@ -40,22 +40,24 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time): email, event=event_name, properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": { - "ip": ip, - "user_agent": user_agent, - }, - "medium": medium, - "first_time": first_time - } + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "medium": medium, + "first_time": first_time, + }, ) except Exception as e: capture_exception(e) - + @shared_task -def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): +def workspace_invite_event( + user, email, user_agent, ip, event_name, accepted_from +): try: POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() @@ -65,14 +67,14 @@ def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_fro email, event=event_name, properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": { - "ip": ip, - "user_agent": user_agent, - }, - "accepted_from": accepted_from - } + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "accepted_from": accepted_from, + }, ) except Exception as e: - capture_exception(e) \ No newline at end of file + capture_exception(e) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index e895b859de8..b99e4b1d944 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -68,7 +68,9 @@ def create_zip_file(files): def upload_to_s3(zip_file, workspace_id, token_id, slug): - file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" + file_name = ( + f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" + ) expires_in = 7 * 24 * 60 * 60 if settings.USE_MINIO: @@ -87,12 +89,15 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): ) presigned_url = s3.generate_presigned_url( "get_object", - Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": file_name, + }, ExpiresIn=expires_in, ) # Create the new url with updated domain and protocol presigned_url = presigned_url.replace( - "http://plane-minio:9000/uploads/", + f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/", f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/", ) else: @@ -112,7 +117,10 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): presigned_url = s3.generate_presigned_url( "get_object", - Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name}, + Params={ + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Key": file_name, + }, ExpiresIn=expires_in, ) @@ -172,11 +180,17 @@ def generate_json_row(issue): else "", "Labels": issue["labels__name"], "Cycle Name": issue["issue_cycle__cycle__name"], - "Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]), + "Cycle Start Date": dateConverter( + issue["issue_cycle__cycle__start_date"] + ), "Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]), "Module Name": issue["issue_module__module__name"], - "Module Start Date": dateConverter(issue["issue_module__module__start_date"]), - "Module Target Date": dateConverter(issue["issue_module__module__target_date"]), + "Module Start Date": dateConverter( + issue["issue_module__module__start_date"] + ), + "Module Target Date": dateConverter( + issue["issue_module__module__target_date"] + ), "Created At": dateTimeConverter(issue["created_at"]), "Updated At": dateTimeConverter(issue["updated_at"]), "Completed At": dateTimeConverter(issue["completed_at"]), @@ -211,7 +225,11 @@ def update_json_row(rows, row): def update_table_row(rows, row): matched_index = next( - (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), + ( + index + for index, existing_row in enumerate(rows) + if existing_row[0] == row[0] + ), None, ) @@ -260,7 +278,9 @@ def generate_xlsx(header, project_id, issues, files): @shared_task -def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug): +def issue_export_task( + provider, workspace_id, project_ids, token_id, multiple, slug +): try: exporter_instance = ExporterHistory.objects.get(token=token_id) exporter_instance.status = "processing" @@ -273,9 +293,14 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s project_id__in=project_ids, project__project_projectmember__member=exporter_instance.initiated_by_id, ) - .select_related("project", "workspace", "state", "parent", "created_by") + .select_related( + "project", "workspace", "state", "parent", "created_by" + ) .prefetch_related( - "assignees", "labels", "issue_cycle__cycle", "issue_module__module" + "assignees", + "labels", + "issue_cycle__cycle", + "issue_module__module", ) .values( "id", diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index 30b638c84c8..d408c6476f9 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -19,7 +19,8 @@ def delete_old_s3_link(): # Get a list of keys and IDs to process expired_exporter_history = ExporterHistory.objects.filter( - Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8)) + Q(url__isnull=False) + & Q(created_at__lte=timezone.now() - timedelta(days=8)) ).values_list("key", "id") if settings.USE_MINIO: s3 = boto3.client( @@ -42,8 +43,12 @@ def delete_old_s3_link(): # Delete object from S3 if file_name: if settings.USE_MINIO: - s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) + s3.delete_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name + ) else: - s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name) + s3.delete_object( + Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name + ) ExporterHistory.objects.filter(id=exporter_id).update(url=None) diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py index 339d245837b..e372355efb6 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -14,10 +14,10 @@ @shared_task def delete_file_asset(): - # file assets to delete file_assets_to_delete = FileAsset.objects.filter( - Q(is_deleted=True) & Q(updated_at__lte=timezone.now() - timedelta(days=7)) + Q(is_deleted=True) + & Q(updated_at__lte=timezone.now() - timedelta(days=7)) ) # Delete the file from storage and the file object from the database @@ -26,4 +26,3 @@ def delete_file_asset(): file_asset.asset.delete(save=False) # Delete the file object file_asset.delete() - diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index d790f845dff..a2ac62927d0 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -21,7 +21,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): try: relative_link = ( - f"/accounts/password/?uidb64={uidb64}&token={token}&email={email}" + f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" ) abs_url = str(current_site) + relative_link @@ -42,7 +42,9 @@ def forgot_password(first_name, email, uidb64, token, current_site): "email": email, } - html_content = render_to_string("emails/auth/forgot_password.html", context) + html_content = render_to_string( + "emails/auth/forgot_password.html", context + ) text_content = strip_tags(html_content) diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 84d10ecd31b..42152136358 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -24,8 +24,8 @@ Label, User, IssueProperty, + UserNotificationPreference, ) -from plane.bgtasks.user_welcome_task import send_welcome_slack @shared_task @@ -51,10 +51,15 @@ def service_importer(service, importer_id): for user in users if user.get("import", False) == "invite" ], - batch_size=10, + batch_size=100, ignore_conflicts=True, ) + _ = UserNotificationPreference.objects.bulk_create( + [UserNotificationPreference(user=user) for user in new_users], + batch_size=100, + ) + _ = [ send_welcome_slack.delay( str(user.id), @@ -130,12 +135,17 @@ def service_importer(service, importer_id): repository_id = importer.metadata.get("repository_id", False) workspace_integration = WorkspaceIntegration.objects.get( - workspace_id=importer.workspace_id, integration__provider="github" + workspace_id=importer.workspace_id, + integration__provider="github", ) # Delete the old repository object - GithubRepositorySync.objects.filter(project_id=importer.project_id).delete() - GithubRepository.objects.filter(project_id=importer.project_id).delete() + GithubRepositorySync.objects.filter( + project_id=importer.project_id + ).delete() + GithubRepository.objects.filter( + project_id=importer.project_id + ).delete() # Create a Label for github label = Label.objects.filter( diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 5d4c0650c30..b9f6bd41103 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,9 +24,11 @@ IssueReaction, CommentReaction, IssueComment, + IssueSubscriber, ) from plane.app.serializers import IssueActivitySerializer from plane.bgtasks.notification_task import notifications +from plane.settings.redis import redis_instance # Track Changes in name @@ -111,9 +113,17 @@ def track_parent( issue_activities, epoch, ): - if current_instance.get("parent") != requested_data.get("parent"): - old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() if current_instance.get("parent") is not None else None - new_parent = Issue.objects.filter(pk=requested_data.get("parent")).first() if requested_data.get("parent") is not None else None + if current_instance.get("parent_id") != requested_data.get("parent_id"): + old_parent = ( + Issue.objects.filter(pk=current_instance.get("parent_id")).first() + if current_instance.get("parent_id") is not None + else None + ) + new_parent = ( + Issue.objects.filter(pk=requested_data.get("parent_id")).first() + if requested_data.get("parent_id") is not None + else None + ) issue_activities.append( IssueActivity( @@ -130,8 +140,12 @@ def track_parent( project_id=project_id, workspace_id=workspace_id, comment=f"updated the parent issue to", - old_identifier=old_parent.id if old_parent is not None else None, - new_identifier=new_parent.id if new_parent is not None else None, + old_identifier=old_parent.id + if old_parent is not None + else None, + new_identifier=new_parent.id + if new_parent is not None + else None, epoch=epoch, ) ) @@ -176,9 +190,11 @@ def track_state( issue_activities, epoch, ): - if current_instance.get("state") != requested_data.get("state"): - new_state = State.objects.get(pk=requested_data.get("state", None)) - old_state = State.objects.get(pk=current_instance.get("state", None)) + if current_instance.get("state_id") != requested_data.get("state_id"): + new_state = State.objects.get(pk=requested_data.get("state_id", None)) + old_state = State.objects.get( + pk=current_instance.get("state_id", None) + ) issue_activities.append( IssueActivity( @@ -209,7 +225,9 @@ def track_target_date( issue_activities, epoch, ): - if current_instance.get("target_date") != requested_data.get("target_date"): + if current_instance.get("target_date") != requested_data.get( + "target_date" + ): issue_activities.append( IssueActivity( issue_id=issue_id, @@ -273,8 +291,12 @@ def track_labels( issue_activities, epoch, ): - requested_labels = set([str(lab) for lab in requested_data.get("labels", [])]) - current_labels = set([str(lab) for lab in current_instance.get("labels", [])]) + requested_labels = set( + [str(lab) for lab in requested_data.get("label_ids", [])] + ) + current_labels = set( + [str(lab) for lab in current_instance.get("label_ids", [])] + ) added_labels = requested_labels - current_labels dropped_labels = current_labels - requested_labels @@ -331,12 +353,17 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set([str(asg) for asg in requested_data.get("assignees", [])]) - current_assignees = set([str(asg) for asg in current_instance.get("assignees", [])]) + requested_assignees = set( + [str(asg) for asg in requested_data.get("assignee_ids", [])] + ) + current_assignees = set( + [str(asg) for asg in current_instance.get("assignee_ids", [])] + ) added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees + bulk_subscribers = [] for added_asignee in added_assignees: assignee = User.objects.get(pk=added_asignee) issue_activities.append( @@ -354,6 +381,21 @@ def track_assignees( epoch=epoch, ) ) + bulk_subscribers.append( + IssueSubscriber( + subscriber_id=assignee.id, + issue_id=issue_id, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=assignee.id, + updated_by_id=assignee.id, + ) + ) + + # Create assignees subscribers to the issue and ignore if already + IssueSubscriber.objects.bulk_create( + bulk_subscribers, batch_size=10, ignore_conflicts=True + ) for dropped_assignee in dropped_assginees: assignee = User.objects.get(pk=dropped_assignee) @@ -384,7 +426,9 @@ def track_estimate_points( issue_activities, epoch, ): - if current_instance.get("estimate_point") != requested_data.get("estimate_point"): + if current_instance.get("estimate_point") != requested_data.get( + "estimate_point" + ): issue_activities.append( IssueActivity( issue_id=issue_id, @@ -415,7 +459,9 @@ def track_archive_at( issue_activities, epoch, ): - if current_instance.get("archived_at") != requested_data.get("archived_at"): + if current_instance.get("archived_at") != requested_data.get( + "archived_at" + ): if requested_data.get("archived_at") is None: issue_activities.append( IssueActivity( @@ -515,20 +561,22 @@ def update_issue_activity( ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, - "parent": track_parent, + "parent_id": track_parent, "priority": track_priority, - "state": track_state, + "state_id": track_state, "description_html": track_description, "target_date": track_target_date, "start_date": track_start_date, - "labels": track_labels, - "assignees": track_assignees, + "label_ids": track_labels, + "assignee_ids": track_assignees, "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, } - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -581,7 +629,9 @@ def create_comment_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -613,12 +663,16 @@ def update_comment_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance.get("comment_html") != requested_data.get("comment_html"): + if current_instance.get("comment_html") != requested_data.get( + "comment_html" + ): issue_activities.append( IssueActivity( issue_id=issue_id, @@ -672,14 +726,18 @@ def create_cycle_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) # Updated Records: updated_records = current_instance.get("updated_cycle_issues", []) - created_records = json.loads(current_instance.get("created_cycle_issues", [])) + created_records = json.loads( + current_instance.get("created_cycle_issues", []) + ) for updated_record in updated_records: old_cycle = Cycle.objects.filter( @@ -714,7 +772,9 @@ def create_cycle_issue_activity( cycle = Cycle.objects.filter( pk=created_record.get("fields").get("cycle") ).first() - issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first() + issue = Issue.objects.filter( + pk=created_record.get("fields").get("issue") + ).first() if issue: issue.updated_at = timezone.now() issue.save(update_fields=["updated_at"]) @@ -746,7 +806,9 @@ def delete_cycle_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -788,67 +850,29 @@ def create_module_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None ) - - # Updated Records: - updated_records = current_instance.get("updated_module_issues", []) - created_records = json.loads(current_instance.get("created_module_issues", [])) - - for updated_record in updated_records: - old_module = Module.objects.filter( - pk=updated_record.get("old_module_id", None) - ).first() - new_module = Module.objects.filter( - pk=updated_record.get("new_module_id", None) - ).first() - issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() - if issue: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - - issue_activities.append( - IssueActivity( - issue_id=updated_record.get("issue_id"), - actor_id=actor_id, - verb="updated", - old_value=old_module.name, - new_value=new_module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"updated module to ", - old_identifier=old_module.id, - new_identifier=new_module.id, - epoch=epoch, - ) - ) - - for created_record in created_records: - module = Module.objects.filter( - pk=created_record.get("fields").get("module") - ).first() - issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first() - if issue: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - issue_activities.append( - IssueActivity( - issue_id=created_record.get("fields").get("issue"), - actor_id=actor_id, - verb="created", - old_value="", - new_value=module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"added module {module.name}", - new_identifier=module.id, - epoch=epoch, - ) + module = Module.objects.filter(pk=requested_data.get("module_id")).first() + issue = Issue.objects.filter(pk=issue_id).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="created", + old_value="", + new_value=module.name, + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added module {module.name}", + new_identifier=requested_data.get("module_id"), + epoch=epoch, ) + ) def delete_module_issue_activity( @@ -861,36 +885,32 @@ def delete_module_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - - module_id = requested_data.get("module_id", "") - module_name = requested_data.get("module_name", "") - module = Module.objects.filter(pk=module_id).first() - issues = requested_data.get("issues") - - for issue in issues: - current_issue = Issue.objects.filter(pk=issue).first() - if issue: - current_issue.updated_at = timezone.now() - current_issue.save(update_fields=["updated_at"]) - issue_activities.append( - IssueActivity( - issue_id=issue, - actor_id=actor_id, - verb="deleted", - old_value=module.name if module is not None else module_name, - new_value="", - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"removed this issue from {module.name if module is not None else module_name}", - old_identifier=module_id if module_id is not None else None, - epoch=epoch, - ) + module_name = current_instance.get("module_name") + current_issue = Issue.objects.filter(pk=issue_id).first() + if current_issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=module_name, + new_value="", + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from {module_name}", + old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None, + epoch=epoch, ) + ) def create_link_activity( @@ -903,7 +923,9 @@ def create_link_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -934,7 +956,9 @@ def update_link_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -998,7 +1022,9 @@ def create_attachment_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1053,7 +1079,9 @@ def create_issue_reaction_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) if requested_data and requested_data.get("reaction") is not None: issue_reaction = ( IssueReaction.objects.filter( @@ -1125,7 +1153,9 @@ def create_comment_reaction_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) if requested_data and requested_data.get("reaction") is not None: comment_reaction_id, comment_id = ( CommentReaction.objects.filter( @@ -1136,7 +1166,9 @@ def create_comment_reaction_activity( .values_list("id", "comment__id") .first() ) - comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) + comment = IssueComment.objects.get( + pk=comment_id, project_id=project_id + ) if ( comment is not None and comment_reaction_id is not None @@ -1210,7 +1242,9 @@ def create_issue_vote_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) if requested_data and requested_data.get("vote") is not None: issue_activities.append( IssueActivity( @@ -1272,44 +1306,48 @@ def create_issue_relation_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance is None and requested_data.get("related_list") is not None: - for issue_relation in requested_data.get("related_list"): - if issue_relation.get("relation_type") == "blocked_by": - relation_type = "blocking" - else: - relation_type = issue_relation.get("relation_type") - issue = Issue.objects.get(pk=issue_relation.get("issue")) + if current_instance is None and requested_data.get("issues") is not None: + for related_issue in requested_data.get("issues"): + issue = Issue.objects.get(pk=related_issue) issue_activities.append( IssueActivity( - issue_id=issue_relation.get("related_issue"), + issue_id=issue_id, actor_id=actor_id, verb="created", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field=relation_type, + field=requested_data.get("relation_type"), project_id=project_id, workspace_id=workspace_id, - comment=f"added {relation_type} relation", - old_identifier=issue_relation.get("issue"), + comment=f"added {requested_data.get('relation_type')} relation", + old_identifier=related_issue, ) ) - issue = Issue.objects.get(pk=issue_relation.get("related_issue")) + issue = Issue.objects.get(pk=issue_id) issue_activities.append( IssueActivity( - issue_id=issue_relation.get("issue"), + issue_id=related_issue, actor_id=actor_id, verb="created", old_value="", new_value=f"{issue.project.identifier}-{issue.sequence_id}", - field=f'{issue_relation.get("relation_type")}', + field="blocking" + if requested_data.get("relation_type") == "blocked_by" + else ( + "blocked_by" + if requested_data.get("relation_type") == "blocking" + else requested_data.get("relation_type") + ), project_id=project_id, workspace_id=workspace_id, - comment=f'added {issue_relation.get("relation_type")} relation', - old_identifier=issue_relation.get("related_issue"), + comment=f'added {"blocking" if requested_data.get("relation_type") == "blocked_by" else ("blocked_by" if requested_data.get("relation_type") == "blocking" else requested_data.get("relation_type")),} relation', + old_identifier=issue_id, epoch=epoch, ) ) @@ -1325,47 +1363,50 @@ def delete_issue_relation_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - if current_instance is not None and requested_data.get("related_list") is None: - if current_instance.get("relation_type") == "blocked_by": - relation_type = "blocking" - else: - relation_type = current_instance.get("relation_type") - issue = Issue.objects.get(pk=current_instance.get("issue")) - issue_activities.append( - IssueActivity( - issue_id=current_instance.get("related_issue"), - actor_id=actor_id, - verb="deleted", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field=relation_type, - project_id=project_id, - workspace_id=workspace_id, - comment=f"deleted {relation_type} relation", - old_identifier=current_instance.get("issue"), - epoch=epoch, - ) + issue = Issue.objects.get(pk=requested_data.get("related_issue")) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", + new_value="", + field=requested_data.get("relation_type"), + project_id=project_id, + workspace_id=workspace_id, + comment=f"deleted {requested_data.get('relation_type')} relation", + old_identifier=requested_data.get("related_issue"), + epoch=epoch, ) - issue = Issue.objects.get(pk=current_instance.get("related_issue")) - issue_activities.append( - IssueActivity( - issue_id=current_instance.get("issue"), - actor_id=actor_id, - verb="deleted", - old_value=f"{issue.project.identifier}-{issue.sequence_id}", - new_value="", - field=f'{current_instance.get("relation_type")}', - project_id=project_id, - workspace_id=workspace_id, - comment=f'deleted {current_instance.get("relation_type")} relation', - old_identifier=current_instance.get("related_issue"), - epoch=epoch, - ) + ) + issue = Issue.objects.get(pk=issue_id) + issue_activities.append( + IssueActivity( + issue_id=requested_data.get("related_issue"), + actor_id=actor_id, + verb="deleted", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", + new_value="", + field="blocking" + if requested_data.get("relation_type") == "blocked_by" + else ( + "blocked_by" + if requested_data.get("relation_type") == "blocking" + else requested_data.get("relation_type") + ), + project_id=project_id, + workspace_id=workspace_id, + comment=f'deleted {requested_data.get("relation_type")} relation', + old_identifier=requested_data.get("related_issue"), + epoch=epoch, ) + ) def create_draft_issue_activity( @@ -1402,7 +1443,9 @@ def update_draft_issue_activity( issue_activities, epoch, ): - requested_data = json.loads(requested_data) if requested_data is not None else None + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) current_instance = ( json.loads(current_instance) if current_instance is not None else None ) @@ -1470,6 +1513,8 @@ def issue_activity( project_id, epoch, subscriber=True, + notification=False, + origin=None, ): try: issue_activities = [] @@ -1478,6 +1523,10 @@ def issue_activity( workspace_id = project.workspace_id if issue_id is not None: + if origin: + ri = redis_instance() + # set the request origin in redis + ri.set(str(issue_id), origin, ex=600) issue = Issue.objects.filter(pk=issue_id).first() if issue: try: @@ -1529,7 +1578,9 @@ def issue_activity( ) # Save all the values to database - issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + issue_activities_created = IssueActivity.objects.bulk_create( + issue_activities + ) # Post the updates to segway for integrations and webhooks if len(issue_activities_created): # Don't send activities if the actor is a bot @@ -1549,19 +1600,22 @@ def issue_activity( except Exception as e: capture_exception(e) - notifications.delay( - type=type, - issue_id=issue_id, - actor_id=actor_id, - project_id=project_id, - subscriber=subscriber, - issue_activities_created=json.dumps( - IssueActivitySerializer(issue_activities_created, many=True).data, - cls=DjangoJSONEncoder, - ), - requested_data=requested_data, - current_instance=current_instance, - ) + if notification: + notifications.delay( + type=type, + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + subscriber=subscriber, + issue_activities_created=json.dumps( + IssueActivitySerializer( + issue_activities_created, many=True + ).data, + cls=DjangoJSONEncoder, + ), + requested_data=requested_data, + current_instance=current_instance, + ) return except Exception as e: diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 6a09b08bab1..974a545fcdd 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -36,7 +36,9 @@ def archive_old_issues(): Q( project=project_id, archived_at__isnull=True, - updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)), + updated_at__lte=( + timezone.now() - timedelta(days=archive_in * 30) + ), state__group__in=["completed", "cancelled"], ), Q(issue_cycle__isnull=True) @@ -46,7 +48,9 @@ def archive_old_issues(): ), Q(issue_module__isnull=True) | ( - Q(issue_module__module__target_date__lt=timezone.now().date()) + Q( + issue_module__module__target_date__lt=timezone.now().date() + ) & Q(issue_module__isnull=False) ), ).filter( @@ -74,13 +78,16 @@ def archive_old_issues(): _ = [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"archived_at": str(archive_at)}), + requested_data=json.dumps( + {"archived_at": str(archive_at)} + ), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, current_instance=json.dumps({"archived_at": None}), subscriber=False, epoch=int(timezone.now().timestamp()), + notification=True, ) for issue in issues_to_update ] @@ -108,7 +115,9 @@ def close_old_issues(): Q( project=project_id, archived_at__isnull=True, - updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)), + updated_at__lte=( + timezone.now() - timedelta(days=close_in * 30) + ), state__group__in=["backlog", "unstarted", "started"], ), Q(issue_cycle__isnull=True) @@ -118,7 +127,9 @@ def close_old_issues(): ), Q(issue_module__isnull=True) | ( - Q(issue_module__module__target_date__lt=timezone.now().date()) + Q( + issue_module__module__target_date__lt=timezone.now().date() + ) & Q(issue_module__isnull=False) ), ).filter( @@ -131,7 +142,9 @@ def close_old_issues(): # Check if Issues if issues: if project.default_state is None: - close_state = State.objects.filter(group="cancelled").first() + close_state = State.objects.filter( + group="cancelled" + ).first() else: close_state = project.default_state @@ -157,6 +170,7 @@ def close_old_issues(): current_instance=None, subscriber=False, epoch=int(timezone.now().timestamp()), + notification=True, ) for issue in issues_to_update ] @@ -165,4 +179,4 @@ def close_old_issues(): if settings.DEBUG: print(e) capture_exception(e) - return \ No newline at end of file + return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index bb61e0adac1..b94ec4bfe23 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -33,7 +33,9 @@ def magic_link(email, key, token, current_site): subject = f"Your unique Plane login code is {token}" context = {"code": token, "email": email} - html_content = render_to_string("emails/auth/magic_signin.html", context) + html_content = render_to_string( + "emails/auth/magic_signin.html", context + ) text_content = strip_tags(html_content) connection = get_connection( diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 4bc27d3ee09..6cfbec72a96 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -10,9 +10,12 @@ User, IssueAssignee, Issue, + State, + EmailNotificationLog, Notification, IssueComment, - IssueActivity + IssueActivity, + UserNotificationPreference, ) # Third Party imports @@ -20,8 +23,8 @@ from bs4 import BeautifulSoup - -# =========== Issue Description Html Parsing and Notification Functions ====================== +# =========== Issue Description Html Parsing and notification Functions ====================== + def update_mentions_for_issue(issue, project, new_mentions, removed_mention): aggregated_issue_mentions = [] @@ -32,14 +35,12 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention): mention_id=mention_id, issue=issue, project=project, - workspace_id=project.workspace_id + workspace_id=project.workspace_id, ) ) - IssueMention.objects.bulk_create( - aggregated_issue_mentions, batch_size=100) - IssueMention.objects.filter( - issue=issue, mention__in=removed_mention).delete() + IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100) + IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete() def get_new_mentions(requested_instance, current_instance): @@ -48,18 +49,18 @@ def get_new_mentions(requested_instance, current_instance): # extract mentions from both the instance of data mentions_older = extract_mentions(current_instance) - + mentions_newer = extract_mentions(requested_instance) # Getting Set Difference from mentions_newer new_mentions = [ - mention for mention in mentions_newer if mention not in mentions_older] + mention for mention in mentions_newer if mention not in mentions_older + ] return new_mentions -# Get Removed Mention - +# Get Removed Mention def get_removed_mentions(requested_instance, current_instance): # requested_data is the newer instance of the current issue # current_instance is the older instance of the current issue, saved in the database @@ -70,13 +71,13 @@ def get_removed_mentions(requested_instance, current_instance): # Getting Set Difference from mentions_newer removed_mentions = [ - mention for mention in mentions_older if mention not in mentions_newer] + mention for mention in mentions_older if mention not in mentions_newer + ] return removed_mentions -# Adds mentions as subscribers - +# Adds mentions as subscribers def extract_mentions_as_subscribers(project_id, issue_id, mentions): # mentions is an array of User IDs representing the FILTERED set of mentioned users @@ -84,27 +85,32 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): for mention_id in mentions: # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification - if not IssueSubscriber.objects.filter( - issue_id=issue_id, - subscriber_id=mention_id, - project_id=project_id, - ).exists() and not IssueAssignee.objects.filter( - project_id=project_id, issue_id=issue_id, - assignee_id=mention_id - ).exists() and not Issue.objects.filter( - project_id=project_id, pk=issue_id, created_by_id=mention_id - ).exists(): - - project = Project.objects.get(pk=project_id) - - bulk_mention_subscribers.append(IssueSubscriber( - workspace_id=project.workspace_id, - project_id=project_id, + if ( + not IssueSubscriber.objects.filter( issue_id=issue_id, subscriber_id=mention_id, - )) + project_id=project_id, + ).exists() + and not IssueAssignee.objects.filter( + project_id=project_id, issue_id=issue_id, assignee_id=mention_id + ).exists() + and not Issue.objects.filter( + project_id=project_id, pk=issue_id, created_by_id=mention_id + ).exists() + ): + project = Project.objects.get(pk=project_id) + + bulk_mention_subscribers.append( + IssueSubscriber( + workspace_id=project.workspace_id, + project_id=project_id, + issue_id=issue_id, + subscriber_id=mention_id, + ) + ) return bulk_mention_subscribers + # Parse Issue Description & extracts mentions def extract_mentions(issue_instance): try: @@ -113,46 +119,46 @@ def extract_mentions(issue_instance): # Convert string to dictionary data = json.loads(issue_instance) html = data.get("description_html") - soup = BeautifulSoup(html, 'html.parser') - mention_tags = soup.find_all( - 'mention-component', attrs={'target': 'users'}) + soup = BeautifulSoup(html, "html.parser") + mention_tags = soup.find_all("mention-component", attrs={"target": "users"}) - mentions = [mention_tag['id'] for mention_tag in mention_tags] + mentions = [mention_tag["id"] for mention_tag in mention_tags] return list(set(mentions)) except Exception as e: return [] - - -# =========== Comment Parsing and Notification Functions ====================== + + +# =========== Comment Parsing and notification Functions ====================== def extract_comment_mentions(comment_value): try: mentions = [] - soup = BeautifulSoup(comment_value, 'html.parser') - mentions_tags = soup.find_all( - 'mention-component', attrs={'target': 'users'} - ) + soup = BeautifulSoup(comment_value, "html.parser") + mentions_tags = soup.find_all("mention-component", attrs={"target": "users"}) for mention_tag in mentions_tags: - mentions.append(mention_tag['id']) + mentions.append(mention_tag["id"]) return list(set(mentions)) except Exception as e: return [] - + + def get_new_comment_mentions(new_value, old_value): - mentions_newer = extract_comment_mentions(new_value) if old_value is None: return mentions_newer - + mentions_older = extract_comment_mentions(old_value) # Getting Set Difference from mentions_newer new_mentions = [ - mention for mention in mentions_newer if mention not in mentions_older] + mention for mention in mentions_newer if mention not in mentions_older + ] return new_mentions -def createMentionNotification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity): +def create_mention_notification( + project, notification_comment, issue, actor_id, mention_id, issue_id, activity +): return Notification( workspace=project.workspace, sender="in_app:issue_activities:mentioned", @@ -178,242 +184,538 @@ def createMentionNotification(project, notification_comment, issue, actor_id, me "actor": str(activity.get("actor_id")), "new_value": str(activity.get("new_value")), "old_value": str(activity.get("old_value")), - } + }, }, ) @shared_task -def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance): - issue_activities_created = ( - json.loads( - issue_activities_created) if issue_activities_created is not None else None - ) - if type not in [ - "issue.activity.deleted", - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "module.activity.deleted", - "issue_reaction.activity.created", - "issue_reaction.activity.deleted", - "comment_reaction.activity.created", - "comment_reaction.activity.deleted", - "issue_vote.activity.created", - "issue_vote.activity.deleted", - "issue_draft.activity.created", - "issue_draft.activity.updated", - "issue_draft.activity.deleted", - ]: - # Create Notifications - bulk_notifications = [] - - """ - Mention Tasks - 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent - 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers - """ - - # Get new mentions from the newer instance - new_mentions = get_new_mentions( - requested_instance=requested_data, current_instance=current_instance) - removed_mention = get_removed_mentions( - requested_instance=requested_data, current_instance=current_instance) - - comment_mentions = [] - all_comment_mentions = [] - - # Get New Subscribers from the mentions of the newer instance - requested_mentions = extract_mentions( - issue_instance=requested_data) - mention_subscribers = extract_mentions_as_subscribers( - project_id=project_id, issue_id=issue_id, mentions=requested_mentions) - - for issue_activity in issue_activities_created: - issue_comment = issue_activity.get("issue_comment") - issue_comment_new_value = issue_activity.get("new_value") - issue_comment_old_value = issue_activity.get("old_value") - if issue_comment is not None: - # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. - - all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value) - - new_comment_mentions = get_new_comment_mentions(old_value=issue_comment_old_value, new_value=issue_comment_new_value) - comment_mentions = comment_mentions + new_comment_mentions - - comment_mention_subscribers = extract_mentions_as_subscribers( project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions) - """ - We will not send subscription activity notification to the below mentioned user sets - - Those who have been newly mentioned in the issue description, we will send mention notification to them. - - When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification - - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification - """ - - issue_assignees = list( - IssueAssignee.objects.filter( - project_id=project_id, issue_id=issue_id) - .exclude(assignee_id__in=list(new_mentions + comment_mentions)) - .values_list("assignee", flat=True) - ) - - issue_subscribers = list( - IssueSubscriber.objects.filter( - project_id=project_id, issue_id=issue_id) - .exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])) - .values_list("subscriber", flat=True) +def notifications( + type, + issue_id, + project_id, + actor_id, + subscriber, + issue_activities_created, + requested_data, + current_instance, +): + try: + issue_activities_created = ( + json.loads(issue_activities_created) + if issue_activities_created is not None + else None ) + if type not in [ + "issue.activity.deleted", + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", + "issue_draft.activity.created", + "issue_draft.activity.updated", + "issue_draft.activity.deleted", + ]: + # Create Notifications + bulk_notifications = [] + bulk_email_logs = [] + + """ + Mention Tasks + 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent + 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers + """ + + # Get new mentions from the newer instance + new_mentions = get_new_mentions( + requested_instance=requested_data, + current_instance=current_instance, + ) + removed_mention = get_removed_mentions( + requested_instance=requested_data, + current_instance=current_instance, + ) - issue = Issue.objects.filter(pk=issue_id).first() + comment_mentions = [] + all_comment_mentions = [] - if (issue.created_by_id is not None and str(issue.created_by_id) != str(actor_id)): - issue_subscribers = issue_subscribers + [issue.created_by_id] + # Get New Subscribers from the mentions of the newer instance + requested_mentions = extract_mentions( + issue_instance=requested_data + ) + mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, + issue_id=issue_id, + mentions=requested_mentions, + ) - if subscriber: - # add the user to issue subscriber - try: - if str(issue.created_by_id) != str(actor_id) and uuid.UUID(actor_id) not in issue_assignees: + for issue_activity in issue_activities_created: + issue_comment = issue_activity.get("issue_comment") + issue_comment_new_value = issue_activity.get("new_value") + issue_comment_old_value = issue_activity.get("old_value") + if issue_comment is not None: + # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. + + all_comment_mentions = ( + all_comment_mentions + + extract_comment_mentions(issue_comment_new_value) + ) + + new_comment_mentions = get_new_comment_mentions( + old_value=issue_comment_old_value, + new_value=issue_comment_new_value, + ) + comment_mentions = comment_mentions + new_comment_mentions + + comment_mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, + issue_id=issue_id, + mentions=all_comment_mentions, + ) + """ + We will not send subscription activity notification to the below mentioned user sets + - Those who have been newly mentioned in the issue description, we will send mention notification to them. + - When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification + - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification + """ + + # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- # + issue_subscribers = list( + IssueSubscriber.objects.filter( + project_id=project_id, issue_id=issue_id + ) + .exclude( + subscriber_id__in=list( + new_mentions + comment_mentions + [actor_id] + ) + ) + .values_list("subscriber", flat=True) + ) + + issue = Issue.objects.filter(pk=issue_id).first() + + if subscriber: + # add the user to issue subscriber + try: _ = IssueSubscriber.objects.get_or_create( project_id=project_id, issue_id=issue_id, subscriber_id=actor_id ) - except Exception as e: - pass + except Exception as e: + pass - project = Project.objects.get(pk=project_id) + project = Project.objects.get(pk=project_id) - issue_subscribers = list(set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}) + issue_assignees = IssueAssignee.objects.filter( + issue_id=issue_id, project_id=project_id + ).values_list("assignee", flat=True) - for subscriber in issue_subscribers: - if subscriber in issue_subscribers: - sender = "in_app:issue_activities:subscribed" - if issue.created_by_id is not None and subscriber == issue.created_by_id: - sender = "in_app:issue_activities:created" - if subscriber in issue_assignees: - sender = "in_app:issue_activities:assigned" + issue_subscribers = list( + set(issue_subscribers) - {uuid.UUID(actor_id)} + ) - for issue_activity in issue_activities_created: - issue_comment = issue_activity.get("issue_comment") - if issue_comment is not None: - issue_comment = IssueComment.objects.get( - id=issue_comment, issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id) - - bulk_notifications.append( - Notification( - workspace=project.workspace, - sender=sender, - triggered_by_id=actor_id, - receiver_id=subscriber, - entity_identifier=issue_id, - entity_name="issue", - project=project, - title=issue_activity.get("comment"), - data={ - "issue": { - "id": str(issue_id), - "name": str(issue.name), - "identifier": str(issue.project.identifier), - "sequence_id": issue.sequence_id, - "state_name": issue.state.name, - "state_group": issue.state.group, - }, - "issue_activity": { - "id": str(issue_activity.get("id")), - "verb": str(issue_activity.get("verb")), - "field": str(issue_activity.get("field")), - "actor": str(issue_activity.get("actor_id")), - "new_value": str(issue_activity.get("new_value")), - "old_value": str(issue_activity.get("old_value")), - "issue_comment": str( - issue_comment.comment_stripped - if issue_activity.get("issue_comment") is not None - else "" - ), - }, - }, - ) - ) + for subscriber in issue_subscribers: + if issue.created_by_id and issue.created_by_id == subscriber: + sender = "in_app:issue_activities:created" + elif ( + subscriber in issue_assignees + and issue.created_by_id not in issue_assignees + ): + sender = "in_app:issue_activities:assigned" + else: + sender = "in_app:issue_activities:subscribed" - # Add Mentioned as Issue Subscribers - IssueSubscriber.objects.bulk_create( - mention_subscribers + comment_mention_subscribers, batch_size=100) + preference = UserNotificationPreference.objects.get( + user_id=subscriber + ) - last_activity = ( - IssueActivity.objects.filter(issue_id=issue_id) - .order_by("-created_at") - .first() - ) - - actor = User.objects.get(pk=actor_id) - - for mention_id in comment_mentions: - if (mention_id != actor_id): - for issue_activity in issue_activities_created: - notification = createMentionNotification( - project=project, - issue=issue, - notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", - actor_id=actor_id, - mention_id=mention_id, - issue_id=issue_id, - activity=issue_activity - ) - bulk_notifications.append(notification) + for issue_activity in issue_activities_created: + # If activity done in blocking then blocked by email should not go + if issue_activity.get("issue_detail").get("id") != issue_id: + continue; + # Do not send notification for description update + if issue_activity.get("field") == "description": + continue + + # Check if the value should be sent or not + send_email = False + if ( + issue_activity.get("field") == "state" + and preference.state_change + ): + send_email = True + elif ( + issue_activity.get("field") == "state" + and preference.issue_completed + and State.objects.filter( + project_id=project_id, + pk=issue_activity.get("new_identifier"), + group="completed", + ).exists() + ): + send_email = True + elif ( + issue_activity.get("field") == "comment" + and preference.comment + ): + send_email = True + elif preference.property_change: + send_email = True + else: + send_email = False + + # If activity is of issue comment fetch the comment + issue_comment = ( + IssueComment.objects.filter( + id=issue_activity.get("issue_comment"), + issue_id=issue_id, + project_id=project_id, + workspace_id=project.workspace_id, + ).first() + if issue_activity.get("issue_comment") + else None + ) - for mention_id in new_mentions: - if (mention_id != actor_id): - if ( - last_activity is not None - and last_activity.field == "description" - and actor_id == str(last_activity.actor_id) - ): + # Create in app notification bulk_notifications.append( - Notification( - workspace=project.workspace, - sender="in_app:issue_activities:mentioned", + Notification( + workspace=project.workspace, + sender=sender, triggered_by_id=actor_id, - receiver_id=mention_id, + receiver_id=subscriber, entity_identifier=issue_id, entity_name="issue", project=project, - message=f"You have been mentioned in the issue {issue.name}", + title=issue_activity.get("comment"), data={ "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str(issue.project.identifier), + "identifier": str( + issue.project.identifier + ), "sequence_id": issue.sequence_id, "state_name": issue.state.name, - "state_group": issue.state.group, - }, - "issue_activity": { - "id": str(last_activity.id), - "verb": str(last_activity.verb), - "field": str(last_activity.field), - "actor": str(last_activity.actor_id), - "new_value": str(last_activity.new_value), - "old_value": str(last_activity.old_value), - }, - }, - ) - ) - else: + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str( + issue_activity.get("actor_id") + ), + "new_value": str( + issue_activity.get("new_value") + ), + "old_value": str( + issue_activity.get("old_value") + ), + "issue_comment": str( + issue_comment.comment_stripped + if issue_comment is not None + else "" + ), + }, + }, + ) + ) + # Create email notification + if send_email: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "project_id": str(issue.project.id), + "workspace_slug": str( + issue.project.workspace.slug + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str( + issue_activity.get("verb") + ), + "field": str( + issue_activity.get("field") + ), + "actor": str( + issue_activity.get("actor_id") + ), + "new_value": str( + issue_activity.get("new_value") + ), + "old_value": str( + issue_activity.get("old_value") + ), + "issue_comment": str( + issue_comment.comment_stripped + if issue_comment is not None + else "" + ), + "activity_time": issue_activity.get("created_at"), + }, + }, + ) + ) + + # ----------------------------------------------------------------------------------------------------------------- # + + # Add Mentioned as Issue Subscribers + IssueSubscriber.objects.bulk_create( + mention_subscribers + comment_mention_subscribers, + batch_size=100, + ignore_conflicts=True, + ) + + last_activity = ( + IssueActivity.objects.filter(issue_id=issue_id) + .order_by("-created_at") + .first() + ) + + actor = User.objects.get(pk=actor_id) + + for mention_id in comment_mentions: + if mention_id != actor_id: + preference = UserNotificationPreference.objects.get( + user_id=mention_id + ) for issue_activity in issue_activities_created: - notification = createMentionNotification( + notification = create_mention_notification( project=project, issue=issue, - notification_comment=f"You have been mentioned in the issue {issue.name}", + notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", actor_id=actor_id, mention_id=mention_id, issue_id=issue_id, - activity=issue_activity + activity=issue_activity, ) + + # check for email notifications + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + "project_id": str( + issue.project.id + ), + "workspace_slug": str( + issue.project.workspace.slug + ), + }, + "issue_activity": { + "id": str( + issue_activity.get("id") + ), + "verb": str( + issue_activity.get("verb") + ), + "field": str("mention"), + "actor": str( + issue_activity.get("actor_id") + ), + "new_value": str( + issue_activity.get("new_value") + ), + "old_value": str( + issue_activity.get("old_value") + ), + }, + }, + ) + ) bulk_notifications.append(notification) - # save new mentions for the particular issue and remove the mentions that has been deleted from the description - update_mentions_for_issue(issue=issue, project=project, new_mentions=new_mentions, - removed_mention=removed_mention) - - # Bulk create notifications - Notification.objects.bulk_create(bulk_notifications, batch_size=100) - - + for mention_id in new_mentions: + if mention_id != actor_id: + preference = UserNotificationPreference.objects.get( + user_id=mention_id + ) + if ( + last_activity is not None + and last_activity.field == "description" + and actor_id == str(last_activity.actor_id) + ): + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mentioned", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=f"You have been mentioned in the issue {issue.name}", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + "project_id": str(issue.project.id), + "workspace_slug": str( + issue.project.workspace.slug + ), + }, + "issue_activity": { + "id": str(last_activity.id), + "verb": str(last_activity.verb), + "field": str(last_activity.field), + "actor": str(last_activity.actor_id), + "new_value": str( + last_activity.new_value + ), + "old_value": str( + last_activity.old_value + ), + }, + }, + ) + ) + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(last_activity.id), + "verb": str(last_activity.verb), + "field": "mention", + "actor": str( + last_activity.actor_id + ), + "new_value": str( + last_activity.new_value + ), + "old_value": str( + last_activity.old_value + ), + }, + }, + ) + ) + else: + for issue_activity in issue_activities_created: + notification = create_mention_notification( + project=project, + issue=issue, + notification_comment=f"You have been mentioned in the issue {issue.name}", + actor_id=actor_id, + mention_id=mention_id, + issue_id=issue_id, + activity=issue_activity, + ) + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str( + issue_activity.get("id") + ), + "verb": str( + issue_activity.get("verb") + ), + "field": str("mention"), + "actor": str( + issue_activity.get( + "actor_id" + ) + ), + "new_value": str( + issue_activity.get( + "new_value" + ) + ), + "old_value": str( + issue_activity.get( + "old_value" + ) + ), + }, + }, + ) + ) + bulk_notifications.append(notification) + + # save new mentions for the particular issue and remove the mentions that has been deleted from the description + update_mentions_for_issue( + issue=issue, + project=project, + new_mentions=new_mentions, + removed_mention=removed_mention, + ) + # Bulk create notifications + Notification.objects.bulk_create( + bulk_notifications, batch_size=100 + ) + EmailNotificationLog.objects.bulk_create( + bulk_email_logs, batch_size=100, ignore_conflicts=True + ) + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index b9221855bd1..a986de33282 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -15,6 +15,7 @@ from plane.db.models import Project, User, ProjectMemberInvite from plane.license.utils.instance_value import get_email_configuration + @shared_task def project_invitation(email, project_id, token, current_site, invitor): try: diff --git a/apiserver/plane/bgtasks/user_welcome_task.py b/apiserver/plane/bgtasks/user_welcome_task.py deleted file mode 100644 index 33f4b568635..00000000000 --- a/apiserver/plane/bgtasks/user_welcome_task.py +++ /dev/null @@ -1,36 +0,0 @@ -# Django imports -from django.conf import settings - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - -# Module imports -from plane.db.models import User - - -@shared_task -def send_welcome_slack(user_id, created, message): - try: - instance = User.objects.get(pk=user_id) - - 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=message, - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return - except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) - return diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 3681f002ddd..34bba0cf87a 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -189,7 +189,8 @@ def send_webhook(event, payload, kw, action, slug, bulk): pk__in=[ str(event.get("issue")) for event in payload ] - ).prefetch_related("issue_cycle", "issue_module"), many=True + ).prefetch_related("issue_cycle", "issue_module"), + many=True, ).data event = "issue" action = "PATCH" @@ -197,7 +198,9 @@ def send_webhook(event, payload, kw, action, slug, bulk): event_data = [ get_model_data( event=event, - event_id=payload.get("id") if isinstance(payload, dict) else None, + event_id=payload.get("id") + if isinstance(payload, dict) + else None, many=False, ) ] diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 7039cb8755e..06dd6e8cd56 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -36,7 +36,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): # The complete url including the domain abs_url = str(current_site) + relative_link - ( EMAIL_HOST, EMAIL_HOST_USER, @@ -83,17 +82,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): msg.attach_alternative(html_content, "text/html") msg.send() - # 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"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}", - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: print("Workspace or WorkspaceMember Invite Does not exists") diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 442e7283647..0912e276af4 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -2,6 +2,7 @@ from celery import Celery from plane.settings.redis import redis_instance from celery.schedules import crontab +from django.utils.timezone import timedelta # Set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") @@ -28,6 +29,10 @@ "task": "plane.bgtasks.file_asset_task.delete_file_asset", "schedule": crontab(hour=0, minute=0), }, + "check-every-five-minutes-to-send-email-notifications": { + "task": "plane.bgtasks.email_notification_task.stack_email_notification", + "schedule": crontab(minute='*/5') + }, } # Load task modules from all registered Django app configs. diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py index 054523bf974..bdd0b7014d6 100644 --- a/apiserver/plane/db/management/commands/create_bucket.py +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -5,7 +5,8 @@ # Django imports from django.core.management import BaseCommand -from django.conf import settings +from django.conf import settings + class Command(BaseCommand): help = "Create the default bucket for the instance" @@ -13,23 +14,31 @@ class Command(BaseCommand): def set_bucket_public_policy(self, s3_client, bucket_name): public_policy = { "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"] - }] + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{bucket_name}/*"], + } + ], } try: s3_client.put_bucket_policy( - Bucket=bucket_name, - Policy=json.dumps(public_policy) + Bucket=bucket_name, Policy=json.dumps(public_policy) + ) + self.stdout.write( + self.style.SUCCESS( + f"Public read access policy set for bucket '{bucket_name}'." + ) ) - self.stdout.write(self.style.SUCCESS(f"Public read access policy set for bucket '{bucket_name}'.")) except ClientError as e: - self.stdout.write(self.style.ERROR(f"Error setting public read access policy: {e}")) - + self.stdout.write( + self.style.ERROR( + f"Error setting public read access policy: {e}" + ) + ) def handle(self, *args, **options): # Create a session using the credentials from Django settings @@ -39,7 +48,9 @@ def handle(self, *args, **options): aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, ) # Create an S3 client using the session - s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) + s3_client = session.client( + "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL + ) bucket_name = settings.AWS_STORAGE_BUCKET_NAME self.stdout.write(self.style.NOTICE("Checking bucket...")) @@ -49,23 +60,41 @@ def handle(self, *args, **options): self.set_bucket_public_policy(s3_client, bucket_name) except ClientError as e: - error_code = int(e.response['Error']['Code']) + error_code = int(e.response["Error"]["Code"]) bucket_name = settings.AWS_STORAGE_BUCKET_NAME if error_code == 404: # Bucket does not exist, create it - self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) + self.stdout.write( + self.style.WARNING( + f"Bucket '{bucket_name}' does not exist. Creating bucket..." + ) + ) try: s3_client.create_bucket(Bucket=bucket_name) - self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) + self.stdout.write( + self.style.SUCCESS( + f"Bucket '{bucket_name}' created successfully." + ) + ) self.set_bucket_public_policy(s3_client, bucket_name) except ClientError as create_error: - self.stdout.write(self.style.ERROR(f"Failed to create bucket: {create_error}")) + self.stdout.write( + self.style.ERROR( + f"Failed to create bucket: {create_error}" + ) + ) elif error_code == 403: # Access to the bucket is forbidden - self.stdout.write(self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")) + self.stdout.write( + self.style.ERROR( + f"Access to the bucket '{bucket_name}' is forbidden. Check permissions." + ) + ) else: # Another ClientError occurred - self.stdout.write(self.style.ERROR(f"Failed to check bucket: {e}")) + self.stdout.write( + self.style.ERROR(f"Failed to check bucket: {e}") + ) except Exception as ex: # Handle any other exception - self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) \ No newline at end of file + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) diff --git a/apiserver/plane/db/management/commands/reset_password.py b/apiserver/plane/db/management/commands/reset_password.py index a5b4c9cc854..d48c24b1cdb 100644 --- a/apiserver/plane/db/management/commands/reset_password.py +++ b/apiserver/plane/db/management/commands/reset_password.py @@ -35,7 +35,7 @@ def handle(self, *args, **options): # get password for the user password = getpass.getpass("Password: ") confirm_password = getpass.getpass("Password (again): ") - + # If the passwords doesn't match raise error if password != confirm_password: self.stderr.write("Error: Your passwords didn't match.") @@ -50,5 +50,7 @@ def handle(self, *args, **options): user.set_password(password) user.is_password_autoset = False user.save() - - self.stdout.write(self.style.SUCCESS(f"User password updated succesfully")) + + self.stdout.write( + self.style.SUCCESS(f"User password updated succesfully") + ) diff --git a/apiserver/plane/db/management/commands/wait_for_db.py b/apiserver/plane/db/management/commands/wait_for_db.py index 365452a7a93..ec971f83a77 100644 --- a/apiserver/plane/db/management/commands/wait_for_db.py +++ b/apiserver/plane/db/management/commands/wait_for_db.py @@ -2,18 +2,19 @@ from django.db import connections from django.db.utils import OperationalError from django.core.management import BaseCommand - + + class Command(BaseCommand): """Django command to pause execution until db is available""" - + def handle(self, *args, **options): - self.stdout.write('Waiting for database...') + self.stdout.write("Waiting for database...") db_conn = None while not db_conn: try: - db_conn = connections['default'] + db_conn = connections["default"] except OperationalError: - self.stdout.write('Database unavailable, waititng 1 second...') + self.stdout.write("Database unavailable, waititng 1 second...") time.sleep(1) - - self.stdout.write(self.style.SUCCESS('Database available!')) + + self.stdout.write(self.style.SUCCESS("Database available!")) diff --git a/apiserver/plane/db/management/commands/wait_for_migrations.py b/apiserver/plane/db/management/commands/wait_for_migrations.py new file mode 100644 index 00000000000..51f2cf33930 --- /dev/null +++ b/apiserver/plane/db/management/commands/wait_for_migrations.py @@ -0,0 +1,21 @@ +# wait_for_migrations.py +import time +from django.core.management.base import BaseCommand +from django.db.migrations.executor import MigrationExecutor +from django.db import connections, DEFAULT_DB_ALIAS + +class Command(BaseCommand): + help = 'Wait for database migrations to complete before starting Celery worker/beat' + + def handle(self, *args, **kwargs): + while self._pending_migrations(): + self.stdout.write("Waiting for database migrations to complete...") + time.sleep(10) # wait for 10 seconds before checking again + + self.stdout.write(self.style.SUCCESS("No migrations Pending. Starting processes ...")) + + def _pending_migrations(self): + connection = connections[DEFAULT_DB_ALIAS] + executor = MigrationExecutor(connection) + targets = executor.loader.graph.leaf_nodes() + return bool(executor.migration_plan(targets)) diff --git a/apiserver/plane/db/migrations/0001_initial.py b/apiserver/plane/db/migrations/0001_initial.py index dd158f0a8e8..936d33fa5f4 100644 --- a/apiserver/plane/db/migrations/0001_initial.py +++ b/apiserver/plane/db/migrations/0001_initial.py @@ -10,695 +10,2481 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='User', + name="User", fields=[ - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('username', models.CharField(max_length=128, unique=True)), - ('mobile_number', models.CharField(blank=True, max_length=255, null=True)), - ('email', models.CharField(blank=True, max_length=255, null=True, unique=True)), - ('first_name', models.CharField(blank=True, max_length=255)), - ('last_name', models.CharField(blank=True, max_length=255)), - ('avatar', models.CharField(blank=True, max_length=255)), - ('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('last_location', models.CharField(blank=True, max_length=255)), - ('created_location', models.CharField(blank=True, max_length=255)), - ('is_superuser', models.BooleanField(default=False)), - ('is_managed', models.BooleanField(default=False)), - ('is_password_expired', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=True)), - ('is_staff', models.BooleanField(default=False)), - ('is_email_verified', models.BooleanField(default=False)), - ('is_password_autoset', models.BooleanField(default=False)), - ('is_onboarded', models.BooleanField(default=False)), - ('token', models.CharField(blank=True, max_length=64)), - ('billing_address_country', models.CharField(default='INDIA', max_length=255)), - ('billing_address', models.JSONField(null=True)), - ('has_billing_address', models.BooleanField(default=False)), - ('user_timezone', models.CharField(default='Asia/Kolkata', max_length=255)), - ('last_active', models.DateTimeField(default=django.utils.timezone.now, null=True)), - ('last_login_time', models.DateTimeField(null=True)), - ('last_logout_time', models.DateTimeField(null=True)), - ('last_login_ip', models.CharField(blank=True, max_length=255)), - ('last_logout_ip', models.CharField(blank=True, max_length=255)), - ('last_login_medium', models.CharField(default='email', max_length=20)), - ('last_login_uagent', models.TextField(blank=True)), - ('token_updated_at', models.DateTimeField(null=True)), - ('last_workspace_id', models.UUIDField(null=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + "password", + models.CharField(max_length=128, verbose_name="password"), + ), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("username", models.CharField(max_length=128, unique=True)), + ( + "mobile_number", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "email", + models.CharField( + blank=True, max_length=255, null=True, unique=True + ), + ), + ("first_name", models.CharField(blank=True, max_length=255)), + ("last_name", models.CharField(blank=True, max_length=255)), + ("avatar", models.CharField(blank=True, max_length=255)), + ( + "date_joined", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "last_location", + models.CharField(blank=True, max_length=255), + ), + ( + "created_location", + models.CharField(blank=True, max_length=255), + ), + ("is_superuser", models.BooleanField(default=False)), + ("is_managed", models.BooleanField(default=False)), + ("is_password_expired", models.BooleanField(default=False)), + ("is_active", models.BooleanField(default=True)), + ("is_staff", models.BooleanField(default=False)), + ("is_email_verified", models.BooleanField(default=False)), + ("is_password_autoset", models.BooleanField(default=False)), + ("is_onboarded", models.BooleanField(default=False)), + ("token", models.CharField(blank=True, max_length=64)), + ( + "billing_address_country", + models.CharField(default="INDIA", max_length=255), + ), + ("billing_address", models.JSONField(null=True)), + ("has_billing_address", models.BooleanField(default=False)), + ( + "user_timezone", + models.CharField(default="Asia/Kolkata", max_length=255), + ), + ( + "last_active", + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), + ), + ("last_login_time", models.DateTimeField(null=True)), + ("last_logout_time", models.DateTimeField(null=True)), + ( + "last_login_ip", + models.CharField(blank=True, max_length=255), + ), + ( + "last_logout_ip", + models.CharField(blank=True, max_length=255), + ), + ( + "last_login_medium", + models.CharField(default="email", max_length=20), + ), + ("last_login_uagent", models.TextField(blank=True)), + ("token_updated_at", models.DateTimeField(null=True)), + ("last_workspace_id", models.UUIDField(null=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'User', - 'verbose_name_plural': 'Users', - 'db_table': 'user', - 'ordering': ('-created_at',), + "verbose_name": "User", + "verbose_name_plural": "Users", + "db_table": "user", + "ordering": ("-created_at",), }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), migrations.CreateModel( - name='Cycle', + name="Cycle", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Cycle Name')), - ('description', models.TextField(blank=True, verbose_name='Cycle Description')), - ('start_date', models.DateField(verbose_name='Start Date')), - ('end_date', models.DateField(verbose_name='End Date')), - ('status', models.CharField(choices=[('started', 'Started'), ('completed', 'Completed')], max_length=255, verbose_name='Cycle Status')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycle_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_by_cycle', to=settings.AUTH_USER_MODEL)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Cycle Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Cycle Description" + ), + ), + ("start_date", models.DateField(verbose_name="Start Date")), + ("end_date", models.DateField(verbose_name="End Date")), + ( + "status", + models.CharField( + choices=[ + ("started", "Started"), + ("completed", "Completed"), + ], + max_length=255, + verbose_name="Cycle Status", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycle_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owned_by_cycle", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Cycle', - 'verbose_name_plural': 'Cycles', - 'db_table': 'cycle', - 'ordering': ('-created_at',), + "verbose_name": "Cycle", + "verbose_name_plural": "Cycles", + "db_table": "cycle", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Issue', + name="Issue", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Issue Name')), - ('description', models.JSONField(blank=True, verbose_name='Issue Description')), - ('priority', models.CharField(blank=True, choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], max_length=30, null=True, verbose_name='Issue Priority')), - ('start_date', models.DateField(blank=True, null=True)), - ('target_date', models.DateField(blank=True, null=True)), - ('sequence_id', models.IntegerField(default=1, verbose_name='Issue Sequence ID')), - ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Issue Name" + ), + ), + ( + "description", + models.JSONField( + blank=True, verbose_name="Issue Description" + ), + ), + ( + "priority", + models.CharField( + blank=True, + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ], + max_length=30, + null=True, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ( + "sequence_id", + models.IntegerField( + default=1, verbose_name="Issue Sequence ID" + ), + ), + ( + "attachments", + django.contrib.postgres.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + size=10, + ), + ), ], options={ - 'verbose_name': 'Issue', - 'verbose_name_plural': 'Issues', - 'db_table': 'issue', - 'ordering': ('-created_at',), + "verbose_name": "Issue", + "verbose_name_plural": "Issues", + "db_table": "issue", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Project Name')), - ('description', models.TextField(blank=True, verbose_name='Project Description')), - ('description_rt', models.JSONField(blank=True, null=True, verbose_name='Project Description RT')), - ('description_html', models.JSONField(blank=True, null=True, verbose_name='Project Description HTML')), - ('network', models.PositiveSmallIntegerField(choices=[(0, 'Secret'), (2, 'Public')], default=2)), - ('identifier', models.CharField(blank=True, max_length=5, null=True, verbose_name='Project Identifier')), - ('slug', models.SlugField(blank=True, max_length=100)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('default_assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='default_assignee', to=settings.AUTH_USER_MODEL)), - ('project_lead', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_lead', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Project Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Project Description" + ), + ), + ( + "description_rt", + models.JSONField( + blank=True, + null=True, + verbose_name="Project Description RT", + ), + ), + ( + "description_html", + models.JSONField( + blank=True, + null=True, + verbose_name="Project Description HTML", + ), + ), + ( + "network", + models.PositiveSmallIntegerField( + choices=[(0, "Secret"), (2, "Public")], default=2 + ), + ), + ( + "identifier", + models.CharField( + blank=True, + max_length=5, + null=True, + verbose_name="Project Identifier", + ), + ), + ("slug", models.SlugField(blank=True, max_length=100)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "default_assignee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="default_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project_lead", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_lead", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Project', - 'verbose_name_plural': 'Projects', - 'db_table': 'project', - 'ordering': ('-created_at',), + "verbose_name": "Project", + "verbose_name_plural": "Projects", + "db_table": "project", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Team', + name="Team", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Team Name')), - ('description', models.TextField(blank=True, verbose_name='Team Description')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="Team Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Team Description" + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="team_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), ], options={ - 'verbose_name': 'Team', - 'verbose_name_plural': 'Teams', - 'db_table': 'team', - 'ordering': ('-created_at',), + "verbose_name": "Team", + "verbose_name_plural": "Teams", + "db_table": "team", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Workspace', + name="Workspace", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Workspace Name')), - ('logo', models.URLField(blank=True, null=True, verbose_name='Logo')), - ('slug', models.SlugField(max_length=100, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner_workspace', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Workspace Name" + ), + ), + ( + "logo", + models.URLField( + blank=True, null=True, verbose_name="Logo" + ), + ), + ("slug", models.SlugField(max_length=100, unique=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="owner_workspace", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Workspace', - 'verbose_name_plural': 'Workspaces', - 'db_table': 'workspace', - 'ordering': ('-created_at',), - 'unique_together': {('name', 'owner')}, + "verbose_name": "Workspace", + "verbose_name_plural": "Workspaces", + "db_table": "workspace", + "ordering": ("-created_at",), + "unique_together": {("name", "owner")}, }, ), migrations.CreateModel( - name='WorkspaceMemberInvite', + name="WorkspaceMemberInvite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('email', models.CharField(max_length=255)), - ('accepted', models.BooleanField(default=False)), - ('token', models.CharField(max_length=255)), - ('message', models.TextField(null=True)), - ('responded_at', models.DateTimeField(null=True)), - ('role', models.PositiveSmallIntegerField(choices=[(20, 'Owner'), (15, 'Admin'), (10, 'Member'), (5, 'Guest')], default=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacememberinvite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacememberinvite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_member_invite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("email", models.CharField(max_length=255)), + ("accepted", models.BooleanField(default=False)), + ("token", models.CharField(max_length=255)), + ("message", models.TextField(null=True)), + ("responded_at", models.DateTimeField(null=True)), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Owner"), + (15, "Admin"), + (10, "Member"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacememberinvite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacememberinvite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_member_invite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Workspace Member Invite', - 'verbose_name_plural': 'Workspace Member Invites', - 'db_table': 'workspace_member_invite', - 'ordering': ('-created_at',), + "verbose_name": "Workspace Member Invite", + "verbose_name_plural": "Workspace Member Invites", + "db_table": "workspace_member_invite", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='View', + name="View", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='View Name')), - ('description', models.TextField(blank=True, verbose_name='View Description')), - ('query', models.JSONField(verbose_name='View Query')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='view_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_view', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='view_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_view', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="View Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="View Description" + ), + ), + ("query", models.JSONField(verbose_name="View Query")), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="view_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_view", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="view_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_view", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'View', - 'verbose_name_plural': 'Views', - 'db_table': 'view', - 'ordering': ('-created_at',), + "verbose_name": "View", + "verbose_name_plural": "Views", + "db_table": "view", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='TimelineIssue', + name="TimelineIssue", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('sequence_id', models.FloatField(default=1.0)), - ('links', models.JSONField(blank=True, default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timelineissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_timeline', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_timelineissue', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='timelineissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_timelineissue', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("sequence_id", models.FloatField(default=1.0)), + ("links", models.JSONField(blank=True, default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="timelineissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_timeline", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_timelineissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="timelineissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_timelineissue", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Timeline Issue', - 'verbose_name_plural': 'Timeline Issues', - 'db_table': 'issue_timeline', - 'ordering': ('-created_at',), + "verbose_name": "Timeline Issue", + "verbose_name_plural": "Timeline Issues", + "db_table": "issue_timeline", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='TeamMember', + name="TeamMember", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teammember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to=settings.AUTH_USER_MODEL)), - ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to='db.team')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teammember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_member', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teammember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_member", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_member", + to="db.team", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teammember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_member", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Team Member', - 'verbose_name_plural': 'Team Members', - 'db_table': 'team_member', - 'ordering': ('-created_at',), - 'unique_together': {('team', 'member')}, + "verbose_name": "Team Member", + "verbose_name_plural": "Team Members", + "db_table": "team_member", + "ordering": ("-created_at",), + "unique_together": {("team", "member")}, }, ), migrations.AddField( - model_name='team', - name='members', - field=models.ManyToManyField(blank=True, related_name='members', through='db.TeamMember', to=settings.AUTH_USER_MODEL), + model_name="team", + name="members", + field=models.ManyToManyField( + blank=True, + related_name="members", + through="db.TeamMember", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='team', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="team", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="team_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='team', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_team', to='db.workspace'), + model_name="team", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_team", + to="db.workspace", + ), ), migrations.CreateModel( - name='State', + name="State", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='State Name')), - ('description', models.TextField(blank=True, verbose_name='State Description')), - ('color', models.CharField(max_length=255, verbose_name='State Color')), - ('slug', models.SlugField(blank=True, max_length=100)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_state', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='state_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_state', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="State Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="State Description" + ), + ), + ( + "color", + models.CharField( + max_length=255, verbose_name="State Color" + ), + ), + ("slug", models.SlugField(blank=True, max_length=100)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="state_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_state", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="state_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_state", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'State', - 'verbose_name_plural': 'States', - 'db_table': 'state', - 'ordering': ('-created_at',), - 'unique_together': {('name', 'project')}, + "verbose_name": "State", + "verbose_name_plural": "States", + "db_table": "state", + "ordering": ("-created_at",), + "unique_together": {("name", "project")}, }, ), migrations.CreateModel( - name='SocialLoginConnection', + name="SocialLoginConnection", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('medium', models.CharField(choices=[('Google', 'google'), ('Github', 'github')], default=None, max_length=20)), - ('last_login_at', models.DateTimeField(default=django.utils.timezone.now, null=True)), - ('last_received_at', models.DateTimeField(default=django.utils.timezone.now, null=True)), - ('token_data', models.JSONField(null=True)), - ('extra_data', models.JSONField(null=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socialloginconnection_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='socialloginconnection_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_login_connections', to=settings.AUTH_USER_MODEL)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "medium", + models.CharField( + choices=[("Google", "google"), ("Github", "github")], + default=None, + max_length=20, + ), + ), + ( + "last_login_at", + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), + ), + ( + "last_received_at", + models.DateTimeField( + default=django.utils.timezone.now, null=True + ), + ), + ("token_data", models.JSONField(null=True)), + ("extra_data", models.JSONField(null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="socialloginconnection_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="socialloginconnection_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_login_connections", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Social Login Connection', - 'verbose_name_plural': 'Social Login Connections', - 'db_table': 'social_login_connection', - 'ordering': ('-created_at',), + "verbose_name": "Social Login Connection", + "verbose_name_plural": "Social Login Connections", + "db_table": "social_login_connection", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Shortcut', + name="Shortcut", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Cycle Name')), - ('description', models.TextField(blank=True, verbose_name='Cycle Description')), - ('type', models.CharField(choices=[('repo', 'Repo'), ('direct', 'Direct')], max_length=255, verbose_name='Shortcut Type')), - ('url', models.URLField(blank=True, null=True, verbose_name='URL')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shortcut_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_shortcut', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shortcut_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_shortcut', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Cycle Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Cycle Description" + ), + ), + ( + "type", + models.CharField( + choices=[("repo", "Repo"), ("direct", "Direct")], + max_length=255, + verbose_name="Shortcut Type", + ), + ), + ( + "url", + models.URLField(blank=True, null=True, verbose_name="URL"), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shortcut_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_shortcut", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="shortcut_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_shortcut", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Shortcut', - 'verbose_name_plural': 'Shortcuts', - 'db_table': 'shortcut', - 'ordering': ('-created_at',), + "verbose_name": "Shortcut", + "verbose_name_plural": "Shortcuts", + "db_table": "shortcut", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ProjectMemberInvite', + name="ProjectMemberInvite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('email', models.CharField(max_length=255)), - ('accepted', models.BooleanField(default=False)), - ('token', models.CharField(max_length=255)), - ('message', models.TextField(null=True)), - ('responded_at', models.DateTimeField(null=True)), - ('role', models.PositiveSmallIntegerField(choices=[(20, 'Admin'), (15, 'Member'), (10, 'Viewer'), (5, 'Guest')], default=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmemberinvite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectmemberinvite', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmemberinvite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectmemberinvite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("email", models.CharField(max_length=255)), + ("accepted", models.BooleanField(default=False)), + ("token", models.CharField(max_length=255)), + ("message", models.TextField(null=True)), + ("responded_at", models.DateTimeField(null=True)), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Admin"), + (15, "Member"), + (10, "Viewer"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmemberinvite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_projectmemberinvite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmemberinvite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_projectmemberinvite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Project Member Invite', - 'verbose_name_plural': 'Project Member Invites', - 'db_table': 'project_member_invite', - 'ordering': ('-created_at',), + "verbose_name": "Project Member Invite", + "verbose_name_plural": "Project Member Invites", + "db_table": "project_member_invite", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ProjectIdentifier', + name="ProjectIdentifier", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('name', models.CharField(max_length=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectidentifier_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_identifier', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectidentifier_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ("name", models.CharField(max_length=10)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectidentifier_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_identifier", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectidentifier_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Project Identifier', - 'verbose_name_plural': 'Project Identifiers', - 'db_table': 'project_identifier', - 'ordering': ('-created_at',), + "verbose_name": "Project Identifier", + "verbose_name_plural": "Project Identifiers", + "db_table": "project_identifier", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='project', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_project', to='db.workspace'), + model_name="project", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_project", + to="db.workspace", + ), ), migrations.CreateModel( - name='Label', + name="Label", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_label', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='label_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_label', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="label_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_label", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="label_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_label", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Label', - 'verbose_name_plural': 'Labels', - 'db_table': 'label', - 'ordering': ('-created_at',), + "verbose_name": "Label", + "verbose_name_plural": "Labels", + "db_table": "label", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueSequence', + name="IssueSequence", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('sequence', models.PositiveBigIntegerField(default=1)), - ('deleted', models.BooleanField(default=False)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuesequence_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_sequence', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuesequence', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuesequence_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuesequence', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("sequence", models.PositiveBigIntegerField(default=1)), + ("deleted", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuesequence_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_sequence", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuesequence", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuesequence_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuesequence", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Sequence', - 'verbose_name_plural': 'Issue Sequences', - 'db_table': 'issue_sequence', - 'ordering': ('-created_at',), + "verbose_name": "Issue Sequence", + "verbose_name_plural": "Issue Sequences", + "db_table": "issue_sequence", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueProperty', + name="IssueProperty", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('properties', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueproperty_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueproperty', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueproperty_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_property_user', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueproperty', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("properties", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueproperty_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueproperty", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueproperty_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_property_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueproperty", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Property', - 'verbose_name_plural': 'Issue Properties', - 'db_table': 'issue_property', - 'ordering': ('-created_at',), + "verbose_name": "Issue Property", + "verbose_name_plural": "Issue Properties", + "db_table": "issue_property", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueLabel', + name="IssueLabel", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='label_issue', to='db.issue')), - ('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='label_issue', to='db.label')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelabel', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelabel_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuelabel', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelabel_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="label_issue", + to="db.issue", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="label_issue", + to="db.label", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuelabel", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelabel_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuelabel", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Label', - 'verbose_name_plural': 'Issue Labels', - 'db_table': 'issue_label', - 'ordering': ('-created_at',), + "verbose_name": "Issue Label", + "verbose_name_plural": "Issue Labels", + "db_table": "issue_label", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueComment', + name="IssueComment", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('comment', models.TextField(blank=True, verbose_name='Comment')), - ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuecomment_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuecomment', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuecomment_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuecomment', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "comment", + models.TextField(blank=True, verbose_name="Comment"), + ), + ( + "attachments", + django.contrib.postgres.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + size=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuecomment_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuecomment", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuecomment_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuecomment", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Comment', - 'verbose_name_plural': 'Issue Comments', - 'db_table': 'issue_comment', - 'ordering': ('-created_at',), + "verbose_name": "Issue Comment", + "verbose_name_plural": "Issue Comments", + "db_table": "issue_comment", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueBlocker', + name="IssueBlocker", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('block', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocker_issues', to='db.issue')), - ('blocked_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocked_issues', to='db.issue')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueblocker_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueblocker', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueblocker_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueblocker', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "block", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocker_issues", + to="db.issue", + ), + ), + ( + "blocked_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocked_issues", + to="db.issue", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueblocker_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueblocker", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueblocker_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueblocker", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Blocker', - 'verbose_name_plural': 'Issue Blockers', - 'db_table': 'issue_blocker', - 'ordering': ('-created_at',), + "verbose_name": "Issue Blocker", + "verbose_name_plural": "Issue Blockers", + "db_table": "issue_blocker", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueAssignee', + name="IssueAssignee", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('assignee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_assignee', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueassignee_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_assignee', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueassignee', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueassignee_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueassignee', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "assignee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueassignee_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_assignee", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueassignee", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueassignee_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueassignee", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Assignee', - 'verbose_name_plural': 'Issue Assignees', - 'db_table': 'issue_assignee', - 'ordering': ('-created_at',), - 'unique_together': {('issue', 'assignee')}, + "verbose_name": "Issue Assignee", + "verbose_name_plural": "Issue Assignees", + "db_table": "issue_assignee", + "ordering": ("-created_at",), + "unique_together": {("issue", "assignee")}, }, ), migrations.CreateModel( - name='IssueActivity', + name="IssueActivity", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('verb', models.CharField(default='created', max_length=255, verbose_name='Action')), - ('field', models.CharField(blank=True, max_length=255, null=True, verbose_name='Field Name')), - ('old_value', models.CharField(blank=True, max_length=255, null=True, verbose_name='Old Value')), - ('new_value', models.CharField(blank=True, max_length=255, null=True, verbose_name='New Value')), - ('comment', models.TextField(blank=True, verbose_name='Comment')), - ('attachments', django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), blank=True, default=list, size=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueactivity_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_activity', to='db.issue')), - ('issue_comment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_comment', to='db.issuecomment')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueactivity', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueactivity_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueactivity', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "verb", + models.CharField( + default="created", + max_length=255, + verbose_name="Action", + ), + ), + ( + "field", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Field Name", + ), + ), + ( + "old_value", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="Old Value", + ), + ), + ( + "new_value", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="New Value", + ), + ), + ( + "comment", + models.TextField(blank=True, verbose_name="Comment"), + ), + ( + "attachments", + django.contrib.postgres.fields.ArrayField( + base_field=models.URLField(), + blank=True, + default=list, + size=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueactivity_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_activity", + to="db.issue", + ), + ), + ( + "issue_comment", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_comment", + to="db.issuecomment", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueactivity", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueactivity_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueactivity", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Activity', - 'verbose_name_plural': 'Issue Activities', - 'db_table': 'issue_activity', - 'ordering': ('-created_at',), + "verbose_name": "Issue Activity", + "verbose_name_plural": "Issue Activities", + "db_table": "issue_activity", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='issue', - name='assignees', - field=models.ManyToManyField(blank=True, related_name='assignee', through='db.IssueAssignee', to=settings.AUTH_USER_MODEL), + model_name="issue", + name="assignees", + field=models.ManyToManyField( + blank=True, + related_name="assignee", + through="db.IssueAssignee", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='issue', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="issue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='issue', - name='labels', - field=models.ManyToManyField(blank=True, related_name='labels', through='db.IssueLabel', to='db.Label'), + model_name="issue", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="labels", + through="db.IssueLabel", + to="db.Label", + ), ), migrations.AddField( - model_name='issue', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_issue', to='db.issue'), + model_name="issue", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="parent_issue", + to="db.issue", + ), ), migrations.AddField( - model_name='issue', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issue', to='db.project'), + model_name="issue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issue", + to="db.project", + ), ), migrations.AddField( - model_name='issue', - name='state', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='state_issue', to='db.state'), + model_name="issue", + name="state", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="state_issue", + to="db.state", + ), ), migrations.AddField( - model_name='issue', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="issue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='issue', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issue', to='db.workspace'), + model_name="issue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issue", + to="db.workspace", + ), ), migrations.CreateModel( - name='FileAsset', + name="FileAsset", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('attributes', models.JSONField(default=dict)), - ('asset', models.FileField(upload_to='library-assets')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fileasset_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fileasset_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("attributes", models.JSONField(default=dict)), + ("asset", models.FileField(upload_to="library-assets")), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fileasset_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fileasset_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'File Asset', - 'verbose_name_plural': 'File Assets', - 'db_table': 'file_asset', - 'ordering': ('-created_at',), + "verbose_name": "File Asset", + "verbose_name_plural": "File Assets", + "db_table": "file_asset", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='CycleIssue', + name="CycleIssue", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.cycle')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cycleissue', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycleissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cycleissue', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycleissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.cycle", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_cycleissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycleissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_cycleissue", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Cycle Issue', - 'verbose_name_plural': 'Cycle Issues', - 'db_table': 'cycle_issue', - 'ordering': ('-created_at',), + "verbose_name": "Cycle Issue", + "verbose_name_plural": "Cycle Issues", + "db_table": "cycle_issue", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='cycle', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cycle', to='db.project'), + model_name="cycle", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_cycle", + to="db.project", + ), ), migrations.AddField( - model_name='cycle', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cycle_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="cycle", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cycle_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='cycle', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cycle', to='db.workspace'), + model_name="cycle", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_cycle", + to="db.workspace", + ), ), migrations.CreateModel( - name='WorkspaceMember', + name="WorkspaceMember", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('role', models.PositiveSmallIntegerField(choices=[(20, 'Owner'), (15, 'Admin'), (10, 'Member'), (5, 'Guest')], default=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='member_workspace', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacemember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_member', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Owner"), + (15, "Admin"), + (10, "Member"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacemember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="member_workspace", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacemember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_member", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Workspace Member', - 'verbose_name_plural': 'Workspace Members', - 'db_table': 'workspace_member', - 'ordering': ('-created_at',), - 'unique_together': {('workspace', 'member')}, + "verbose_name": "Workspace Member", + "verbose_name_plural": "Workspace Members", + "db_table": "workspace_member", + "ordering": ("-created_at",), + "unique_together": {("workspace", "member")}, }, ), migrations.AlterUniqueTogether( - name='team', - unique_together={('name', 'workspace')}, + name="team", + unique_together={("name", "workspace")}, ), migrations.CreateModel( - name='ProjectMember', + name="ProjectMember", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('comment', models.TextField(blank=True, null=True)), - ('role', models.PositiveSmallIntegerField(choices=[(20, 'Admin'), (15, 'Member'), (10, 'Viewer'), (5, 'Guest')], default=10)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('member', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='member_project', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectmember', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectmember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectmember', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("comment", models.TextField(blank=True, null=True)), + ( + "role", + models.PositiveSmallIntegerField( + choices=[ + (20, "Admin"), + (15, "Member"), + (10, "Viewer"), + (5, "Guest"), + ], + default=10, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="member_project", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_projectmember", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectmember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_projectmember", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Project Member', - 'verbose_name_plural': 'Project Members', - 'db_table': 'project_member', - 'ordering': ('-created_at',), - 'unique_together': {('project', 'member')}, + "verbose_name": "Project Member", + "verbose_name_plural": "Project Members", + "db_table": "project_member", + "ordering": ("-created_at",), + "unique_together": {("project", "member")}, }, ), migrations.AlterUniqueTogether( - name='project', - unique_together={('name', 'workspace')}, + name="project", + unique_together={("name", "workspace")}, ), ] diff --git a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py index 9c25c451817..d69ef1a712b 100644 --- a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py +++ b/apiserver/plane/db/migrations/0002_auto_20221104_2239.py @@ -6,49 +6,66 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0001_initial'), + ("db", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='state', - options={'ordering': ('sequence',), 'verbose_name': 'State', 'verbose_name_plural': 'States'}, + name="state", + options={ + "ordering": ("sequence",), + "verbose_name": "State", + "verbose_name_plural": "States", + }, ), migrations.RenameField( - model_name='project', - old_name='description_rt', - new_name='description_text', + model_name="project", + old_name="description_rt", + new_name="description_text", ), migrations.AddField( - model_name='issueactivity', - name='actor', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activities', to=settings.AUTH_USER_MODEL), + model_name="issueactivity", + name="actor", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_activities", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='issuecomment', - name='actor', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL), + model_name="issuecomment", + name="actor", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='state', - name='sequence', + model_name="state", + name="sequence", field=models.PositiveIntegerField(default=65535), ), migrations.AddField( - model_name='workspace', - name='company_size', + model_name="workspace", + name="company_size", field=models.PositiveIntegerField(default=10), ), migrations.AddField( - model_name='workspacemember', - name='company_role', + model_name="workspacemember", + name="company_role", field=models.TextField(blank=True, null=True), ), migrations.AlterField( - model_name='cycleissue', - name='issue', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_cycle', to='db.issue'), + model_name="cycleissue", + name="issue", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_cycle", + to="db.issue", + ), ), ] diff --git a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py index 3adac35a7e2..763d52eb6ee 100644 --- a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py +++ b/apiserver/plane/db/migrations/0003_auto_20221109_2320.py @@ -6,19 +6,22 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0002_auto_20221104_2239'), + ("db", "0002_auto_20221104_2239"), ] operations = [ migrations.AlterField( - model_name='issueproperty', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_property_user', to=settings.AUTH_USER_MODEL), + model_name="issueproperty", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_property_user", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterUniqueTogether( - name='issueproperty', - unique_together={('user', 'project')}, + name="issueproperty", + unique_together={("user", "project")}, ), ] diff --git a/apiserver/plane/db/migrations/0004_alter_state_sequence.py b/apiserver/plane/db/migrations/0004_alter_state_sequence.py index 0d4616aea1a..f3489449c91 100644 --- a/apiserver/plane/db/migrations/0004_alter_state_sequence.py +++ b/apiserver/plane/db/migrations/0004_alter_state_sequence.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0003_auto_20221109_2320'), + ("db", "0003_auto_20221109_2320"), ] operations = [ migrations.AlterField( - model_name='state', - name='sequence', + model_name="state", + name="sequence", field=models.FloatField(default=65535), ), ] diff --git a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py index 14c280e26b0..8ab63a22ae3 100644 --- a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py +++ b/apiserver/plane/db/migrations/0005_auto_20221114_2127.py @@ -4,20 +4,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0004_alter_state_sequence'), + ("db", "0004_alter_state_sequence"), ] operations = [ migrations.AlterField( - model_name='cycle', - name='end_date', - field=models.DateField(blank=True, null=True, verbose_name='End Date'), + model_name="cycle", + name="end_date", + field=models.DateField( + blank=True, null=True, verbose_name="End Date" + ), ), migrations.AlterField( - model_name='cycle', - name='start_date', - field=models.DateField(blank=True, null=True, verbose_name='Start Date'), + model_name="cycle", + name="start_date", + field=models.DateField( + blank=True, null=True, verbose_name="Start Date" + ), ), ] diff --git a/apiserver/plane/db/migrations/0006_alter_cycle_status.py b/apiserver/plane/db/migrations/0006_alter_cycle_status.py index f49e263fb96..3121f4fe594 100644 --- a/apiserver/plane/db/migrations/0006_alter_cycle_status.py +++ b/apiserver/plane/db/migrations/0006_alter_cycle_status.py @@ -4,15 +4,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0005_auto_20221114_2127'), + ("db", "0005_auto_20221114_2127"), ] operations = [ migrations.AlterField( - model_name='cycle', - name='status', - field=models.CharField(choices=[('draft', 'Draft'), ('started', 'Started'), ('completed', 'Completed')], default='draft', max_length=255, verbose_name='Cycle Status'), + model_name="cycle", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("started", "Started"), + ("completed", "Completed"), + ], + default="draft", + max_length=255, + verbose_name="Cycle Status", + ), ), ] diff --git a/apiserver/plane/db/migrations/0007_label_parent.py b/apiserver/plane/db/migrations/0007_label_parent.py index 03e6604731c..6e67a3c9456 100644 --- a/apiserver/plane/db/migrations/0007_label_parent.py +++ b/apiserver/plane/db/migrations/0007_label_parent.py @@ -5,15 +5,20 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0006_alter_cycle_status'), + ("db", "0006_alter_cycle_status"), ] operations = [ migrations.AddField( - model_name='label', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_label', to='db.label'), + model_name="label", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="parent_label", + to="db.label", + ), ), ] diff --git a/apiserver/plane/db/migrations/0008_label_colour.py b/apiserver/plane/db/migrations/0008_label_colour.py index 9e630969dff..3ca6b91c1e2 100644 --- a/apiserver/plane/db/migrations/0008_label_colour.py +++ b/apiserver/plane/db/migrations/0008_label_colour.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0007_label_parent'), + ("db", "0007_label_parent"), ] operations = [ migrations.AddField( - model_name='label', - name='colour', + model_name="label", + name="colour", field=models.CharField(blank=True, max_length=255), ), ] diff --git a/apiserver/plane/db/migrations/0009_auto_20221208_0310.py b/apiserver/plane/db/migrations/0009_auto_20221208_0310.py index 077ab7e8255..829baaa628a 100644 --- a/apiserver/plane/db/migrations/0009_auto_20221208_0310.py +++ b/apiserver/plane/db/migrations/0009_auto_20221208_0310.py @@ -4,20 +4,29 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0008_label_colour'), + ("db", "0008_label_colour"), ] operations = [ migrations.AddField( - model_name='projectmember', - name='view_props', + model_name="projectmember", + name="view_props", field=models.JSONField(null=True), ), migrations.AddField( - model_name='state', - name='group', - field=models.CharField(choices=[('backlog', 'Backlog'), ('unstarted', 'Unstarted'), ('started', 'Started'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='backlog', max_length=20), + model_name="state", + name="group", + field=models.CharField( + choices=[ + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="backlog", + max_length=20, + ), ), ] diff --git a/apiserver/plane/db/migrations/0010_auto_20221213_0037.py b/apiserver/plane/db/migrations/0010_auto_20221213_0037.py index e8579b5ffb0..1672a10ab50 100644 --- a/apiserver/plane/db/migrations/0010_auto_20221213_0037.py +++ b/apiserver/plane/db/migrations/0010_auto_20221213_0037.py @@ -5,28 +5,37 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0009_auto_20221208_0310'), + ("db", "0009_auto_20221208_0310"), ] operations = [ migrations.AddField( - model_name='projectidentifier', - name='workspace', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='project_identifiers', to='db.workspace'), + model_name="projectidentifier", + name="workspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_identifiers", + to="db.workspace", + ), ), migrations.AlterField( - model_name='project', - name='identifier', - field=models.CharField(max_length=5, verbose_name='Project Identifier'), + model_name="project", + name="identifier", + field=models.CharField( + max_length=5, verbose_name="Project Identifier" + ), ), migrations.AlterUniqueTogether( - name='project', - unique_together={('name', 'workspace'), ('identifier', 'workspace')}, + name="project", + unique_together={ + ("name", "workspace"), + ("identifier", "workspace"), + }, ), migrations.AlterUniqueTogether( - name='projectidentifier', - unique_together={('name', 'workspace')}, + name="projectidentifier", + unique_together={("name", "workspace")}, ), ] diff --git a/apiserver/plane/db/migrations/0011_auto_20221222_2357.py b/apiserver/plane/db/migrations/0011_auto_20221222_2357.py index deeb1cc2f60..b52df301269 100644 --- a/apiserver/plane/db/migrations/0011_auto_20221222_2357.py +++ b/apiserver/plane/db/migrations/0011_auto_20221222_2357.py @@ -8,122 +8,341 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0010_auto_20221213_0037'), + ("db", "0010_auto_20221213_0037"), ] operations = [ migrations.CreateModel( - name='Module', + name="Module", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Module Name')), - ('description', models.TextField(blank=True, verbose_name='Module Description')), - ('description_text', models.JSONField(blank=True, null=True, verbose_name='Module Description RT')), - ('description_html', models.JSONField(blank=True, null=True, verbose_name='Module Description HTML')), - ('start_date', models.DateField(null=True)), - ('target_date', models.DateField(null=True)), - ('status', models.CharField(choices=[('backlog', 'Backlog'), ('planned', 'Planned'), ('in-progress', 'In Progress'), ('paused', 'Paused'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='planned', max_length=20)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + max_length=255, verbose_name="Module Name" + ), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="Module Description" + ), + ), + ( + "description_text", + models.JSONField( + blank=True, + null=True, + verbose_name="Module Description RT", + ), + ), + ( + "description_html", + models.JSONField( + blank=True, + null=True, + verbose_name="Module Description HTML", + ), + ), + ("start_date", models.DateField(null=True)), + ("target_date", models.DateField(null=True)), + ( + "status", + models.CharField( + choices=[ + ("backlog", "Backlog"), + ("planned", "Planned"), + ("in-progress", "In Progress"), + ("paused", "Paused"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="planned", + max_length=20, + ), + ), ], options={ - 'verbose_name': 'Module', - 'verbose_name_plural': 'Modules', - 'db_table': 'module', - 'ordering': ('-created_at',), + "verbose_name": "Module", + "verbose_name_plural": "Modules", + "db_table": "module", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='project', - name='icon', + model_name="project", + name="icon", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='projectmember', - name='default_props', - field=models.JSONField(default=plane.db.models.project.get_default_props), + model_name="projectmember", + name="default_props", + field=models.JSONField( + default=plane.db.models.project.get_default_props + ), ), migrations.AddField( - model_name='user', - name='my_issues_prop', + model_name="user", + name="my_issues_prop", field=models.JSONField(null=True), ), migrations.CreateModel( - name='ModuleMember', + name="ModuleMember", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='db.module')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulemember', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulemember_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulemember', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulemember_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_modulemember", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulemember_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_modulemember", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Module Member', - 'verbose_name_plural': 'Module Members', - 'db_table': 'module_member', - 'ordering': ('-created_at',), - 'unique_together': {('module', 'member')}, + "verbose_name": "Module Member", + "verbose_name_plural": "Module Members", + "db_table": "module_member", + "ordering": ("-created_at",), + "unique_together": {("module", "member")}, }, ), migrations.AddField( - model_name='module', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), + model_name="module", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="module_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), ), migrations.AddField( - model_name='module', - name='lead', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_leads', to=settings.AUTH_USER_MODEL), + model_name="module", + name="lead", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="module_leads", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='module', - name='members', - field=models.ManyToManyField(blank=True, related_name='module_members', through='db.ModuleMember', to=settings.AUTH_USER_MODEL), + model_name="module", + name="members", + field=models.ManyToManyField( + blank=True, + related_name="module_members", + through="db.ModuleMember", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='module', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_module', to='db.project'), + model_name="module", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_module", + to="db.project", + ), ), migrations.AddField( - model_name='module', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='module_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="module", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="module_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='module', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_module', to='db.workspace'), + model_name="module", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_module", + to="db.workspace", + ), ), migrations.CreateModel( - name='ModuleIssue', + name="ModuleIssue", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue')), - ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.module')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_moduleissue', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moduleissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_moduleissue', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moduleissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.issue", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_moduleissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moduleissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_moduleissue", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Module Issue', - 'verbose_name_plural': 'Module Issues', - 'db_table': 'module_issues', - 'ordering': ('-created_at',), - 'unique_together': {('module', 'issue')}, + "verbose_name": "Module Issue", + "verbose_name_plural": "Module Issues", + "db_table": "module_issues", + "ordering": ("-created_at",), + "unique_together": {("module", "issue")}, }, ), migrations.AlterUniqueTogether( - name='module', - unique_together={('name', 'project')}, + name="module", + unique_together={("name", "project")}, ), ] diff --git a/apiserver/plane/db/migrations/0012_auto_20230104_0117.py b/apiserver/plane/db/migrations/0012_auto_20230104_0117.py index b1ff63fe1cc..bc767dd5d33 100644 --- a/apiserver/plane/db/migrations/0012_auto_20230104_0117.py +++ b/apiserver/plane/db/migrations/0012_auto_20230104_0117.py @@ -7,166 +7,228 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0011_auto_20221222_2357'), + ("db", "0011_auto_20221222_2357"), ] operations = [ migrations.AddField( - model_name='issueactivity', - name='new_identifier', + model_name="issueactivity", + name="new_identifier", field=models.UUIDField(null=True), ), migrations.AddField( - model_name='issueactivity', - name='old_identifier', + model_name="issueactivity", + name="old_identifier", field=models.UUIDField(null=True), ), migrations.AlterField( - model_name='moduleissue', - name='issue', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'), + model_name="moduleissue", + name="issue", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_module", + to="db.issue", + ), ), migrations.AlterUniqueTogether( - name='moduleissue', + name="moduleissue", unique_together=set(), ), migrations.AlterModelTable( - name='cycle', - table='cycles', + name="cycle", + table="cycles", ), migrations.AlterModelTable( - name='cycleissue', - table='cycle_issues', + name="cycleissue", + table="cycle_issues", ), migrations.AlterModelTable( - name='fileasset', - table='file_assets', + name="fileasset", + table="file_assets", ), migrations.AlterModelTable( - name='issue', - table='issues', + name="issue", + table="issues", ), migrations.AlterModelTable( - name='issueactivity', - table='issue_activities', + name="issueactivity", + table="issue_activities", ), migrations.AlterModelTable( - name='issueassignee', - table='issue_assignees', + name="issueassignee", + table="issue_assignees", ), migrations.AlterModelTable( - name='issueblocker', - table='issue_blockers', + name="issueblocker", + table="issue_blockers", ), migrations.AlterModelTable( - name='issuecomment', - table='issue_comments', + name="issuecomment", + table="issue_comments", ), migrations.AlterModelTable( - name='issuelabel', - table='issue_labels', + name="issuelabel", + table="issue_labels", ), migrations.AlterModelTable( - name='issueproperty', - table='issue_properties', + name="issueproperty", + table="issue_properties", ), migrations.AlterModelTable( - name='issuesequence', - table='issue_sequences', + name="issuesequence", + table="issue_sequences", ), migrations.AlterModelTable( - name='label', - table='labels', + name="label", + table="labels", ), migrations.AlterModelTable( - name='module', - table='modules', + name="module", + table="modules", ), migrations.AlterModelTable( - name='modulemember', - table='module_members', + name="modulemember", + table="module_members", ), migrations.AlterModelTable( - name='project', - table='projects', + name="project", + table="projects", ), migrations.AlterModelTable( - name='projectidentifier', - table='project_identifiers', + name="projectidentifier", + table="project_identifiers", ), migrations.AlterModelTable( - name='projectmember', - table='project_members', + name="projectmember", + table="project_members", ), migrations.AlterModelTable( - name='projectmemberinvite', - table='project_member_invites', + name="projectmemberinvite", + table="project_member_invites", ), migrations.AlterModelTable( - name='shortcut', - table='shortcuts', + name="shortcut", + table="shortcuts", ), migrations.AlterModelTable( - name='socialloginconnection', - table='social_login_connections', + name="socialloginconnection", + table="social_login_connections", ), migrations.AlterModelTable( - name='state', - table='states', + name="state", + table="states", ), migrations.AlterModelTable( - name='team', - table='teams', + name="team", + table="teams", ), migrations.AlterModelTable( - name='teammember', - table='team_members', + name="teammember", + table="team_members", ), migrations.AlterModelTable( - name='timelineissue', - table='issue_timelines', + name="timelineissue", + table="issue_timelines", ), migrations.AlterModelTable( - name='user', - table='users', + name="user", + table="users", ), migrations.AlterModelTable( - name='view', - table='views', + name="view", + table="views", ), migrations.AlterModelTable( - name='workspace', - table='workspaces', + name="workspace", + table="workspaces", ), migrations.AlterModelTable( - name='workspacemember', - table='workspace_members', + name="workspacemember", + table="workspace_members", ), migrations.AlterModelTable( - name='workspacememberinvite', - table='workspace_member_invites', + name="workspacememberinvite", + table="workspace_member_invites", ), migrations.CreateModel( - name='ModuleLink', + name="ModuleLink", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('title', models.CharField(max_length=255, null=True)), - ('url', models.URLField()), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_module', to='db.module')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulelink', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulelink_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulelink', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=255, null=True)), + ("url", models.URLField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulelink_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="link_module", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_modulelink", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulelink_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_modulelink", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Module Link', - 'verbose_name_plural': 'Module Links', - 'db_table': 'module_links', - 'ordering': ('-created_at',), + "verbose_name": "Module Link", + "verbose_name_plural": "Module Links", + "db_table": "module_links", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0013_auto_20230107_0041.py b/apiserver/plane/db/migrations/0013_auto_20230107_0041.py index c75537fc1bf..786e6cb5dae 100644 --- a/apiserver/plane/db/migrations/0013_auto_20230107_0041.py +++ b/apiserver/plane/db/migrations/0013_auto_20230107_0041.py @@ -4,35 +4,34 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0012_auto_20230104_0117'), + ("db", "0012_auto_20230104_0117"), ] operations = [ migrations.AddField( - model_name='issue', - name='description_html', + model_name="issue", + name="description_html", field=models.TextField(blank=True), ), migrations.AddField( - model_name='issue', - name='description_stripped', + model_name="issue", + name="description_stripped", field=models.TextField(blank=True), ), migrations.AddField( - model_name='user', - name='role', + model_name="user", + name="role", field=models.CharField(blank=True, max_length=300, null=True), ), migrations.AddField( - model_name='workspacemember', - name='view_props', + model_name="workspacemember", + name="view_props", field=models.JSONField(blank=True, null=True), ), migrations.AlterField( - model_name='issue', - name='description', + model_name="issue", + name="description", field=models.JSONField(blank=True), ), ] diff --git a/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py b/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py index b1786c9c169..5642ae15d17 100644 --- a/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py +++ b/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py @@ -4,14 +4,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0013_auto_20230107_0041'), + ("db", "0013_auto_20230107_0041"), ] operations = [ migrations.AlterUniqueTogether( - name='workspacememberinvite', - unique_together={('email', 'workspace')}, + name="workspacememberinvite", + unique_together={("email", "workspace")}, ), ] diff --git a/apiserver/plane/db/migrations/0015_auto_20230107_1636.py b/apiserver/plane/db/migrations/0015_auto_20230107_1636.py index e3f5dc26a2b..903c78b05eb 100644 --- a/apiserver/plane/db/migrations/0015_auto_20230107_1636.py +++ b/apiserver/plane/db/migrations/0015_auto_20230107_1636.py @@ -4,25 +4,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0014_alter_workspacememberinvite_unique_together'), + ("db", "0014_alter_workspacememberinvite_unique_together"), ] operations = [ migrations.RenameField( - model_name='issuecomment', - old_name='comment', - new_name='comment_stripped', + model_name="issuecomment", + old_name="comment", + new_name="comment_stripped", ), migrations.AddField( - model_name='issuecomment', - name='comment_html', + model_name="issuecomment", + name="comment_html", field=models.TextField(blank=True), ), migrations.AddField( - model_name='issuecomment', - name='comment_json', + model_name="issuecomment", + name="comment_json", field=models.JSONField(blank=True, null=True), ), ] diff --git a/apiserver/plane/db/migrations/0016_auto_20230107_1735.py b/apiserver/plane/db/migrations/0016_auto_20230107_1735.py index 073c1e11710..a22dc9a62e3 100644 --- a/apiserver/plane/db/migrations/0016_auto_20230107_1735.py +++ b/apiserver/plane/db/migrations/0016_auto_20230107_1735.py @@ -6,20 +6,27 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0015_auto_20230107_1636'), + ("db", "0015_auto_20230107_1636"), ] operations = [ migrations.AddField( - model_name='fileasset', - name='workspace', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='db.workspace'), + model_name="fileasset", + name="workspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.workspace", + ), ), migrations.AlterField( - model_name='fileasset', - name='asset', - field=models.FileField(upload_to=plane.db.models.asset.get_upload_path, validators=[plane.db.models.asset.file_size]), + model_name="fileasset", + name="asset", + field=models.FileField( + upload_to=plane.db.models.asset.get_upload_path, + validators=[plane.db.models.asset.file_size], + ), ), ] diff --git a/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py b/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py index c6bfc21458e..1ab721a3e1c 100644 --- a/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py +++ b/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py @@ -4,14 +4,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0016_auto_20230107_1735'), + ("db", "0016_auto_20230107_1735"), ] operations = [ migrations.AlterUniqueTogether( - name='workspace', + name="workspace", unique_together=set(), ), ] diff --git a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py index 03eaeacd73d..32f88653929 100644 --- a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py +++ b/apiserver/plane/db/migrations/0018_auto_20230130_0119.py @@ -8,50 +8,112 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0017_alter_workspace_unique_together'), + ("db", "0017_alter_workspace_unique_together"), ] operations = [ migrations.AddField( - model_name='user', - name='is_bot', + model_name="user", + name="is_bot", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='issue', - name='description', + model_name="issue", + name="description", field=models.JSONField(blank=True, null=True), ), migrations.AlterField( - model_name='issue', - name='description_html', + model_name="issue", + name="description_html", field=models.TextField(blank=True, null=True), ), migrations.AlterField( - model_name='issue', - name='description_stripped', + model_name="issue", + name="description_stripped", field=models.TextField(blank=True, null=True), ), migrations.CreateModel( - name='APIToken', + name="APIToken", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('token', models.CharField(default=plane.db.models.api.generate_token, max_length=255, unique=True)), - ('label', models.CharField(default=plane.db.models.api.generate_label_token, max_length=255)), - ('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_tokens', to=settings.AUTH_USER_MODEL)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "token", + models.CharField( + default=plane.db.models.api.generate_token, + max_length=255, + unique=True, + ), + ), + ( + "label", + models.CharField( + default=plane.db.models.api.generate_label_token, + max_length=255, + ), + ), + ( + "user_type", + models.PositiveSmallIntegerField( + choices=[(0, "Human"), (1, "Bot")], default=0 + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="apitoken_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="apitoken_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="bot_tokens", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'API Token', - 'verbose_name_plural': 'API Tokems', - 'db_table': 'api_tokens', - 'ordering': ('-created_at',), + "verbose_name": "API Token", + "verbose_name_plural": "API Tokems", + "db_table": "api_tokens", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0019_auto_20230131_0049.py b/apiserver/plane/db/migrations/0019_auto_20230131_0049.py index 38412aa9e3a..63545f497d2 100644 --- a/apiserver/plane/db/migrations/0019_auto_20230131_0049.py +++ b/apiserver/plane/db/migrations/0019_auto_20230131_0049.py @@ -4,20 +4,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0018_auto_20230130_0119'), + ("db", "0018_auto_20230130_0119"), ] operations = [ migrations.AlterField( - model_name='issueactivity', - name='new_value', - field=models.TextField(blank=True, null=True, verbose_name='New Value'), + model_name="issueactivity", + name="new_value", + field=models.TextField( + blank=True, null=True, verbose_name="New Value" + ), ), migrations.AlterField( - model_name='issueactivity', - name='old_value', - field=models.TextField(blank=True, null=True, verbose_name='Old Value'), + model_name="issueactivity", + name="old_value", + field=models.TextField( + blank=True, null=True, verbose_name="Old Value" + ), ), ] diff --git a/apiserver/plane/db/migrations/0020_auto_20230214_0118.py b/apiserver/plane/db/migrations/0020_auto_20230214_0118.py index 19276407821..4269f53b30d 100644 --- a/apiserver/plane/db/migrations/0020_auto_20230214_0118.py +++ b/apiserver/plane/db/migrations/0020_auto_20230214_0118.py @@ -5,65 +5,69 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0019_auto_20230131_0049'), + ("db", "0019_auto_20230131_0049"), ] operations = [ migrations.RenameField( - model_name='label', - old_name='colour', - new_name='color', + model_name="label", + old_name="colour", + new_name="color", ), migrations.AddField( - model_name='apitoken', - name='workspace', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to='db.workspace'), + model_name="apitoken", + name="workspace", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="api_tokens", + to="db.workspace", + ), ), migrations.AddField( - model_name='issue', - name='completed_at', + model_name="issue", + name="completed_at", field=models.DateTimeField(null=True), ), migrations.AddField( - model_name='issue', - name='sort_order', + model_name="issue", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AddField( - model_name='project', - name='cycle_view', + model_name="project", + name="cycle_view", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='project', - name='module_view', + model_name="project", + name="module_view", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='state', - name='default', + model_name="state", + name="default", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='issue', - name='description', + model_name="issue", + name="description", field=models.JSONField(blank=True, default=dict), ), migrations.AlterField( - model_name='issue', - name='description_html', - field=models.TextField(blank=True, default='

'), + model_name="issue", + name="description_html", + field=models.TextField(blank=True, default="

"), ), migrations.AlterField( - model_name='issuecomment', - name='comment_html', - field=models.TextField(blank=True, default='

'), + model_name="issuecomment", + name="comment_html", + field=models.TextField(blank=True, default="

"), ), migrations.AlterField( - model_name='issuecomment', - name='comment_json', + model_name="issuecomment", + name="comment_json", field=models.JSONField(blank=True, default=dict), ), ] diff --git a/apiserver/plane/db/migrations/0021_auto_20230223_0104.py b/apiserver/plane/db/migrations/0021_auto_20230223_0104.py index bae6a086ad7..0dc052c2891 100644 --- a/apiserver/plane/db/migrations/0021_auto_20230223_0104.py +++ b/apiserver/plane/db/migrations/0021_auto_20230223_0104.py @@ -7,179 +7,616 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0020_auto_20230214_0118'), + ("db", "0020_auto_20230214_0118"), ] operations = [ migrations.CreateModel( - name='GithubRepository', + name="GithubRepository", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=500)), - ('url', models.URLField(null=True)), - ('config', models.JSONField(default=dict)), - ('repository_id', models.BigIntegerField()), - ('owner', models.CharField(max_length=500)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepository', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepository_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubrepository', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=500)), + ("url", models.URLField(null=True)), + ("config", models.JSONField(default=dict)), + ("repository_id", models.BigIntegerField()), + ("owner", models.CharField(max_length=500)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepository_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubrepository", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepository_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_githubrepository", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Repository', - 'verbose_name_plural': 'Repositories', - 'db_table': 'github_repositories', - 'ordering': ('-created_at',), + "verbose_name": "Repository", + "verbose_name_plural": "Repositories", + "db_table": "github_repositories", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Integration', + name="Integration", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('title', models.CharField(max_length=400)), - ('provider', models.CharField(max_length=400, unique=True)), - ('network', models.PositiveIntegerField(choices=[(1, 'Private'), (2, 'Public')], default=1)), - ('description', models.JSONField(default=dict)), - ('author', models.CharField(blank=True, max_length=400)), - ('webhook_url', models.TextField(blank=True)), - ('webhook_secret', models.TextField(blank=True)), - ('redirect_url', models.TextField(blank=True)), - ('metadata', models.JSONField(default=dict)), - ('verified', models.BooleanField(default=False)), - ('avatar_url', models.URLField(blank=True, null=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='integration_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=400)), + ("provider", models.CharField(max_length=400, unique=True)), + ( + "network", + models.PositiveIntegerField( + choices=[(1, "Private"), (2, "Public")], default=1 + ), + ), + ("description", models.JSONField(default=dict)), + ("author", models.CharField(blank=True, max_length=400)), + ("webhook_url", models.TextField(blank=True)), + ("webhook_secret", models.TextField(blank=True)), + ("redirect_url", models.TextField(blank=True)), + ("metadata", models.JSONField(default=dict)), + ("verified", models.BooleanField(default=False)), + ("avatar_url", models.URLField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="integration_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="integration_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Integration', - 'verbose_name_plural': 'Integrations', - 'db_table': 'integrations', - 'ordering': ('-created_at',), + "verbose_name": "Integration", + "verbose_name_plural": "Integrations", + "db_table": "integrations", + "ordering": ("-created_at",), }, ), migrations.AlterField( - model_name='issueactivity', - name='issue', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_activity', to='db.issue'), + model_name="issueactivity", + name="issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_activity", + to="db.issue", + ), ), migrations.CreateModel( - name='WorkspaceIntegration', + name="WorkspaceIntegration", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('metadata', models.JSONField(default=dict)), - ('config', models.JSONField(default=dict)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to=settings.AUTH_USER_MODEL)), - ('api_token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrations', to='db.apitoken')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='integrated_workspaces', to='db.integration')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspaceintegration_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_integrations', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("metadata", models.JSONField(default=dict)), + ("config", models.JSONField(default=dict)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="integrations", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "api_token", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="integrations", + to="db.apitoken", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspaceintegration_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="integrated_workspaces", + to="db.integration", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspaceintegration_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_integrations", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Workspace Integration', - 'verbose_name_plural': 'Workspace Integrations', - 'db_table': 'workspace_integrations', - 'ordering': ('-created_at',), - 'unique_together': {('workspace', 'integration')}, + "verbose_name": "Workspace Integration", + "verbose_name_plural": "Workspace Integrations", + "db_table": "workspace_integrations", + "ordering": ("-created_at",), + "unique_together": {("workspace", "integration")}, }, ), migrations.CreateModel( - name='IssueLink', + name="IssueLink", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('title', models.CharField(max_length=255, null=True)), - ('url', models.URLField()), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_link', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuelink', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuelink_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuelink', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("title", models.CharField(max_length=255, null=True)), + ("url", models.URLField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelink_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_link", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issuelink", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issuelink_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issuelink", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Link', - 'verbose_name_plural': 'Issue Links', - 'db_table': 'issue_links', - 'ordering': ('-created_at',), + "verbose_name": "Issue Link", + "verbose_name_plural": "Issue Links", + "db_table": "issue_links", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='GithubRepositorySync', + name="GithubRepositorySync", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('credentials', models.JSONField(default=dict)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_syncs', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('label', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='repo_syncs', to='db.label')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubrepositorysync', to='db.project')), - ('repository', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='syncs', to='db.githubrepository')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubrepositorysync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubrepositorysync', to='db.workspace')), - ('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.workspaceintegration')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("credentials", models.JSONField(default=dict)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_syncs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepositorysync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "label", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="repo_syncs", + to="db.label", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubrepositorysync", + to="db.project", + ), + ), + ( + "repository", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="syncs", + to="db.githubrepository", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubrepositorysync_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_githubrepositorysync", + to="db.workspace", + ), + ), + ( + "workspace_integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="github_syncs", + to="db.workspaceintegration", + ), + ), ], options={ - 'verbose_name': 'Github Repository Sync', - 'verbose_name_plural': 'Github Repository Syncs', - 'db_table': 'github_repository_syncs', - 'ordering': ('-created_at',), - 'unique_together': {('project', 'repository')}, + "verbose_name": "Github Repository Sync", + "verbose_name_plural": "Github Repository Syncs", + "db_table": "github_repository_syncs", + "ordering": ("-created_at",), + "unique_together": {("project", "repository")}, }, ), migrations.CreateModel( - name='GithubIssueSync', + name="GithubIssueSync", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('repo_issue_id', models.BigIntegerField()), - ('github_issue_id', models.BigIntegerField()), - ('issue_url', models.URLField()), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_syncs', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubissuesync', to='db.project')), - ('repository_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_syncs', to='db.githubrepositorysync')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubissuesync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubissuesync', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("repo_issue_id", models.BigIntegerField()), + ("github_issue_id", models.BigIntegerField()), + ("issue_url", models.URLField()), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubissuesync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="github_syncs", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubissuesync", + to="db.project", + ), + ), + ( + "repository_sync", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_syncs", + to="db.githubrepositorysync", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubissuesync_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_githubissuesync", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Github Issue Sync', - 'verbose_name_plural': 'Github Issue Syncs', - 'db_table': 'github_issue_syncs', - 'ordering': ('-created_at',), - 'unique_together': {('repository_sync', 'issue')}, + "verbose_name": "Github Issue Sync", + "verbose_name_plural": "Github Issue Syncs", + "db_table": "github_issue_syncs", + "ordering": ("-created_at",), + "unique_together": {("repository_sync", "issue")}, }, ), migrations.CreateModel( - name='GithubCommentSync', + name="GithubCommentSync", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('repo_comment_id', models.BigIntegerField()), - ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.issuecomment')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue_sync', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_syncs', to='db.githubissuesync')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_githubcommentsync', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='githubcommentsync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_githubcommentsync', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("repo_comment_id", models.BigIntegerField()), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_syncs", + to="db.issuecomment", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubcommentsync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue_sync", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_syncs", + to="db.githubissuesync", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_githubcommentsync", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="githubcommentsync_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_githubcommentsync", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Github Comment Sync', - 'verbose_name_plural': 'Github Comment Syncs', - 'db_table': 'github_comment_syncs', - 'ordering': ('-created_at',), - 'unique_together': {('issue_sync', 'comment')}, + "verbose_name": "Github Comment Sync", + "verbose_name_plural": "Github Comment Syncs", + "db_table": "github_comment_syncs", + "ordering": ("-created_at",), + "unique_together": {("issue_sync", "comment")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0022_auto_20230307_0304.py b/apiserver/plane/db/migrations/0022_auto_20230307_0304.py index 25a8eef614f..69bd577d749 100644 --- a/apiserver/plane/db/migrations/0022_auto_20230307_0304.py +++ b/apiserver/plane/db/migrations/0022_auto_20230307_0304.py @@ -7,95 +7,285 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0021_auto_20230223_0104'), + ("db", "0021_auto_20230223_0104"), ] operations = [ migrations.RemoveField( - model_name='cycle', - name='status', + model_name="cycle", + name="status", ), migrations.RemoveField( - model_name='project', - name='slug', + model_name="project", + name="slug", ), migrations.AddField( - model_name='issuelink', - name='metadata', + model_name="issuelink", + name="metadata", field=models.JSONField(default=dict), ), migrations.AddField( - model_name='modulelink', - name='metadata', + model_name="modulelink", + name="metadata", field=models.JSONField(default=dict), ), migrations.AddField( - model_name='project', - name='cover_image', + model_name="project", + name="cover_image", field=models.URLField(blank=True, null=True), ), migrations.CreateModel( - name='ProjectFavorite', + name="ProjectFavorite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectfavorite', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_favorites', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectfavorite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectfavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_projectfavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projectfavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_projectfavorite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Project Favorite', - 'verbose_name_plural': 'Project Favorites', - 'db_table': 'project_favorites', - 'ordering': ('-created_at',), - 'unique_together': {('project', 'user')}, + "verbose_name": "Project Favorite", + "verbose_name_plural": "Project Favorites", + "db_table": "project_favorites", + "ordering": ("-created_at",), + "unique_together": {("project", "user")}, }, ), migrations.CreateModel( - name='ModuleFavorite', + name="ModuleFavorite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_favorites', to='db.module')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulefavorite', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_favorites', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulefavorite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulefavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_favorites", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_modulefavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="modulefavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_modulefavorite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Module Favorite', - 'verbose_name_plural': 'Module Favorites', - 'db_table': 'module_favorites', - 'ordering': ('-created_at',), - 'unique_together': {('module', 'user')}, + "verbose_name": "Module Favorite", + "verbose_name_plural": "Module Favorites", + "db_table": "module_favorites", + "ordering": ("-created_at",), + "unique_together": {("module", "user")}, }, ), migrations.CreateModel( - name='CycleFavorite', + name="CycleFavorite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cyclefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycle_favorites', to='db.cycle')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cyclefavorite', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cyclefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycle_favorites', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cyclefavorite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cyclefavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_favorites", + to="db.cycle", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_cyclefavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="cyclefavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_cyclefavorite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Cycle Favorite', - 'verbose_name_plural': 'Cycle Favorites', - 'db_table': 'cycle_favorites', - 'ordering': ('-created_at',), - 'unique_together': {('cycle', 'user')}, + "verbose_name": "Cycle Favorite", + "verbose_name_plural": "Cycle Favorites", + "db_table": "cycle_favorites", + "ordering": ("-created_at",), + "unique_together": {("cycle", "user")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0023_auto_20230316_0040.py b/apiserver/plane/db/migrations/0023_auto_20230316_0040.py index c6985866ca6..6f6103caefb 100644 --- a/apiserver/plane/db/migrations/0023_auto_20230316_0040.py +++ b/apiserver/plane/db/migrations/0023_auto_20230316_0040.py @@ -7,86 +7,299 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0022_auto_20230307_0304'), + ("db", "0022_auto_20230307_0304"), ] operations = [ migrations.CreateModel( - name='Importer', + name="Importer", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('service', models.CharField(choices=[('github', 'GitHub')], max_length=50)), - ('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)), - ('metadata', models.JSONField(default=dict)), - ('config', models.JSONField(default=dict)), - ('data', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='imports', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_importer', to='db.project')), - ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='importer', to='db.apitoken')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='importer_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_importer', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "service", + models.CharField( + choices=[("github", "GitHub")], max_length=50 + ), + ), + ( + "status", + models.CharField( + choices=[ + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="queued", + max_length=50, + ), + ), + ("metadata", models.JSONField(default=dict)), + ("config", models.JSONField(default=dict)), + ("data", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="importer_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "initiated_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="imports", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_importer", + to="db.project", + ), + ), + ( + "token", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="importer", + to="db.apitoken", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="importer_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_importer", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Importer', - 'verbose_name_plural': 'Importers', - 'db_table': 'importers', - 'ordering': ('-created_at',), + "verbose_name": "Importer", + "verbose_name_plural": "Importers", + "db_table": "importers", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueView', + name="IssueView", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='View Name')), - ('description', models.TextField(blank=True, verbose_name='View Description')), - ('query', models.JSONField(verbose_name='View Query')), - ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), - ('query_data', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueview', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueview', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="View Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="View Description" + ), + ), + ("query", models.JSONField(verbose_name="View Query")), + ( + "access", + models.PositiveSmallIntegerField( + choices=[(0, "Private"), (1, "Public")], default=1 + ), + ), + ("query_data", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueview_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueview", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueview_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueview", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue View', - 'verbose_name_plural': 'Issue Views', - 'db_table': 'issue_views', - 'ordering': ('-created_at',), + "verbose_name": "Issue View", + "verbose_name_plural": "Issue Views", + "db_table": "issue_views", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='IssueViewFavorite', + name="IssueViewFavorite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueviewfavorite', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueviewfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_view_favorites', to=settings.AUTH_USER_MODEL)), - ('view', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='view_favorites', to='db.issueview')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueviewfavorite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueviewfavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueviewfavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueviewfavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_view_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "view", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="view_favorites", + to="db.issueview", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueviewfavorite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'View Favorite', - 'verbose_name_plural': 'View Favorites', - 'db_table': 'view_favorites', - 'ordering': ('-created_at',), - 'unique_together': {('view', 'user')}, + "verbose_name": "View Favorite", + "verbose_name_plural": "View Favorites", + "db_table": "view_favorites", + "ordering": ("-created_at",), + "unique_together": {("view", "user")}, }, ), migrations.AlterUniqueTogether( - name='label', - unique_together={('name', 'project')}, + name="label", + unique_together={("name", "project")}, ), migrations.DeleteModel( - name='View', + name="View", ), ] diff --git a/apiserver/plane/db/migrations/0024_auto_20230322_0138.py b/apiserver/plane/db/migrations/0024_auto_20230322_0138.py index 65880891a37..7a95d519eaa 100644 --- a/apiserver/plane/db/migrations/0024_auto_20230322_0138.py +++ b/apiserver/plane/db/migrations/0024_auto_20230322_0138.py @@ -7,107 +7,308 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0023_auto_20230316_0040'), + ("db", "0023_auto_20230316_0040"), ] operations = [ migrations.CreateModel( - name='Page', + name="Page", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.JSONField(blank=True, default=dict)), - ('description_html', models.TextField(blank=True, default='

')), - ('description_stripped', models.TextField(blank=True, null=True)), - ('access', models.PositiveSmallIntegerField(choices=[(0, 'Public'), (1, 'Private')], default=0)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pages', to=settings.AUTH_USER_MODEL)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.JSONField(blank=True, default=dict)), + ( + "description_html", + models.TextField(blank=True, default="

"), + ), + ( + "description_stripped", + models.TextField(blank=True, null=True), + ), + ( + "access", + models.PositiveSmallIntegerField( + choices=[(0, "Public"), (1, "Private")], default=0 + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="page_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "owned_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Page', - 'verbose_name_plural': 'Pages', - 'db_table': 'pages', - 'ordering': ('-created_at',), + "verbose_name": "Page", + "verbose_name_plural": "Pages", + "db_table": "pages", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='project', - name='issue_views_view', + model_name="project", + name="issue_views_view", field=models.BooleanField(default=True), ), migrations.AlterField( - model_name='importer', - name='service', - field=models.CharField(choices=[('github', 'GitHub'), ('jira', 'Jira')], max_length=50), + model_name="importer", + name="service", + field=models.CharField( + choices=[("github", "GitHub"), ("jira", "Jira")], max_length=50 + ), ), migrations.AlterField( - model_name='project', - name='cover_image', + model_name="project", + name="cover_image", field=models.URLField(blank=True, max_length=800, null=True), ), migrations.CreateModel( - name='PageBlock', + name="PageBlock", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.JSONField(blank=True, default=dict)), - ('description_html', models.TextField(blank=True, default='

')), - ('description_stripped', models.TextField(blank=True, null=True)), - ('completed_at', models.DateTimeField(null=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blocks', to='db.issue')), - ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='db.page')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pageblock', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pageblock_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pageblock', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.JSONField(blank=True, default=dict)), + ( + "description_html", + models.TextField(blank=True, default="

"), + ), + ( + "description_stripped", + models.TextField(blank=True, null=True), + ), + ("completed_at", models.DateTimeField(null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pageblock_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="blocks", + to="db.issue", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="blocks", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pageblock", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pageblock_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_pageblock", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Page Block', - 'verbose_name_plural': 'Page Blocks', - 'db_table': 'page_blocks', - 'ordering': ('-created_at',), + "verbose_name": "Page Block", + "verbose_name_plural": "Page Blocks", + "db_table": "page_blocks", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='page', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_page', to='db.project'), + model_name="page", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_page", + to="db.project", + ), ), migrations.AddField( - model_name='page', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="page", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="page_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='page', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_page', to='db.workspace'), + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page", + to="db.workspace", + ), ), migrations.CreateModel( - name='PageFavorite', + name="PageFavorite", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to='db.page')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagefavorite', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_favorites', to=settings.AUTH_USER_MODEL)), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagefavorite', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagefavorite_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_favorites", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pagefavorite", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagefavorite_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_pagefavorite", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Page Favorite', - 'verbose_name_plural': 'Page Favorites', - 'db_table': 'page_favorites', - 'ordering': ('-created_at',), - 'unique_together': {('page', 'user')}, + "verbose_name": "Page Favorite", + "verbose_name_plural": "Page Favorites", + "db_table": "page_favorites", + "ordering": ("-created_at",), + "unique_together": {("page", "user")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0025_auto_20230331_0203.py b/apiserver/plane/db/migrations/0025_auto_20230331_0203.py index 1097a4612ef..702d74cfcab 100644 --- a/apiserver/plane/db/migrations/0025_auto_20230331_0203.py +++ b/apiserver/plane/db/migrations/0025_auto_20230331_0203.py @@ -7,55 +7,125 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0024_auto_20230322_0138'), + ("db", "0024_auto_20230322_0138"), ] operations = [ migrations.AddField( - model_name='page', - name='color', + model_name="page", + name="color", field=models.CharField(blank=True, max_length=255), ), migrations.AddField( - model_name='pageblock', - name='sort_order', + model_name="pageblock", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AddField( - model_name='pageblock', - name='sync', + model_name="pageblock", + name="sync", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='project', - name='page_view', + model_name="project", + name="page_view", field=models.BooleanField(default=True), ), migrations.CreateModel( - name='PageLabel', + name="PageLabel", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('label', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.label')), - ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_labels', to='db.page')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_pagelabel', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pagelabel_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_pagelabel', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagelabel_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_labels", + to="db.label", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_labels", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pagelabel", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pagelabel_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_pagelabel", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Page Label', - 'verbose_name_plural': 'Page Labels', - 'db_table': 'page_labels', - 'ordering': ('-created_at',), + "verbose_name": "Page Label", + "verbose_name_plural": "Page Labels", + "db_table": "page_labels", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='page', - name='labels', - field=models.ManyToManyField(blank=True, related_name='pages', through='db.PageLabel', to='db.Label'), + model_name="page", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="pages", + through="db.PageLabel", + to="db.Label", + ), ), ] diff --git a/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py b/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py index 6f74fa49951..310087f9798 100644 --- a/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py +++ b/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py @@ -5,15 +5,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0025_auto_20230331_0203'), + ("db", "0025_auto_20230331_0203"), ] operations = [ migrations.AlterField( - model_name='projectmember', - name='view_props', - field=models.JSONField(default=plane.db.models.project.get_default_props), + model_name="projectmember", + name="view_props", + field=models.JSONField( + default=plane.db.models.project.get_default_props + ), ), - ] \ No newline at end of file + ] diff --git a/apiserver/plane/db/migrations/0027_auto_20230409_0312.py b/apiserver/plane/db/migrations/0027_auto_20230409_0312.py index 8d344cf3403..0377c84e836 100644 --- a/apiserver/plane/db/migrations/0027_auto_20230409_0312.py +++ b/apiserver/plane/db/migrations/0027_auto_20230409_0312.py @@ -9,89 +9,289 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0026_alter_projectmember_view_props'), + ("db", "0026_alter_projectmember_view_props"), ] operations = [ migrations.CreateModel( - name='Estimate', + name="Estimate", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True, verbose_name='Estimate Description')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_estimate', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimate_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_estimate', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "description", + models.TextField( + blank=True, verbose_name="Estimate Description" + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimate_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_estimate", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimate_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_estimate", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Estimate', - 'verbose_name_plural': 'Estimates', - 'db_table': 'estimates', - 'ordering': ('name',), - 'unique_together': {('name', 'project')}, + "verbose_name": "Estimate", + "verbose_name_plural": "Estimates", + "db_table": "estimates", + "ordering": ("name",), + "unique_together": {("name", "project")}, }, ), migrations.RemoveField( - model_name='issue', - name='attachments', + model_name="issue", + name="attachments", ), migrations.AddField( - model_name='issue', - name='estimate_point', - field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]), + model_name="issue", + name="estimate_point", + field=models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(7), + ], + ), ), migrations.CreateModel( - name='IssueAttachment', + name="IssueAttachment", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('attributes', models.JSONField(default=dict)), - ('asset', models.FileField(upload_to=plane.db.models.issue.get_upload_path, validators=[plane.db.models.issue.file_size])), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueattachment_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_attachment', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issueattachment', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issueattachment_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issueattachment', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("attributes", models.JSONField(default=dict)), + ( + "asset", + models.FileField( + upload_to=plane.db.models.issue.get_upload_path, + validators=[plane.db.models.issue.file_size], + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueattachment_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_attachment", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_issueattachment", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issueattachment_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_issueattachment", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Attachment', - 'verbose_name_plural': 'Issue Attachments', - 'db_table': 'issue_attachments', - 'ordering': ('-created_at',), + "verbose_name": "Issue Attachment", + "verbose_name_plural": "Issue Attachments", + "db_table": "issue_attachments", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='project', - name='estimate', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='db.estimate'), + model_name="project", + name="estimate", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="projects", + to="db.estimate", + ), ), migrations.CreateModel( - name='EstimatePoint', + name="EstimatePoint", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('key', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)])), - ('description', models.TextField(blank=True)), - ('value', models.CharField(max_length=20)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimatepoint_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('estimate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='points', to='db.estimate')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_estimatepoint', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='estimatepoint_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_estimatepoint', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "key", + models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(7), + ], + ), + ), + ("description", models.TextField(blank=True)), + ("value", models.CharField(max_length=20)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimatepoint_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "estimate", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="points", + to="db.estimate", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_estimatepoint", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="estimatepoint_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_estimatepoint", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Estimate Point', - 'verbose_name_plural': 'Estimate Points', - 'db_table': 'estimate_points', - 'ordering': ('value',), - 'unique_together': {('value', 'estimate')}, + "verbose_name": "Estimate Point", + "verbose_name_plural": "Estimate Points", + "db_table": "estimate_points", + "ordering": ("value",), + "unique_together": {("value", "estimate")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0028_auto_20230414_1703.py b/apiserver/plane/db/migrations/0028_auto_20230414_1703.py index bb0b67b92aa..ffccccff5c9 100644 --- a/apiserver/plane/db/migrations/0028_auto_20230414_1703.py +++ b/apiserver/plane/db/migrations/0028_auto_20230414_1703.py @@ -8,41 +8,99 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0027_auto_20230409_0312'), + ("db", "0027_auto_20230409_0312"), ] operations = [ migrations.AddField( - model_name='user', - name='theme', + model_name="user", + name="theme", field=models.JSONField(default=dict), ), migrations.AlterField( - model_name='issue', - name='estimate_point', - field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(7)]), + model_name="issue", + name="estimate_point", + field=models.IntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(7), + ], + ), ), migrations.CreateModel( - name='WorkspaceTheme', + name="WorkspaceTheme", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=300)), - ('colors', models.JSONField(default=dict)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspacetheme_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='themes', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=300)), + ("colors", models.JSONField(default=dict)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="themes", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacetheme_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspacetheme_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="themes", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Workspace Theme', - 'verbose_name_plural': 'Workspace Themes', - 'db_table': 'workspace_themes', - 'ordering': ('-created_at',), - 'unique_together': {('workspace', 'name')}, + "verbose_name": "Workspace Theme", + "verbose_name_plural": "Workspace Themes", + "db_table": "workspace_themes", + "ordering": ("-created_at",), + "unique_together": {("workspace", "name")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0029_auto_20230502_0126.py b/apiserver/plane/db/migrations/0029_auto_20230502_0126.py index 373cc39bddd..cd2b1b865a9 100644 --- a/apiserver/plane/db/migrations/0029_auto_20230502_0126.py +++ b/apiserver/plane/db/migrations/0029_auto_20230502_0126.py @@ -7,52 +7,110 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0028_auto_20230414_1703'), + ("db", "0028_auto_20230414_1703"), ] operations = [ migrations.AddField( - model_name='cycle', - name='view_props', + model_name="cycle", + name="view_props", field=models.JSONField(default=dict), ), migrations.AddField( - model_name='importer', - name='imported_data', + model_name="importer", + name="imported_data", field=models.JSONField(null=True), ), migrations.AddField( - model_name='module', - name='view_props', + model_name="module", + name="view_props", field=models.JSONField(default=dict), ), migrations.CreateModel( - name='SlackProjectSync', + name="SlackProjectSync", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('access_token', models.CharField(max_length=300)), - ('scopes', models.TextField()), - ('bot_user_id', models.CharField(max_length=50)), - ('webhook_url', models.URLField(max_length=1000)), - ('data', models.JSONField(default=dict)), - ('team_id', models.CharField(max_length=30)), - ('team_name', models.CharField(max_length=300)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_slackprojectsync', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slackprojectsync_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_slackprojectsync', to='db.workspace')), - ('workspace_integration', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slack_syncs', to='db.workspaceintegration')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("access_token", models.CharField(max_length=300)), + ("scopes", models.TextField()), + ("bot_user_id", models.CharField(max_length=50)), + ("webhook_url", models.URLField(max_length=1000)), + ("data", models.JSONField(default=dict)), + ("team_id", models.CharField(max_length=30)), + ("team_name", models.CharField(max_length=300)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="slackprojectsync_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_slackprojectsync", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="slackprojectsync_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_slackprojectsync", + to="db.workspace", + ), + ), + ( + "workspace_integration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="slack_syncs", + to="db.workspaceintegration", + ), + ), ], options={ - 'verbose_name': 'Slack Project Sync', - 'verbose_name_plural': 'Slack Project Syncs', - 'db_table': 'slack_project_syncs', - 'ordering': ('-created_at',), - 'unique_together': {('team_id', 'project')}, + "verbose_name": "Slack Project Sync", + "verbose_name_plural": "Slack Project Syncs", + "db_table": "slack_project_syncs", + "ordering": ("-created_at",), + "unique_together": {("team_id", "project")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py b/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py index bfc1da53020..63db205dcbc 100644 --- a/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py +++ b/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py @@ -4,14 +4,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0029_auto_20230502_0126'), + ("db", "0029_auto_20230502_0126"), ] operations = [ migrations.AlterUniqueTogether( - name='estimatepoint', + name="estimatepoint", unique_together=set(), ), ] diff --git a/apiserver/plane/db/migrations/0031_analyticview.py b/apiserver/plane/db/migrations/0031_analyticview.py index 7e02b78b263..f4520a8f52e 100644 --- a/apiserver/plane/db/migrations/0031_analyticview.py +++ b/apiserver/plane/db/migrations/0031_analyticview.py @@ -7,31 +7,75 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0030_alter_estimatepoint_unique_together'), + ("db", "0030_alter_estimatepoint_unique_together"), ] operations = [ migrations.CreateModel( - name='AnalyticView', + name="AnalyticView", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('query', models.JSONField()), - ('query_dict', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analytics', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("query", models.JSONField()), + ("query_dict", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="analyticview_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="analyticview_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="analytics", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Analytic', - 'verbose_name_plural': 'Analytics', - 'db_table': 'analytic_views', - 'ordering': ('-created_at',), + "verbose_name": "Analytic", + "verbose_name_plural": "Analytics", + "db_table": "analytic_views", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0032_auto_20230520_2015.py b/apiserver/plane/db/migrations/0032_auto_20230520_2015.py index 27c13537e1c..c781d298cf3 100644 --- a/apiserver/plane/db/migrations/0032_auto_20230520_2015.py +++ b/apiserver/plane/db/migrations/0032_auto_20230520_2015.py @@ -4,20 +4,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0031_analyticview'), + ("db", "0031_analyticview"), ] operations = [ migrations.RenameField( - model_name='project', - old_name='icon', - new_name='emoji', + model_name="project", + old_name="icon", + new_name="emoji", ), migrations.AddField( - model_name='project', - name='icon_prop', + model_name="project", + name="icon_prop", field=models.JSONField(null=True), ), ] diff --git a/apiserver/plane/db/migrations/0033_auto_20230618_2125.py b/apiserver/plane/db/migrations/0033_auto_20230618_2125.py index 8eb2eda622b..1705aead6cb 100644 --- a/apiserver/plane/db/migrations/0033_auto_20230618_2125.py +++ b/apiserver/plane/db/migrations/0033_auto_20230618_2125.py @@ -7,77 +7,210 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0032_auto_20230520_2015'), + ("db", "0032_auto_20230520_2015"), ] operations = [ migrations.CreateModel( - name='Inbox', + name="Inbox", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True, verbose_name='Inbox Description')), - ('is_default', models.BooleanField(default=False)), - ('view_props', models.JSONField(default=dict)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "description", + models.TextField( + blank=True, verbose_name="Inbox Description" + ), + ), + ("is_default", models.BooleanField(default=False)), + ("view_props", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inbox_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), ], options={ - 'verbose_name': 'Inbox', - 'verbose_name_plural': 'Inboxes', - 'db_table': 'inboxes', - 'ordering': ('name',), + "verbose_name": "Inbox", + "verbose_name_plural": "Inboxes", + "db_table": "inboxes", + "ordering": ("name",), }, ), migrations.AddField( - model_name='project', - name='inbox_view', + model_name="project", + name="inbox_view", field=models.BooleanField(default=False), ), migrations.CreateModel( - name='InboxIssue', + name="InboxIssue", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('status', models.IntegerField(choices=[(-2, 'Pending'), (-1, 'Rejected'), (0, 'Snoozed'), (1, 'Accepted'), (2, 'Duplicate')], default=-2)), - ('snoozed_till', models.DateTimeField(null=True)), - ('source', models.TextField(blank=True, null=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('duplicate_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_duplicate', to='db.issue')), - ('inbox', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.inbox')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inboxissue', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inboxissue', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "status", + models.IntegerField( + choices=[ + (-2, "Pending"), + (-1, "Rejected"), + (0, "Snoozed"), + (1, "Accepted"), + (2, "Duplicate"), + ], + default=-2, + ), + ), + ("snoozed_till", models.DateTimeField(null=True)), + ("source", models.TextField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inboxissue_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "duplicate_to", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inbox_duplicate", + to="db.issue", + ), + ), + ( + "inbox", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_inbox", + to="db.inbox", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_inbox", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_inboxissue", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inboxissue_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_inboxissue", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'InboxIssue', - 'verbose_name_plural': 'InboxIssues', - 'db_table': 'inbox_issues', - 'ordering': ('-created_at',), + "verbose_name": "InboxIssue", + "verbose_name_plural": "InboxIssues", + "db_table": "inbox_issues", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='inbox', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inbox', to='db.project'), + model_name="inbox", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_inbox", + to="db.project", + ), ), migrations.AddField( - model_name='inbox', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="inbox", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="inbox_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.AddField( - model_name='inbox', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inbox', to='db.workspace'), + model_name="inbox", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_inbox", + to="db.workspace", + ), ), migrations.AlterUniqueTogether( - name='inbox', - unique_together={('name', 'project')}, + name="inbox", + unique_together={("name", "project")}, ), ] diff --git a/apiserver/plane/db/migrations/0034_auto_20230628_1046.py b/apiserver/plane/db/migrations/0034_auto_20230628_1046.py index cdd722f59bf..dd6d21f6d8d 100644 --- a/apiserver/plane/db/migrations/0034_auto_20230628_1046.py +++ b/apiserver/plane/db/migrations/0034_auto_20230628_1046.py @@ -4,36 +4,35 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0033_auto_20230618_2125'), + ("db", "0033_auto_20230618_2125"), ] operations = [ migrations.RemoveField( - model_name='timelineissue', - name='created_by', + model_name="timelineissue", + name="created_by", ), migrations.RemoveField( - model_name='timelineissue', - name='issue', + model_name="timelineissue", + name="issue", ), migrations.RemoveField( - model_name='timelineissue', - name='project', + model_name="timelineissue", + name="project", ), migrations.RemoveField( - model_name='timelineissue', - name='updated_by', + model_name="timelineissue", + name="updated_by", ), migrations.RemoveField( - model_name='timelineissue', - name='workspace', + model_name="timelineissue", + name="workspace", ), migrations.DeleteModel( - name='Shortcut', + name="Shortcut", ), migrations.DeleteModel( - name='TimelineIssue', + name="TimelineIssue", ), ] diff --git a/apiserver/plane/db/migrations/0035_auto_20230704_2225.py b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py index dec6265e615..806bfef51c4 100644 --- a/apiserver/plane/db/migrations/0035_auto_20230704_2225.py +++ b/apiserver/plane/db/migrations/0035_auto_20230704_2225.py @@ -10,7 +10,9 @@ def update_company_organization_size(apps, schema_editor): obj.organization_size = str(obj.company_size) updated_size.append(obj) - Model.objects.bulk_update(updated_size, ["organization_size"], batch_size=100) + Model.objects.bulk_update( + updated_size, ["organization_size"], batch_size=100 + ) class Migration(migrations.Migration): @@ -28,7 +30,9 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="workspace", name="name", - field=models.CharField(max_length=80, verbose_name="Workspace Name"), + field=models.CharField( + max_length=80, verbose_name="Workspace Name" + ), ), migrations.AlterField( model_name="workspace", diff --git a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py index 0b182f50b77..86748c77888 100644 --- a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py +++ b/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0035_auto_20230704_2225'), + ("db", "0035_auto_20230704_2225"), ] operations = [ migrations.AlterField( - model_name='workspace', - name='organization_size', + model_name="workspace", + name="organization_size", field=models.CharField(max_length=20), ), ] diff --git a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py index d11e1afd83f..e659133d105 100644 --- a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py +++ b/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py @@ -8,7 +8,6 @@ import uuid - def onboarding_default_steps(apps, schema_editor): default_onboarding_schema = { "workspace_join": True, @@ -24,7 +23,9 @@ def onboarding_default_steps(apps, schema_editor): obj.is_tour_completed = True updated_user.append(obj) - Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100) + Model.objects.bulk_update( + updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100 + ) class Migration(migrations.Migration): @@ -78,7 +79,9 @@ class Migration(migrations.Migration): migrations.AddField( model_name="user", name="onboarding_step", - field=models.JSONField(default=plane.db.models.user.get_default_onboarding), + field=models.JSONField( + default=plane.db.models.user.get_default_onboarding + ), ), migrations.RunPython(onboarding_default_steps), migrations.CreateModel( @@ -86,7 +89,9 @@ class Migration(migrations.Migration): fields=[ ( "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), ), ( "updated_at", @@ -110,7 +115,10 @@ class Migration(migrations.Migration): ("entity_name", models.CharField(max_length=255)), ("title", models.TextField()), ("message", models.JSONField(null=True)), - ("message_html", models.TextField(blank=True, default="

")), + ( + "message_html", + models.TextField(blank=True, default="

"), + ), ("message_stripped", models.TextField(blank=True, null=True)), ("sender", models.CharField(max_length=255)), ("read_at", models.DateTimeField(null=True)), @@ -183,7 +191,9 @@ class Migration(migrations.Migration): fields=[ ( "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), ), ( "updated_at", diff --git a/apiserver/plane/db/migrations/0038_auto_20230720_1505.py b/apiserver/plane/db/migrations/0038_auto_20230720_1505.py index 1f5c63a89c9..53e50ed41e3 100644 --- a/apiserver/plane/db/migrations/0038_auto_20230720_1505.py +++ b/apiserver/plane/db/migrations/0038_auto_20230720_1505.py @@ -15,14 +15,12 @@ def restructure_theming(apps, schema_editor): "text": current_theme.get("textBase", ""), "sidebarText": current_theme.get("textBase", ""), "palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""", - "darkPalette": current_theme.get("darkPalette", "") + "darkPalette": current_theme.get("darkPalette", ""), } obj.theme = updated_theme updated_user.append(obj) - Model.objects.bulk_update( - updated_user, ["theme"], batch_size=100 - ) + Model.objects.bulk_update(updated_user, ["theme"], batch_size=100) class Migration(migrations.Migration): @@ -30,6 +28,4 @@ class Migration(migrations.Migration): ("db", "0037_issue_archived_at_project_archive_in_and_more"), ] - operations = [ - migrations.RunPython(restructure_theming) - ] + operations = [migrations.RunPython(restructure_theming)] diff --git a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py index 5d5747543f4..26849d7f7cb 100644 --- a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py +++ b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py @@ -55,7 +55,9 @@ def update_workspace_member_props(apps, schema_editor): updated_workspace_member.append(obj) - Model.objects.bulk_update(updated_workspace_member, ["view_props"], batch_size=100) + Model.objects.bulk_update( + updated_workspace_member, ["view_props"], batch_size=100 + ) def update_project_member_sort_order(apps, schema_editor): @@ -67,7 +69,9 @@ def update_project_member_sort_order(apps, schema_editor): obj.sort_order = random.randint(1, 65536) updated_project_members.append(obj) - Model.objects.bulk_update(updated_project_members, ["sort_order"], batch_size=100) + Model.objects.bulk_update( + updated_project_members, ["sort_order"], batch_size=100 + ) class Migration(migrations.Migration): @@ -79,18 +83,22 @@ class Migration(migrations.Migration): migrations.RunPython(rename_field), migrations.RunPython(update_workspace_member_props), migrations.AlterField( - model_name='workspacemember', - name='view_props', - field=models.JSONField(default=plane.db.models.workspace.get_default_props), + model_name="workspacemember", + name="view_props", + field=models.JSONField( + default=plane.db.models.workspace.get_default_props + ), ), migrations.AddField( - model_name='workspacemember', - name='default_props', - field=models.JSONField(default=plane.db.models.workspace.get_default_props), + model_name="workspacemember", + name="default_props", + field=models.JSONField( + default=plane.db.models.workspace.get_default_props + ), ), migrations.AddField( - model_name='projectmember', - name='sort_order', + model_name="projectmember", + name="sort_order", field=models.FloatField(default=65535), ), migrations.RunPython(update_project_member_sort_order), diff --git a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py index 5662ef666af..76f8e6272c3 100644 --- a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py +++ b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py @@ -8,74 +8,209 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0039_auto_20230723_2203'), + ("db", "0039_auto_20230723_2203"), ] operations = [ migrations.AddField( - model_name='projectmember', - name='preferences', - field=models.JSONField(default=plane.db.models.project.get_default_preferences), + model_name="projectmember", + name="preferences", + field=models.JSONField( + default=plane.db.models.project.get_default_preferences + ), ), migrations.AddField( - model_name='user', - name='cover_image', + model_name="user", + name="cover_image", field=models.URLField(blank=True, max_length=800, null=True), ), migrations.CreateModel( - name='IssueReaction', + name="IssueReaction", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('reaction', models.CharField(max_length=20)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("reaction", models.CharField(max_length=20)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_reactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_reactions", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Reaction', - 'verbose_name_plural': 'Issue Reactions', - 'db_table': 'issue_reactions', - 'ordering': ('-created_at',), - 'unique_together': {('issue', 'actor', 'reaction')}, + "verbose_name": "Issue Reaction", + "verbose_name_plural": "Issue Reactions", + "db_table": "issue_reactions", + "ordering": ("-created_at",), + "unique_together": {("issue", "actor", "reaction")}, }, ), migrations.CreateModel( - name='CommentReaction', + name="CommentReaction", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('reaction', models.CharField(max_length=20)), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to=settings.AUTH_USER_MODEL)), - ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to='db.issuecomment')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("reaction", models.CharField(max_length=20)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_reactions", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comment_reactions", + to="db.issuecomment", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Comment Reaction', - 'verbose_name_plural': 'Comment Reactions', - 'db_table': 'comment_reactions', - 'ordering': ('-created_at',), - 'unique_together': {('comment', 'actor', 'reaction')}, + "verbose_name": "Comment Reaction", + "verbose_name_plural": "Comment Reactions", + "db_table": "comment_reactions", + "ordering": ("-created_at",), + "unique_together": {("comment", "actor", "reaction")}, }, - ), + ), migrations.AlterField( - model_name='project', - name='identifier', - field=models.CharField(max_length=12, verbose_name='Project Identifier'), + model_name="project", + name="identifier", + field=models.CharField( + max_length=12, verbose_name="Project Identifier" + ), ), migrations.AlterField( - model_name='projectidentifier', - name='name', + model_name="projectidentifier", + name="name", field=models.CharField(max_length=12), ), ] diff --git a/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py b/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py index 07c302c76b6..91119dbbd1a 100644 --- a/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py +++ b/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py @@ -10,6 +10,7 @@ import random import string + def generate_display_name(apps, schema_editor): UserModel = apps.get_model("db", "User") updated_users = [] @@ -20,7 +21,9 @@ def generate_display_name(apps, schema_editor): else "".join(random.choice(string.ascii_letters) for _ in range(6)) ) updated_users.append(obj) - UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100) + UserModel.objects.bulk_update( + updated_users, ["display_name"], batch_size=100 + ) def rectify_field_issue_activity(apps, schema_editor): @@ -72,7 +75,13 @@ def update_assignee_issue_activity(apps, schema_editor): Model.objects.bulk_update( updated_activity, - ["old_value", "new_value", "old_identifier", "new_identifier", "comment"], + [ + "old_value", + "new_value", + "old_identifier", + "new_identifier", + "comment", + ], batch_size=200, ) @@ -93,7 +102,9 @@ def random_cycle_order(apps, schema_editor): for obj in CycleModel.objects.all(): obj.sort_order = random.randint(1, 65536) updated_cycles.append(obj) - CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100) + CycleModel.objects.bulk_update( + updated_cycles, ["sort_order"], batch_size=100 + ) def random_module_order(apps, schema_editor): @@ -102,7 +113,9 @@ def random_module_order(apps, schema_editor): for obj in ModuleModel.objects.all(): obj.sort_order = random.randint(1, 65536) updated_modules.append(obj) - ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100) + ModuleModel.objects.bulk_update( + updated_modules, ["sort_order"], batch_size=100 + ) def update_user_issue_properties(apps, schema_editor): @@ -125,111 +138,353 @@ def workspace_member_properties(apps, schema_editor): updated_workspace_members.append(obj) WorkspaceMemberModel.objects.bulk_update( - updated_workspace_members, ["view_props", "default_props"], batch_size=100 + updated_workspace_members, + ["view_props", "default_props"], + batch_size=100, ) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('db', '0040_projectmember_preferences_user_cover_image_and_more'), + ("db", "0040_projectmember_preferences_user_cover_image_and_more"), ] operations = [ migrations.AddField( - model_name='cycle', - name='sort_order', + model_name="cycle", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AddField( - model_name='issuecomment', - name='access', - field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100), + model_name="issuecomment", + name="access", + field=models.CharField( + choices=[("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")], + default="INTERNAL", + max_length=100, + ), ), migrations.AddField( - model_name='module', - name='sort_order', + model_name="module", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AddField( - model_name='user', - name='display_name', - field=models.CharField(default='', max_length=255), + model_name="user", + name="display_name", + field=models.CharField(default="", max_length=255), ), migrations.CreateModel( - name='ExporterHistory', + name="ExporterHistory", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)), - ('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)), - ('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)), - ('reason', models.TextField(blank=True)), - ('key', models.TextField(blank=True)), - ('url', models.URLField(blank=True, max_length=800, null=True)), - ('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "project", + django.contrib.postgres.fields.ArrayField( + base_field=models.UUIDField(default=uuid.uuid4), + blank=True, + null=True, + size=None, + ), + ), + ( + "provider", + models.CharField( + choices=[ + ("json", "json"), + ("csv", "csv"), + ("xlsx", "xlsx"), + ], + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("queued", "Queued"), + ("processing", "Processing"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="queued", + max_length=50, + ), + ), + ("reason", models.TextField(blank=True)), + ("key", models.TextField(blank=True)), + ( + "url", + models.URLField(blank=True, max_length=800, null=True), + ), + ( + "token", + models.CharField( + default=plane.db.models.exporter.generate_token, + max_length=255, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "initiated_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_exporters", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_exporters", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Exporter', - 'verbose_name_plural': 'Exporters', - 'db_table': 'exporters', - 'ordering': ('-created_at',), + "verbose_name": "Exporter", + "verbose_name_plural": "Exporters", + "db_table": "exporters", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='ProjectDeployBoard', + name="ProjectDeployBoard", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)), - ('comments', models.BooleanField(default=False)), - ('reactions', models.BooleanField(default=False)), - ('votes', models.BooleanField(default=False)), - ('views', models.JSONField(default=plane.db.models.project.get_default_views)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "anchor", + models.CharField( + db_index=True, + default=plane.db.models.project.get_anchor, + max_length=255, + unique=True, + ), + ), + ("comments", models.BooleanField(default=False)), + ("reactions", models.BooleanField(default=False)), + ("votes", models.BooleanField(default=False)), + ( + "views", + models.JSONField( + default=plane.db.models.project.get_default_views + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "inbox", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bord_inbox", + to="db.inbox", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Project Deploy Board', - 'verbose_name_plural': 'Project Deploy Boards', - 'db_table': 'project_deploy_boards', - 'ordering': ('-created_at',), - 'unique_together': {('project', 'anchor')}, + "verbose_name": "Project Deploy Board", + "verbose_name_plural": "Project Deploy Boards", + "db_table": "project_deploy_boards", + "ordering": ("-created_at",), + "unique_together": {("project", "anchor")}, }, ), migrations.CreateModel( - name='IssueVote', + name="IssueVote", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])), - ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "vote", + models.IntegerField( + choices=[(-1, "DOWNVOTE"), (1, "UPVOTE")] + ), + ), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Vote', - 'verbose_name_plural': 'Issue Votes', - 'db_table': 'issue_votes', - 'ordering': ('-created_at',), - 'unique_together': {('issue', 'actor')}, + "verbose_name": "Issue Vote", + "verbose_name_plural": "Issue Votes", + "db_table": "issue_votes", + "ordering": ("-created_at",), + "unique_together": {("issue", "actor")}, }, ), migrations.AlterField( - model_name='modulelink', - name='title', + model_name="modulelink", + name="title", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.RunPython(generate_display_name), diff --git a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py index 01af46d20cf..f1fa99a3659 100644 --- a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py @@ -5,56 +5,762 @@ import django.db.models.deletion import uuid + def update_user_timezones(apps, schema_editor): UserModel = apps.get_model("db", "User") updated_users = [] for obj in UserModel.objects.all(): obj.user_timezone = "UTC" updated_users.append(obj) - UserModel.objects.bulk_update(updated_users, ["user_timezone"], batch_size=100) + UserModel.objects.bulk_update( + updated_users, ["user_timezone"], batch_size=100 + ) class Migration(migrations.Migration): - dependencies = [ - ('db', '0041_cycle_sort_order_issuecomment_access_and_more'), + ("db", "0041_cycle_sort_order_issuecomment_access_and_more"), ] operations = [ migrations.AlterField( - model_name='user', - name='user_timezone', - field=models.CharField(choices=[('Africa/Abidjan', 'Africa/Abidjan'), ('Africa/Accra', 'Africa/Accra'), ('Africa/Addis_Ababa', 'Africa/Addis_Ababa'), ('Africa/Algiers', 'Africa/Algiers'), ('Africa/Asmara', 'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'), ('Africa/Bamako', 'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'), ('Africa/Banjul', 'Africa/Banjul'), ('Africa/Bissau', 'Africa/Bissau'), ('Africa/Blantyre', 'Africa/Blantyre'), ('Africa/Brazzaville', 'Africa/Brazzaville'), ('Africa/Bujumbura', 'Africa/Bujumbura'), ('Africa/Cairo', 'Africa/Cairo'), ('Africa/Casablanca', 'Africa/Casablanca'), ('Africa/Ceuta', 'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'), ('Africa/Dakar', 'Africa/Dakar'), ('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'), ('Africa/Djibouti', 'Africa/Djibouti'), ('Africa/Douala', 'Africa/Douala'), ('Africa/El_Aaiun', 'Africa/El_Aaiun'), ('Africa/Freetown', 'Africa/Freetown'), ('Africa/Gaborone', 'Africa/Gaborone'), ('Africa/Harare', 'Africa/Harare'), ('Africa/Johannesburg', 'Africa/Johannesburg'), ('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'), ('Africa/Khartoum', 'Africa/Khartoum'), ('Africa/Kigali', 'Africa/Kigali'), ('Africa/Kinshasa', 'Africa/Kinshasa'), ('Africa/Lagos', 'Africa/Lagos'), ('Africa/Libreville', 'Africa/Libreville'), ('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'), ('Africa/Lubumbashi', 'Africa/Lubumbashi'), ('Africa/Lusaka', 'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'), ('Africa/Maputo', 'Africa/Maputo'), ('Africa/Maseru', 'Africa/Maseru'), ('Africa/Mbabane', 'Africa/Mbabane'), ('Africa/Mogadishu', 'Africa/Mogadishu'), ('Africa/Monrovia', 'Africa/Monrovia'), ('Africa/Nairobi', 'Africa/Nairobi'), ('Africa/Ndjamena', 'Africa/Ndjamena'), ('Africa/Niamey', 'Africa/Niamey'), ('Africa/Nouakchott', 'Africa/Nouakchott'), ('Africa/Ouagadougou', 'Africa/Ouagadougou'), ('Africa/Porto-Novo', 'Africa/Porto-Novo'), ('Africa/Sao_Tome', 'Africa/Sao_Tome'), ('Africa/Timbuktu', 'Africa/Timbuktu'), ('Africa/Tripoli', 'Africa/Tripoli'), ('Africa/Tunis', 'Africa/Tunis'), ('Africa/Windhoek', 'Africa/Windhoek'), ('America/Adak', 'America/Adak'), ('America/Anchorage', 'America/Anchorage'), ('America/Anguilla', 'America/Anguilla'), ('America/Antigua', 'America/Antigua'), ('America/Araguaina', 'America/Araguaina'), ('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'), ('America/Argentina/Catamarca', 'America/Argentina/Catamarca'), ('America/Argentina/ComodRivadavia', 'America/Argentina/ComodRivadavia'), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'), ('America/Argentina/Jujuy', 'America/Argentina/Jujuy'), ('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'), ('America/Argentina/Mendoza', 'America/Argentina/Mendoza'), ('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'), ('America/Argentina/Salta', 'America/Argentina/Salta'), ('America/Argentina/San_Juan', 'America/Argentina/San_Juan'), ('America/Argentina/San_Luis', 'America/Argentina/San_Luis'), ('America/Argentina/Tucuman', 'America/Argentina/Tucuman'), ('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'), ('America/Aruba', 'America/Aruba'), ('America/Asuncion', 'America/Asuncion'), ('America/Atikokan', 'America/Atikokan'), ('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'), ('America/Bahia_Banderas', 'America/Bahia_Banderas'), ('America/Barbados', 'America/Barbados'), ('America/Belem', 'America/Belem'), ('America/Belize', 'America/Belize'), ('America/Blanc-Sablon', 'America/Blanc-Sablon'), ('America/Boa_Vista', 'America/Boa_Vista'), ('America/Bogota', 'America/Bogota'), ('America/Boise', 'America/Boise'), ('America/Buenos_Aires', 'America/Buenos_Aires'), ('America/Cambridge_Bay', 'America/Cambridge_Bay'), ('America/Campo_Grande', 'America/Campo_Grande'), ('America/Cancun', 'America/Cancun'), ('America/Caracas', 'America/Caracas'), ('America/Catamarca', 'America/Catamarca'), ('America/Cayenne', 'America/Cayenne'), ('America/Cayman', 'America/Cayman'), ('America/Chicago', 'America/Chicago'), ('America/Chihuahua', 'America/Chihuahua'), ('America/Ciudad_Juarez', 'America/Ciudad_Juarez'), ('America/Coral_Harbour', 'America/Coral_Harbour'), ('America/Cordoba', 'America/Cordoba'), ('America/Costa_Rica', 'America/Costa_Rica'), ('America/Creston', 'America/Creston'), ('America/Cuiaba', 'America/Cuiaba'), ('America/Curacao', 'America/Curacao'), ('America/Danmarkshavn', 'America/Danmarkshavn'), ('America/Dawson', 'America/Dawson'), ('America/Dawson_Creek', 'America/Dawson_Creek'), ('America/Denver', 'America/Denver'), ('America/Detroit', 'America/Detroit'), ('America/Dominica', 'America/Dominica'), ('America/Edmonton', 'America/Edmonton'), ('America/Eirunepe', 'America/Eirunepe'), ('America/El_Salvador', 'America/El_Salvador'), ('America/Ensenada', 'America/Ensenada'), ('America/Fort_Nelson', 'America/Fort_Nelson'), ('America/Fort_Wayne', 'America/Fort_Wayne'), ('America/Fortaleza', 'America/Fortaleza'), ('America/Glace_Bay', 'America/Glace_Bay'), ('America/Godthab', 'America/Godthab'), ('America/Goose_Bay', 'America/Goose_Bay'), ('America/Grand_Turk', 'America/Grand_Turk'), ('America/Grenada', 'America/Grenada'), ('America/Guadeloupe', 'America/Guadeloupe'), ('America/Guatemala', 'America/Guatemala'), ('America/Guayaquil', 'America/Guayaquil'), ('America/Guyana', 'America/Guyana'), ('America/Halifax', 'America/Halifax'), ('America/Havana', 'America/Havana'), ('America/Hermosillo', 'America/Hermosillo'), ('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'), ('America/Indiana/Knox', 'America/Indiana/Knox'), ('America/Indiana/Marengo', 'America/Indiana/Marengo'), ('America/Indiana/Petersburg', 'America/Indiana/Petersburg'), ('America/Indiana/Tell_City', 'America/Indiana/Tell_City'), ('America/Indiana/Vevay', 'America/Indiana/Vevay'), ('America/Indiana/Vincennes', 'America/Indiana/Vincennes'), ('America/Indiana/Winamac', 'America/Indiana/Winamac'), ('America/Indianapolis', 'America/Indianapolis'), ('America/Inuvik', 'America/Inuvik'), ('America/Iqaluit', 'America/Iqaluit'), ('America/Jamaica', 'America/Jamaica'), ('America/Jujuy', 'America/Jujuy'), ('America/Juneau', 'America/Juneau'), ('America/Kentucky/Louisville', 'America/Kentucky/Louisville'), ('America/Kentucky/Monticello', 'America/Kentucky/Monticello'), ('America/Knox_IN', 'America/Knox_IN'), ('America/Kralendijk', 'America/Kralendijk'), ('America/La_Paz', 'America/La_Paz'), ('America/Lima', 'America/Lima'), ('America/Los_Angeles', 'America/Los_Angeles'), ('America/Louisville', 'America/Louisville'), ('America/Lower_Princes', 'America/Lower_Princes'), ('America/Maceio', 'America/Maceio'), ('America/Managua', 'America/Managua'), ('America/Manaus', 'America/Manaus'), ('America/Marigot', 'America/Marigot'), ('America/Martinique', 'America/Martinique'), ('America/Matamoros', 'America/Matamoros'), ('America/Mazatlan', 'America/Mazatlan'), ('America/Mendoza', 'America/Mendoza'), ('America/Menominee', 'America/Menominee'), ('America/Merida', 'America/Merida'), ('America/Metlakatla', 'America/Metlakatla'), ('America/Mexico_City', 'America/Mexico_City'), ('America/Miquelon', 'America/Miquelon'), ('America/Moncton', 'America/Moncton'), ('America/Monterrey', 'America/Monterrey'), ('America/Montevideo', 'America/Montevideo'), ('America/Montreal', 'America/Montreal'), ('America/Montserrat', 'America/Montserrat'), ('America/Nassau', 'America/Nassau'), ('America/New_York', 'America/New_York'), ('America/Nipigon', 'America/Nipigon'), ('America/Nome', 'America/Nome'), ('America/Noronha', 'America/Noronha'), ('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'), ('America/North_Dakota/Center', 'America/North_Dakota/Center'), ('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'), ('America/Nuuk', 'America/Nuuk'), ('America/Ojinaga', 'America/Ojinaga'), ('America/Panama', 'America/Panama'), ('America/Pangnirtung', 'America/Pangnirtung'), ('America/Paramaribo', 'America/Paramaribo'), ('America/Phoenix', 'America/Phoenix'), ('America/Port-au-Prince', 'America/Port-au-Prince'), ('America/Port_of_Spain', 'America/Port_of_Spain'), ('America/Porto_Acre', 'America/Porto_Acre'), ('America/Porto_Velho', 'America/Porto_Velho'), ('America/Puerto_Rico', 'America/Puerto_Rico'), ('America/Punta_Arenas', 'America/Punta_Arenas'), ('America/Rainy_River', 'America/Rainy_River'), ('America/Rankin_Inlet', 'America/Rankin_Inlet'), ('America/Recife', 'America/Recife'), ('America/Regina', 'America/Regina'), ('America/Resolute', 'America/Resolute'), ('America/Rio_Branco', 'America/Rio_Branco'), ('America/Rosario', 'America/Rosario'), ('America/Santa_Isabel', 'America/Santa_Isabel'), ('America/Santarem', 'America/Santarem'), ('America/Santiago', 'America/Santiago'), ('America/Santo_Domingo', 'America/Santo_Domingo'), ('America/Sao_Paulo', 'America/Sao_Paulo'), ('America/Scoresbysund', 'America/Scoresbysund'), ('America/Shiprock', 'America/Shiprock'), ('America/Sitka', 'America/Sitka'), ('America/St_Barthelemy', 'America/St_Barthelemy'), ('America/St_Johns', 'America/St_Johns'), ('America/St_Kitts', 'America/St_Kitts'), ('America/St_Lucia', 'America/St_Lucia'), ('America/St_Thomas', 'America/St_Thomas'), ('America/St_Vincent', 'America/St_Vincent'), ('America/Swift_Current', 'America/Swift_Current'), ('America/Tegucigalpa', 'America/Tegucigalpa'), ('America/Thule', 'America/Thule'), ('America/Thunder_Bay', 'America/Thunder_Bay'), ('America/Tijuana', 'America/Tijuana'), ('America/Toronto', 'America/Toronto'), ('America/Tortola', 'America/Tortola'), ('America/Vancouver', 'America/Vancouver'), ('America/Virgin', 'America/Virgin'), ('America/Whitehorse', 'America/Whitehorse'), ('America/Winnipeg', 'America/Winnipeg'), ('America/Yakutat', 'America/Yakutat'), ('America/Yellowknife', 'America/Yellowknife'), ('Antarctica/Casey', 'Antarctica/Casey'), ('Antarctica/Davis', 'Antarctica/Davis'), ('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'), ('Antarctica/Macquarie', 'Antarctica/Macquarie'), ('Antarctica/Mawson', 'Antarctica/Mawson'), ('Antarctica/McMurdo', 'Antarctica/McMurdo'), ('Antarctica/Palmer', 'Antarctica/Palmer'), ('Antarctica/Rothera', 'Antarctica/Rothera'), ('Antarctica/South_Pole', 'Antarctica/South_Pole'), ('Antarctica/Syowa', 'Antarctica/Syowa'), ('Antarctica/Troll', 'Antarctica/Troll'), ('Antarctica/Vostok', 'Antarctica/Vostok'), ('Arctic/Longyearbyen', 'Arctic/Longyearbyen'), ('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'), ('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'), ('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'), ('Asia/Ashgabat', 'Asia/Ashgabat'), ('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'), ('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'), ('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'), ('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'), ('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'), ('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'), ('Asia/Choibalsan', 'Asia/Choibalsan'), ('Asia/Chongqing', 'Asia/Chongqing'), ('Asia/Chungking', 'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'), ('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'), ('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'), ('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'), ('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'), ('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'), ('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'), ('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'), ('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'), ('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'), ('Asia/Kamchatka', 'Asia/Kamchatka'), ('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'), ('Asia/Kathmandu', 'Asia/Kathmandu'), ('Asia/Katmandu', 'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'), ('Asia/Kolkata', 'Asia/Kolkata'), ('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'), ('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'), ('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'), ('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'), ('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'), ('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'), ('Asia/Nicosia', 'Asia/Nicosia'), ('Asia/Novokuznetsk', 'Asia/Novokuznetsk'), ('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'), ('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'), ('Asia/Pontianak', 'Asia/Pontianak'), ('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'), ('Asia/Qostanay', 'Asia/Qostanay'), ('Asia/Qyzylorda', 'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'), ('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'), ('Asia/Sakhalin', 'Asia/Sakhalin'), ('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'), ('Asia/Shanghai', 'Asia/Shanghai'), ('Asia/Singapore', 'Asia/Singapore'), ('Asia/Srednekolymsk', 'Asia/Srednekolymsk'), ('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'), ('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'), ('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Asia/Tomsk', 'Asia/Tomsk'), ('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'), ('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'), ('Asia/Ulan_Bator', 'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'), ('Asia/Ust-Nera', 'Asia/Ust-Nera'), ('Asia/Vientiane', 'Asia/Vientiane'), ('Asia/Vladivostok', 'Asia/Vladivostok'), ('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'), ('Asia/Yekaterinburg', 'Asia/Yekaterinburg'), ('Asia/Yerevan', 'Asia/Yerevan'), ('Atlantic/Azores', 'Atlantic/Azores'), ('Atlantic/Bermuda', 'Atlantic/Bermuda'), ('Atlantic/Canary', 'Atlantic/Canary'), ('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'), ('Atlantic/Faeroe', 'Atlantic/Faeroe'), ('Atlantic/Faroe', 'Atlantic/Faroe'), ('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'), ('Atlantic/Madeira', 'Atlantic/Madeira'), ('Atlantic/Reykjavik', 'Atlantic/Reykjavik'), ('Atlantic/South_Georgia', 'Atlantic/South_Georgia'), ('Atlantic/St_Helena', 'Atlantic/St_Helena'), ('Atlantic/Stanley', 'Atlantic/Stanley'), ('Australia/ACT', 'Australia/ACT'), ('Australia/Adelaide', 'Australia/Adelaide'), ('Australia/Brisbane', 'Australia/Brisbane'), ('Australia/Broken_Hill', 'Australia/Broken_Hill'), ('Australia/Canberra', 'Australia/Canberra'), ('Australia/Currie', 'Australia/Currie'), ('Australia/Darwin', 'Australia/Darwin'), ('Australia/Eucla', 'Australia/Eucla'), ('Australia/Hobart', 'Australia/Hobart'), ('Australia/LHI', 'Australia/LHI'), ('Australia/Lindeman', 'Australia/Lindeman'), ('Australia/Lord_Howe', 'Australia/Lord_Howe'), ('Australia/Melbourne', 'Australia/Melbourne'), ('Australia/NSW', 'Australia/NSW'), ('Australia/North', 'Australia/North'), ('Australia/Perth', 'Australia/Perth'), ('Australia/Queensland', 'Australia/Queensland'), ('Australia/South', 'Australia/South'), ('Australia/Sydney', 'Australia/Sydney'), ('Australia/Tasmania', 'Australia/Tasmania'), ('Australia/Victoria', 'Australia/Victoria'), ('Australia/West', 'Australia/West'), ('Australia/Yancowinna', 'Australia/Yancowinna'), ('Brazil/Acre', 'Brazil/Acre'), ('Brazil/DeNoronha', 'Brazil/DeNoronha'), ('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'), ('CET', 'CET'), ('CST6CDT', 'CST6CDT'), ('Canada/Atlantic', 'Canada/Atlantic'), ('Canada/Central', 'Canada/Central'), ('Canada/Eastern', 'Canada/Eastern'), ('Canada/Mountain', 'Canada/Mountain'), ('Canada/Newfoundland', 'Canada/Newfoundland'), ('Canada/Pacific', 'Canada/Pacific'), ('Canada/Saskatchewan', 'Canada/Saskatchewan'), ('Canada/Yukon', 'Canada/Yukon'), ('Chile/Continental', 'Chile/Continental'), ('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'), ('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'), ('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'), ('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'), ('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'), ('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'), ('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'), ('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'), ('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'), ('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'), ('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'), ('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'), ('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'), ('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'), ('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'), ('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'), ('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'), ('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'), ('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'), ('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'), ('Europe/Amsterdam', 'Europe/Amsterdam'), ('Europe/Andorra', 'Europe/Andorra'), ('Europe/Astrakhan', 'Europe/Astrakhan'), ('Europe/Athens', 'Europe/Athens'), ('Europe/Belfast', 'Europe/Belfast'), ('Europe/Belgrade', 'Europe/Belgrade'), ('Europe/Berlin', 'Europe/Berlin'), ('Europe/Bratislava', 'Europe/Bratislava'), ('Europe/Brussels', 'Europe/Brussels'), ('Europe/Bucharest', 'Europe/Bucharest'), ('Europe/Budapest', 'Europe/Budapest'), ('Europe/Busingen', 'Europe/Busingen'), ('Europe/Chisinau', 'Europe/Chisinau'), ('Europe/Copenhagen', 'Europe/Copenhagen'), ('Europe/Dublin', 'Europe/Dublin'), ('Europe/Gibraltar', 'Europe/Gibraltar'), ('Europe/Guernsey', 'Europe/Guernsey'), ('Europe/Helsinki', 'Europe/Helsinki'), ('Europe/Isle_of_Man', 'Europe/Isle_of_Man'), ('Europe/Istanbul', 'Europe/Istanbul'), ('Europe/Jersey', 'Europe/Jersey'), ('Europe/Kaliningrad', 'Europe/Kaliningrad'), ('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'), ('Europe/Kyiv', 'Europe/Kyiv'), ('Europe/Lisbon', 'Europe/Lisbon'), ('Europe/Ljubljana', 'Europe/Ljubljana'), ('Europe/London', 'Europe/London'), ('Europe/Luxembourg', 'Europe/Luxembourg'), ('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'), ('Europe/Mariehamn', 'Europe/Mariehamn'), ('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'), ('Europe/Moscow', 'Europe/Moscow'), ('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'), ('Europe/Paris', 'Europe/Paris'), ('Europe/Podgorica', 'Europe/Podgorica'), ('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'), ('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'), ('Europe/San_Marino', 'Europe/San_Marino'), ('Europe/Sarajevo', 'Europe/Sarajevo'), ('Europe/Saratov', 'Europe/Saratov'), ('Europe/Simferopol', 'Europe/Simferopol'), ('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'), ('Europe/Stockholm', 'Europe/Stockholm'), ('Europe/Tallinn', 'Europe/Tallinn'), ('Europe/Tirane', 'Europe/Tirane'), ('Europe/Tiraspol', 'Europe/Tiraspol'), ('Europe/Ulyanovsk', 'Europe/Ulyanovsk'), ('Europe/Uzhgorod', 'Europe/Uzhgorod'), ('Europe/Vaduz', 'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'), ('Europe/Vienna', 'Europe/Vienna'), ('Europe/Vilnius', 'Europe/Vilnius'), ('Europe/Volgograd', 'Europe/Volgograd'), ('Europe/Warsaw', 'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'), ('Europe/Zaporozhye', 'Europe/Zaporozhye'), ('Europe/Zurich', 'Europe/Zurich'), ('GB', 'GB'), ('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'), ('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'), ('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'), ('Indian/Antananarivo', 'Indian/Antananarivo'), ('Indian/Chagos', 'Indian/Chagos'), ('Indian/Christmas', 'Indian/Christmas'), ('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'), ('Indian/Kerguelen', 'Indian/Kerguelen'), ('Indian/Mahe', 'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'), ('Indian/Mauritius', 'Indian/Mauritius'), ('Indian/Mayotte', 'Indian/Mayotte'), ('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'), ('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'), ('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'), ('MST', 'MST'), ('MST7MDT', 'MST7MDT'), ('Mexico/BajaNorte', 'Mexico/BajaNorte'), ('Mexico/BajaSur', 'Mexico/BajaSur'), ('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'), ('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'), ('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'), ('Pacific/Auckland', 'Pacific/Auckland'), ('Pacific/Bougainville', 'Pacific/Bougainville'), ('Pacific/Chatham', 'Pacific/Chatham'), ('Pacific/Chuuk', 'Pacific/Chuuk'), ('Pacific/Easter', 'Pacific/Easter'), ('Pacific/Efate', 'Pacific/Efate'), ('Pacific/Enderbury', 'Pacific/Enderbury'), ('Pacific/Fakaofo', 'Pacific/Fakaofo'), ('Pacific/Fiji', 'Pacific/Fiji'), ('Pacific/Funafuti', 'Pacific/Funafuti'), ('Pacific/Galapagos', 'Pacific/Galapagos'), ('Pacific/Gambier', 'Pacific/Gambier'), ('Pacific/Guadalcanal', 'Pacific/Guadalcanal'), ('Pacific/Guam', 'Pacific/Guam'), ('Pacific/Honolulu', 'Pacific/Honolulu'), ('Pacific/Johnston', 'Pacific/Johnston'), ('Pacific/Kanton', 'Pacific/Kanton'), ('Pacific/Kiritimati', 'Pacific/Kiritimati'), ('Pacific/Kosrae', 'Pacific/Kosrae'), ('Pacific/Kwajalein', 'Pacific/Kwajalein'), ('Pacific/Majuro', 'Pacific/Majuro'), ('Pacific/Marquesas', 'Pacific/Marquesas'), ('Pacific/Midway', 'Pacific/Midway'), ('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'), ('Pacific/Norfolk', 'Pacific/Norfolk'), ('Pacific/Noumea', 'Pacific/Noumea'), ('Pacific/Pago_Pago', 'Pacific/Pago_Pago'), ('Pacific/Palau', 'Pacific/Palau'), ('Pacific/Pitcairn', 'Pacific/Pitcairn'), ('Pacific/Pohnpei', 'Pacific/Pohnpei'), ('Pacific/Ponape', 'Pacific/Ponape'), ('Pacific/Port_Moresby', 'Pacific/Port_Moresby'), ('Pacific/Rarotonga', 'Pacific/Rarotonga'), ('Pacific/Saipan', 'Pacific/Saipan'), ('Pacific/Samoa', 'Pacific/Samoa'), ('Pacific/Tahiti', 'Pacific/Tahiti'), ('Pacific/Tarawa', 'Pacific/Tarawa'), ('Pacific/Tongatapu', 'Pacific/Tongatapu'), ('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'), ('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'), ('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'), ('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'), ('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'), ('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'), ('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'), ('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'), ('US/Indiana-Starke', 'US/Indiana-Starke'), ('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'), ('US/Pacific', 'US/Pacific'), ('US/Samoa', 'US/Samoa'), ('UTC', 'UTC'), ('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'), ('Zulu', 'Zulu')], default='UTC', max_length=255), + model_name="user", + name="user_timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), ), migrations.AlterField( - model_name='issuelink', - name='title', + model_name="issuelink", + name="title", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.RunPython(update_user_timezones), migrations.AlterField( - model_name='issuevote', - name='vote', - field=models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')], default=1), + model_name="issuevote", + name="vote", + field=models.IntegerField( + choices=[(-1, "DOWNVOTE"), (1, "UPVOTE")], default=1 + ), ), migrations.CreateModel( - name='ProjectPublicMember', + name="ProjectPublicMember", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_project_members', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "member", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="public_project_members", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Project Public Member', - 'verbose_name_plural': 'Project Public Members', - 'db_table': 'project_public_members', - 'ordering': ('-created_at',), - 'unique_together': {('project', 'member')}, + "verbose_name": "Project Public Member", + "verbose_name_plural": "Project Public Members", + "db_table": "project_public_members", + "ordering": ("-created_at",), + "unique_together": {("project", "member")}, }, ), ] diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py index 5a806c7046a..81d91bb786f 100644 --- a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -24,7 +24,9 @@ def create_issue_relation(apps, schema_editor): updated_by_id=blocked_issue.updated_by_id, ) ) - IssueRelation.objects.bulk_create(updated_issue_relation, batch_size=100) + IssueRelation.objects.bulk_create( + updated_issue_relation, batch_size=100 + ) except Exception as e: print(e) capture_exception(e) @@ -36,47 +38,137 @@ def update_issue_priority_choice(apps, schema_editor): for obj in IssueModel.objects.filter(priority=None): obj.priority = "none" updated_issues.append(obj) - IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100) + IssueModel.objects.bulk_update( + updated_issues, ["priority"], batch_size=100 + ) class Migration(migrations.Migration): - dependencies = [ - ('db', '0042_alter_analyticview_created_by_and_more'), + ("db", "0042_alter_analyticview_created_by_and_more"), ] operations = [ migrations.CreateModel( - name='IssueRelation', + name="IssueRelation", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('relation_type', models.CharField(choices=[('duplicate', 'Duplicate'), ('relates_to', 'Relates To'), ('blocked_by', 'Blocked By')], default='blocked_by', max_length=20, verbose_name='Issue Relation Type')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation', to='db.issue')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('related_issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_related', to='db.issue')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "relation_type", + models.CharField( + choices=[ + ("duplicate", "Duplicate"), + ("relates_to", "Relates To"), + ("blocked_by", "Blocked By"), + ], + default="blocked_by", + max_length=20, + verbose_name="Issue Relation Type", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_relation", + to="db.issue", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "related_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_related", + to="db.issue", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Relation', - 'verbose_name_plural': 'Issue Relations', - 'db_table': 'issue_relations', - 'ordering': ('-created_at',), - 'unique_together': {('issue', 'related_issue')}, + "verbose_name": "Issue Relation", + "verbose_name_plural": "Issue Relations", + "db_table": "issue_relations", + "ordering": ("-created_at",), + "unique_together": {("issue", "related_issue")}, }, ), migrations.AddField( - model_name='issue', - name='is_draft', + model_name="issue", + name="is_draft", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='issue', - name='priority', - field=models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')], default='none', max_length=30, verbose_name='Issue Priority'), + model_name="issue", + name="priority", + field=models.CharField( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), ), migrations.RunPython(create_issue_relation), migrations.RunPython(update_issue_priority_choice), diff --git a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py index 19a1449af46..d42b3431ec8 100644 --- a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py +++ b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py @@ -8,12 +8,16 @@ def workspace_member_props(old_props): "filters": { "priority": old_props.get("filters", {}).get("priority", None), "state": old_props.get("filters", {}).get("state", None), - "state_group": old_props.get("filters", {}).get("state_group", None), + "state_group": old_props.get("filters", {}).get( + "state_group", None + ), "assignees": old_props.get("filters", {}).get("assignees", None), "created_by": old_props.get("filters", {}).get("created_by", None), "labels": old_props.get("filters", {}).get("labels", None), "start_date": old_props.get("filters", {}).get("start_date", None), - "target_date": old_props.get("filters", {}).get("target_date", None), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), "subscriber": old_props.get("filters", {}).get("subscriber", None), }, "display_filters": { @@ -27,18 +31,28 @@ def workspace_member_props(old_props): }, "display_properties": { "assignee": old_props.get("properties", {}).get("assignee", True), - "attachment_count": old_props.get("properties", {}).get("attachment_count", True), - "created_on": old_props.get("properties", {}).get("created_on", True), + "attachment_count": old_props.get("properties", {}).get( + "attachment_count", True + ), + "created_on": old_props.get("properties", {}).get( + "created_on", True + ), "due_date": old_props.get("properties", {}).get("due_date", True), "estimate": old_props.get("properties", {}).get("estimate", True), "key": old_props.get("properties", {}).get("key", True), "labels": old_props.get("properties", {}).get("labels", True), "link": old_props.get("properties", {}).get("link", True), "priority": old_props.get("properties", {}).get("priority", True), - "start_date": old_props.get("properties", {}).get("start_date", True), + "start_date": old_props.get("properties", {}).get( + "start_date", True + ), "state": old_props.get("properties", {}).get("state", True), - "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True), - "updated_on": old_props.get("properties", {}).get("updated_on", True), + "sub_issue_count": old_props.get("properties", {}).get( + "sub_issue_count", True + ), + "updated_on": old_props.get("properties", {}).get( + "updated_on", True + ), }, } return new_props @@ -49,12 +63,16 @@ def project_member_props(old_props): "filters": { "priority": old_props.get("filters", {}).get("priority", None), "state": old_props.get("filters", {}).get("state", None), - "state_group": old_props.get("filters", {}).get("state_group", None), + "state_group": old_props.get("filters", {}).get( + "state_group", None + ), "assignees": old_props.get("filters", {}).get("assignees", None), "created_by": old_props.get("filters", {}).get("created_by", None), "labels": old_props.get("filters", {}).get("labels", None), "start_date": old_props.get("filters", {}).get("start_date", None), - "target_date": old_props.get("filters", {}).get("target_date", None), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), "subscriber": old_props.get("filters", {}).get("subscriber", None), }, "display_filters": { @@ -75,59 +93,75 @@ def cycle_module_props(old_props): "filters": { "priority": old_props.get("filters", {}).get("priority", None), "state": old_props.get("filters", {}).get("state", None), - "state_group": old_props.get("filters", {}).get("state_group", None), + "state_group": old_props.get("filters", {}).get( + "state_group", None + ), "assignees": old_props.get("filters", {}).get("assignees", None), "created_by": old_props.get("filters", {}).get("created_by", None), "labels": old_props.get("filters", {}).get("labels", None), "start_date": old_props.get("filters", {}).get("start_date", None), - "target_date": old_props.get("filters", {}).get("target_date", None), + "target_date": old_props.get("filters", {}).get( + "target_date", None + ), "subscriber": old_props.get("filters", {}).get("subscriber", None), }, } return new_props - + def update_workspace_member_view_props(apps, schema_editor): WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember") updated_workspace_member = [] for obj in WorkspaceMemberModel.objects.all(): - obj.view_props = workspace_member_props(obj.view_props) - obj.default_props = workspace_member_props(obj.default_props) - updated_workspace_member.append(obj) - WorkspaceMemberModel.objects.bulk_update(updated_workspace_member, ["view_props", "default_props"], batch_size=100) + obj.view_props = workspace_member_props(obj.view_props) + obj.default_props = workspace_member_props(obj.default_props) + updated_workspace_member.append(obj) + WorkspaceMemberModel.objects.bulk_update( + updated_workspace_member, + ["view_props", "default_props"], + batch_size=100, + ) + def update_project_member_view_props(apps, schema_editor): ProjectMemberModel = apps.get_model("db", "ProjectMember") updated_project_member = [] for obj in ProjectMemberModel.objects.all(): - obj.view_props = project_member_props(obj.view_props) - obj.default_props = project_member_props(obj.default_props) - updated_project_member.append(obj) - ProjectMemberModel.objects.bulk_update(updated_project_member, ["view_props", "default_props"], batch_size=100) + obj.view_props = project_member_props(obj.view_props) + obj.default_props = project_member_props(obj.default_props) + updated_project_member.append(obj) + ProjectMemberModel.objects.bulk_update( + updated_project_member, ["view_props", "default_props"], batch_size=100 + ) + def update_cycle_props(apps, schema_editor): CycleModel = apps.get_model("db", "Cycle") updated_cycle = [] for obj in CycleModel.objects.all(): - if "filter" in obj.view_props: - obj.view_props = cycle_module_props(obj.view_props) - updated_cycle.append(obj) - CycleModel.objects.bulk_update(updated_cycle, ["view_props"], batch_size=100) + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_cycle.append(obj) + CycleModel.objects.bulk_update( + updated_cycle, ["view_props"], batch_size=100 + ) + def update_module_props(apps, schema_editor): ModuleModel = apps.get_model("db", "Module") updated_module = [] for obj in ModuleModel.objects.all(): - if "filter" in obj.view_props: - obj.view_props = cycle_module_props(obj.view_props) - updated_module.append(obj) - ModuleModel.objects.bulk_update(updated_module, ["view_props"], batch_size=100) + if "filter" in obj.view_props: + obj.view_props = cycle_module_props(obj.view_props) + updated_module.append(obj) + ModuleModel.objects.bulk_update( + updated_module, ["view_props"], batch_size=100 + ) class Migration(migrations.Migration): - dependencies = [ - ('db', '0043_alter_analyticview_created_by_and_more'), + ("db", "0043_alter_analyticview_created_by_and_more"), ] operations = [ diff --git a/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py index 4b9c1b1eb94..9ac52882997 100644 --- a/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py +++ b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py @@ -21,6 +21,7 @@ def update_issue_activity_priority(apps, schema_editor): batch_size=2000, ) + def update_issue_activity_blocked(apps, schema_editor): IssueActivity = apps.get_model("db", "IssueActivity") updated_issue_activity = [] @@ -34,44 +35,104 @@ def update_issue_activity_blocked(apps, schema_editor): batch_size=1000, ) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('db', '0044_auto_20230913_0709'), + ("db", "0044_auto_20230913_0709"), ] operations = [ migrations.CreateModel( - name='GlobalView', + name="GlobalView", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='View Name')), - ('description', models.TextField(blank=True, verbose_name='View Description')), - ('query', models.JSONField(verbose_name='View Query')), - ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), - ('query_data', models.JSONField(default=dict)), - ('sort_order', models.FloatField(default=65535)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField(max_length=255, verbose_name="View Name"), + ), + ( + "description", + models.TextField( + blank=True, verbose_name="View Description" + ), + ), + ("query", models.JSONField(verbose_name="View Query")), + ( + "access", + models.PositiveSmallIntegerField( + choices=[(0, "Private"), (1, "Public")], default=1 + ), + ), + ("query_data", models.JSONField(default=dict)), + ("sort_order", models.FloatField(default=65535)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="global_views", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Global View', - 'verbose_name_plural': 'Global Views', - 'db_table': 'global_views', - 'ordering': ('-created_at',), + "verbose_name": "Global View", + "verbose_name_plural": "Global Views", + "db_table": "global_views", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='workspacemember', - name='issue_props', - field=models.JSONField(default=plane.db.models.workspace.get_issue_props), + model_name="workspacemember", + name="issue_props", + field=models.JSONField( + default=plane.db.models.workspace.get_issue_props + ), ), migrations.AddField( - model_name='issueactivity', - name='epoch', + model_name="issueactivity", + name="epoch", field=models.FloatField(null=True), ), migrations.RunPython(update_issue_activity_priority), diff --git a/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py index f02660e1dac..be58c8f5f53 100644 --- a/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py @@ -7,977 +7,2001 @@ import uuid import random + def random_sort_ordering(apps, schema_editor): Label = apps.get_model("db", "Label") bulk_labels = [] for label in Label.objects.all(): - label.sort_order = random.randint(0,65535) + label.sort_order = random.randint(0, 65535) bulk_labels.append(label) Label.objects.bulk_update(bulk_labels, ["sort_order"], batch_size=1000) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'), + ( + "db", + "0045_issueactivity_epoch_workspacemember_issue_props_and_more", + ), ] operations = [ migrations.AddField( - model_name='label', - name='sort_order', + model_name="label", + name="sort_order", field=models.FloatField(default=65535), ), migrations.AlterField( - model_name='analyticview', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='analyticview', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='apitoken', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='apitoken', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='cycle', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='cycle', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='cycle', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='cycle', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='cyclefavorite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='cyclefavorite', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='cyclefavorite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='cyclefavorite', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='cycleissue', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='cycleissue', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='cycleissue', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='cycleissue', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='estimate', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='estimate', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='estimate', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='estimate', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='estimatepoint', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='estimatepoint', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='estimatepoint', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='estimatepoint', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='fileasset', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='fileasset', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='githubcommentsync', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='githubcommentsync', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='githubcommentsync', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='githubcommentsync', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='githubissuesync', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='githubissuesync', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='githubissuesync', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='githubissuesync', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='githubrepository', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='githubrepository', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='githubrepository', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='githubrepository', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='githubrepositorysync', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='githubrepositorysync', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='githubrepositorysync', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='githubrepositorysync', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='importer', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='importer', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='importer', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='importer', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='inbox', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='inbox', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='inbox', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='inbox', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='inboxissue', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='inboxissue', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='inboxissue', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='inboxissue', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='integration', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='integration', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issue', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issue', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issue', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issue', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueactivity', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueactivity', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueactivity', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueactivity', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueassignee', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueassignee', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueassignee', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueassignee', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueattachment', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueattachment', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueattachment', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueattachment', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueblocker', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueblocker', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueblocker', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueblocker', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issuecomment', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issuecomment', - name='issue', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_comments', to='db.issue'), - ), - migrations.AlterField( - model_name='issuecomment', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issuecomment', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issuecomment', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issuelabel', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issuelabel', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issuelabel', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issuelabel', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issuelink', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issuelink', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issuelink', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issuelink', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueproperty', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueproperty', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueproperty', - name='properties', - field=models.JSONField(default=plane.db.models.issue.get_default_properties), - ), - migrations.AlterField( - model_name='issueproperty', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueproperty', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issuesequence', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issuesequence', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issuesequence', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issuesequence', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueview', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueview', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueview', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueview', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='issueviewfavorite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='issueviewfavorite', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='issueviewfavorite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='issueviewfavorite', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='label', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='label', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='label', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='label', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='module', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='module', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='module', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='module', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='modulefavorite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='modulefavorite', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='modulefavorite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='modulefavorite', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='moduleissue', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='moduleissue', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='moduleissue', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='moduleissue', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='modulelink', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='modulelink', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='modulelink', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='modulelink', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='modulemember', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='modulemember', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='modulemember', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='modulemember', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='page', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='page', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='page', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='page', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='pageblock', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='pageblock', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='pageblock', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='pageblock', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='pagefavorite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='pagefavorite', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='pagefavorite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='pagefavorite', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='pagelabel', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='pagelabel', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='pagelabel', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='pagelabel', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='project', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='project', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='projectfavorite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='projectfavorite', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='projectfavorite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='projectfavorite', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='projectidentifier', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='projectidentifier', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='projectmember', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='projectmember', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='projectmember', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='projectmember', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='projectmemberinvite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='projectmemberinvite', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='projectmemberinvite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='projectmemberinvite', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='slackprojectsync', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='slackprojectsync', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='slackprojectsync', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='slackprojectsync', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='socialloginconnection', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='socialloginconnection', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='state', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='state', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'), - ), - migrations.AlterField( - model_name='state', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='state', - name='workspace', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'), - ), - migrations.AlterField( - model_name='team', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='team', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='teammember', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='teammember', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='workspace', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='workspace', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='workspaceintegration', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='workspaceintegration', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='workspacemember', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='workspacemember', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='workspacememberinvite', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='workspacememberinvite', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), - ), - migrations.AlterField( - model_name='workspacetheme', - name='created_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By'), - ), - migrations.AlterField( - model_name='workspacetheme', - name='updated_by', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + model_name="analyticview", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="analyticview", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="apitoken", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="apitoken", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cycle", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="cycle", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="cycle", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cycle", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cyclefavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="cycleissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="estimate", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="estimate", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="estimate", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="estimate", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="estimatepoint", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubcommentsync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubissuesync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubrepository", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="githubrepositorysync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="importer", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="importer", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="importer", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="importer", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="inbox", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="inbox", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="inbox", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="inbox", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="inboxissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="integration", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="integration", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueactivity", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueassignee", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueattachment", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueblocker", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_comments", + to="db.issue", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuecomment", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuelink", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="properties", + field=models.JSONField( + default=plane.db.models.issue.get_default_properties + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueproperty", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issuesequence", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueview", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueview", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueview", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueview", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="issueviewfavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="label", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="label", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="label", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="label", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="module", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="module", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="module", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="module", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="modulefavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="moduleissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="modulelink", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="modulemember", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="page", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="page", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="page", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="pageblock", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="pagefavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="pagelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="project", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="project", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectfavorite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectidentifier", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectmember", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="projectmemberinvite", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="slackprojectsync", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="state", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="state", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AlterField( + model_name="state", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="state", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="team", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="team", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="teammember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="teammember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspace", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspace", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspaceintegration", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspaceintegration", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspacemember", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspacemember", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspacememberinvite", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspacememberinvite", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AlterField( + model_name="workspacetheme", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AlterField( + model_name="workspacetheme", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), ), migrations.CreateModel( - name='IssueMention', + name="IssueMention", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to='db.issue')), - ('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_mention", + to="db.issue", + ), + ), + ( + "mention", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue_mention", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Issue Mention', - 'verbose_name_plural': 'Issue Mentions', - 'db_table': 'issue_mentions', - 'ordering': ('-created_at',), - 'unique_together': {('issue', 'mention')}, + "verbose_name": "Issue Mention", + "verbose_name_plural": "Issue Mentions", + "db_table": "issue_mentions", + "ordering": ("-created_at",), + "unique_together": {("issue", "mention")}, }, ), migrations.RunPython(random_sort_ordering), diff --git a/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py b/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py index d44f760d0ac..f0a52a355fe 100644 --- a/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py +++ b/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py @@ -9,123 +9,288 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0046_label_sort_order_alter_analyticview_created_by_and_more'), + ("db", "0046_label_sort_order_alter_analyticview_created_by_and_more"), ] operations = [ migrations.CreateModel( - name='Webhook', + name="Webhook", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('url', models.URLField(validators=[plane.db.models.webhook.validate_schema, plane.db.models.webhook.validate_domain])), - ('is_active', models.BooleanField(default=True)), - ('secret_key', models.CharField(default=plane.db.models.webhook.generate_token, max_length=255)), - ('project', models.BooleanField(default=False)), - ('issue', models.BooleanField(default=False)), - ('module', models.BooleanField(default=False)), - ('cycle', models.BooleanField(default=False)), - ('issue_comment', models.BooleanField(default=False)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_webhooks', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "url", + models.URLField( + validators=[ + plane.db.models.webhook.validate_schema, + plane.db.models.webhook.validate_domain, + ] + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "secret_key", + models.CharField( + default=plane.db.models.webhook.generate_token, + max_length=255, + ), + ), + ("project", models.BooleanField(default=False)), + ("issue", models.BooleanField(default=False)), + ("module", models.BooleanField(default=False)), + ("cycle", models.BooleanField(default=False)), + ("issue_comment", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_webhooks", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Webhook', - 'verbose_name_plural': 'Webhooks', - 'db_table': 'webhooks', - 'ordering': ('-created_at',), - 'unique_together': {('workspace', 'url')}, + "verbose_name": "Webhook", + "verbose_name_plural": "Webhooks", + "db_table": "webhooks", + "ordering": ("-created_at",), + "unique_together": {("workspace", "url")}, }, ), migrations.AddField( - model_name='apitoken', - name='description', + model_name="apitoken", + name="description", field=models.TextField(blank=True), ), migrations.AddField( - model_name='apitoken', - name='expired_at', + model_name="apitoken", + name="expired_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='apitoken', - name='is_active', + model_name="apitoken", + name="is_active", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='apitoken', - name='last_used', + model_name="apitoken", + name="last_used", field=models.DateTimeField(null=True), ), migrations.AddField( - model_name='projectmember', - name='is_active', + model_name="projectmember", + name="is_active", field=models.BooleanField(default=True), ), migrations.AddField( - model_name='workspacemember', - name='is_active', + model_name="workspacemember", + name="is_active", field=models.BooleanField(default=True), ), migrations.AlterField( - model_name='apitoken', - name='token', - field=models.CharField(db_index=True, default=plane.db.models.api.generate_token, max_length=255, unique=True), + model_name="apitoken", + name="token", + field=models.CharField( + db_index=True, + default=plane.db.models.api.generate_token, + max_length=255, + unique=True, + ), ), migrations.CreateModel( - name='WebhookLog', + name="WebhookLog", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('event_type', models.CharField(blank=True, max_length=255, null=True)), - ('request_method', models.CharField(blank=True, max_length=10, null=True)), - ('request_headers', models.TextField(blank=True, null=True)), - ('request_body', models.TextField(blank=True, null=True)), - ('response_status', models.TextField(blank=True, null=True)), - ('response_headers', models.TextField(blank=True, null=True)), - ('response_body', models.TextField(blank=True, null=True)), - ('retry_count', models.PositiveSmallIntegerField(default=0)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='db.webhook')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_logs', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "event_type", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "request_method", + models.CharField(blank=True, max_length=10, null=True), + ), + ("request_headers", models.TextField(blank=True, null=True)), + ("request_body", models.TextField(blank=True, null=True)), + ("response_status", models.TextField(blank=True, null=True)), + ("response_headers", models.TextField(blank=True, null=True)), + ("response_body", models.TextField(blank=True, null=True)), + ("retry_count", models.PositiveSmallIntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="logs", + to="db.webhook", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="webhook_logs", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Webhook Log', - 'verbose_name_plural': 'Webhook Logs', - 'db_table': 'webhook_logs', - 'ordering': ('-created_at',), + "verbose_name": "Webhook Log", + "verbose_name_plural": "Webhook Logs", + "db_table": "webhook_logs", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='APIActivityLog', + name="APIActivityLog", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('token_identifier', models.CharField(max_length=255)), - ('path', models.CharField(max_length=255)), - ('method', models.CharField(max_length=10)), - ('query_params', models.TextField(blank=True, null=True)), - ('headers', models.TextField(blank=True, null=True)), - ('body', models.TextField(blank=True, null=True)), - ('response_code', models.PositiveIntegerField()), - ('response_body', models.TextField(blank=True, null=True)), - ('ip_address', models.GenericIPAddressField(blank=True, null=True)), - ('user_agent', models.CharField(blank=True, max_length=512, null=True)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("token_identifier", models.CharField(max_length=255)), + ("path", models.CharField(max_length=255)), + ("method", models.CharField(max_length=10)), + ("query_params", models.TextField(blank=True, null=True)), + ("headers", models.TextField(blank=True, null=True)), + ("body", models.TextField(blank=True, null=True)), + ("response_code", models.PositiveIntegerField()), + ("response_body", models.TextField(blank=True, null=True)), + ( + "ip_address", + models.GenericIPAddressField(blank=True, null=True), + ), + ( + "user_agent", + models.CharField(blank=True, max_length=512, null=True), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'API Activity Log', - 'verbose_name_plural': 'API Activity Logs', - 'db_table': 'api_activity_logs', - 'ordering': ('-created_at',), + "verbose_name": "API Activity Log", + "verbose_name_plural": "API Activity Logs", + "db_table": "api_activity_logs", + "ordering": ("-created_at",), }, ), ] diff --git a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py index 8d896b01da5..791affed662 100644 --- a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py +++ b/apiserver/plane/db/migrations/0048_auto_20231116_0713.py @@ -7,48 +7,135 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0047_webhook_apitoken_description_apitoken_expired_at_and_more'), + ( + "db", + "0047_webhook_apitoken_description_apitoken_expired_at_and_more", + ), ] operations = [ migrations.CreateModel( - name='PageLog', + name="PageLog", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('transaction', models.UUIDField(default=uuid.uuid4)), - ('entity_identifier', models.UUIDField(null=True)), - ('entity_name', models.CharField(choices=[('to_do', 'To Do'), ('issue', 'issue'), ('image', 'Image'), ('video', 'Video'), ('file', 'File'), ('link', 'Link'), ('cycle', 'Cycle'), ('module', 'Module'), ('back_link', 'Back Link'), ('forward_link', 'Forward Link'), ('page_mention', 'Page Mention'), ('user_mention', 'User Mention')], max_length=30, verbose_name='Transaction Type')), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='page_log', to='db.page')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("transaction", models.UUIDField(default=uuid.uuid4)), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("to_do", "To Do"), + ("issue", "issue"), + ("image", "Image"), + ("video", "Video"), + ("file", "File"), + ("link", "Link"), + ("cycle", "Cycle"), + ("module", "Module"), + ("back_link", "Back Link"), + ("forward_link", "Forward Link"), + ("page_mention", "Page Mention"), + ("user_mention", "User Mention"), + ], + max_length=30, + verbose_name="Transaction Type", + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="page_log", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), ], options={ - 'verbose_name': 'Page Log', - 'verbose_name_plural': 'Page Logs', - 'db_table': 'page_logs', - 'ordering': ('-created_at',), - 'unique_together': {('page', 'transaction')} + "verbose_name": "Page Log", + "verbose_name_plural": "Page Logs", + "db_table": "page_logs", + "ordering": ("-created_at",), + "unique_together": {("page", "transaction")}, }, ), migrations.AddField( - model_name='page', - name='archived_at', + model_name="page", + name="archived_at", field=models.DateField(null=True), ), migrations.AddField( - model_name='page', - name='is_locked', + model_name="page", + name="is_locked", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='page', - name='parent', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_page', to='db.page'), + model_name="page", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="child_page", + to="db.page", + ), ), - ] \ No newline at end of file + ] diff --git a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py index 75d5e598212..d59fc5a84bc 100644 --- a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py +++ b/apiserver/plane/db/migrations/0049_auto_20231116_0713.py @@ -18,7 +18,9 @@ def update_pages(apps, schema_editor): # looping through all the pages for page in Page.objects.all(): page_blocks = PageBlock.objects.filter( - page_id=page.id, project_id=page.project_id, workspace_id=page.workspace_id + page_id=page.id, + project_id=page.project_id, + workspace_id=page.workspace_id, ).order_by("sort_order") if page_blocks: @@ -69,4 +71,4 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(update_pages), - ] \ No newline at end of file + ] diff --git a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py index a8807d10488..327a5ab7276 100644 --- a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py +++ b/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py @@ -3,37 +3,41 @@ from django.db import migrations, models import plane.db.models.workspace + def user_password_autoset(apps, schema_editor): User = apps.get_model("db", "User") User.objects.update(is_password_autoset=True) class Migration(migrations.Migration): - dependencies = [ - ('db', '0049_auto_20231116_0713'), + ("db", "0049_auto_20231116_0713"), ] operations = [ migrations.AddField( - model_name='user', - name='use_case', + model_name="user", + name="use_case", field=models.TextField(blank=True, null=True), ), migrations.AlterField( - model_name='workspace', - name='organization_size', + model_name="workspace", + name="organization_size", field=models.CharField(blank=True, max_length=20, null=True), ), migrations.AddField( - model_name='fileasset', - name='is_deleted', + model_name="fileasset", + name="is_deleted", field=models.BooleanField(default=False), ), migrations.AlterField( - model_name='workspace', - name='slug', - field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]), + model_name="workspace", + name="slug", + field=models.SlugField( + max_length=48, + unique=True, + validators=[plane.db.models.workspace.slug_validator], + ), ), - migrations.RunPython(user_password_autoset), + migrations.RunPython(user_password_autoset), ] diff --git a/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py b/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py index 19267dfc2ba..886cee52df6 100644 --- a/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py +++ b/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py @@ -4,80 +4,79 @@ class Migration(migrations.Migration): - dependencies = [ - ('db', '0050_user_use_case_alter_workspace_organization_size'), + ("db", "0050_user_use_case_alter_workspace_organization_size"), ] operations = [ migrations.AddField( - model_name='cycle', - name='external_id', + model_name="cycle", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='cycle', - name='external_source', + model_name="cycle", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='inboxissue', - name='external_id', + model_name="inboxissue", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='inboxissue', - name='external_source', + model_name="inboxissue", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='issue', - name='external_id', + model_name="issue", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='issue', - name='external_source', + model_name="issue", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='issuecomment', - name='external_id', + model_name="issuecomment", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='issuecomment', - name='external_source', + model_name="issuecomment", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='label', - name='external_id', + model_name="label", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='label', - name='external_source', + model_name="label", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='module', - name='external_id', + model_name="module", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='module', - name='external_source', + model_name="module", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='state', - name='external_id', + model_name="state", + name="external_id", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AddField( - model_name='state', - name='external_source', + model_name="state", + name="external_source", field=models.CharField(blank=True, max_length=255, null=True), ), ] diff --git a/apiserver/plane/db/migrations/0052_auto_20231220_1141.py b/apiserver/plane/db/migrations/0052_auto_20231220_1141.py new file mode 100644 index 00000000000..da16fb9f67e --- /dev/null +++ b/apiserver/plane/db/migrations/0052_auto_20231220_1141.py @@ -0,0 +1,379 @@ +# Generated by Django 4.2.7 on 2023-12-20 11:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.cycle +import plane.db.models.issue +import plane.db.models.module +import plane.db.models.view +import plane.db.models.workspace +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0051_cycle_external_id_cycle_external_source_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="issueview", + old_name="query_data", + new_name="filters", + ), + migrations.RenameField( + model_name="issueproperty", + old_name="properties", + new_name="display_properties", + ), + migrations.AlterField( + model_name="issueproperty", + name="display_properties", + field=models.JSONField( + default=plane.db.models.issue.get_default_display_properties + ), + ), + migrations.AddField( + model_name="issueproperty", + name="display_filters", + field=models.JSONField( + default=plane.db.models.issue.get_default_display_filters + ), + ), + migrations.AddField( + model_name="issueproperty", + name="filters", + field=models.JSONField( + default=plane.db.models.issue.get_default_filters + ), + ), + migrations.AddField( + model_name="issueview", + name="display_filters", + field=models.JSONField( + default=plane.db.models.view.get_default_display_filters + ), + ), + migrations.AddField( + model_name="issueview", + name="display_properties", + field=models.JSONField( + default=plane.db.models.view.get_default_display_properties + ), + ), + migrations.AddField( + model_name="issueview", + name="sort_order", + field=models.FloatField(default=65535), + ), + migrations.AlterField( + model_name="issueview", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.CreateModel( + name="WorkspaceUserProperties", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "filters", + models.JSONField( + default=plane.db.models.workspace.get_default_filters + ), + ), + ( + "display_filters", + models.JSONField( + default=plane.db.models.workspace.get_default_display_filters + ), + ), + ( + "display_properties", + models.JSONField( + default=plane.db.models.workspace.get_default_display_properties + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_properties", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_user_properties", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Workspace User Property", + "verbose_name_plural": "Workspace User Property", + "db_table": "Workspace_user_properties", + "ordering": ("-created_at",), + "unique_together": {("workspace", "user")}, + }, + ), + migrations.CreateModel( + name="ModuleUserProperties", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "filters", + models.JSONField( + default=plane.db.models.module.get_default_filters + ), + ), + ( + "display_filters", + models.JSONField( + default=plane.db.models.module.get_default_display_filters + ), + ), + ( + "display_properties", + models.JSONField( + default=plane.db.models.module.get_default_display_properties + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_user_properties", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_user_properties", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Module User Property", + "verbose_name_plural": "Module User Property", + "db_table": "module_user_properties", + "ordering": ("-created_at",), + "unique_together": {("module", "user")}, + }, + ), + migrations.CreateModel( + name="CycleUserProperties", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "filters", + models.JSONField( + default=plane.db.models.cycle.get_default_filters + ), + ), + ( + "display_filters", + models.JSONField( + default=plane.db.models.cycle.get_default_display_filters + ), + ), + ( + "display_properties", + models.JSONField( + default=plane.db.models.cycle.get_default_display_properties + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_user_properties", + to="db.cycle", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="cycle_user_properties", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Cycle User Property", + "verbose_name_plural": "Cycle User Properties", + "db_table": "cycle_user_properties", + "ordering": ("-created_at",), + "unique_together": {("cycle", "user")}, + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0053_auto_20240102_1315.py b/apiserver/plane/db/migrations/0053_auto_20240102_1315.py new file mode 100644 index 00000000000..32b5ad2d51e --- /dev/null +++ b/apiserver/plane/db/migrations/0053_auto_20240102_1315.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.7 on 2024-01-02 13:15 + +from plane.db.models import WorkspaceUserProperties, ProjectMember, IssueView +from django.db import migrations + + +def workspace_user_properties(apps, schema_editor): + WorkspaceMember = apps.get_model("db", "WorkspaceMember") + updated_workspace_user_properties = [] + for workspace_members in WorkspaceMember.objects.all(): + updated_workspace_user_properties.append( + WorkspaceUserProperties( + user_id=workspace_members.member_id, + display_filters=workspace_members.view_props.get( + "display_filters" + ), + display_properties=workspace_members.view_props.get( + "display_properties" + ), + workspace_id=workspace_members.workspace_id, + ) + ) + WorkspaceUserProperties.objects.bulk_create( + updated_workspace_user_properties, batch_size=2000 + ) + + +def project_user_properties(apps, schema_editor): + IssueProperty = apps.get_model("db", "IssueProperty") + updated_issue_user_properties = [] + for issue_property in IssueProperty.objects.all(): + project_member = ProjectMember.objects.filter( + project_id=issue_property.project_id, + member_id=issue_property.user_id, + ).first() + if project_member: + issue_property.filters = project_member.view_props.get("filters") + issue_property.display_filters = project_member.view_props.get( + "display_filters" + ) + updated_issue_user_properties.append(issue_property) + + IssueProperty.objects.bulk_update( + updated_issue_user_properties, + ["filters", "display_filters"], + batch_size=2000, + ) + + +def issue_view(apps, schema_editor): + GlobalView = apps.get_model("db", "GlobalView") + updated_issue_views = [] + + for global_view in GlobalView.objects.all(): + updated_issue_views.append( + IssueView( + workspace_id=global_view.workspace_id, + name=global_view.name, + description=global_view.description, + query=global_view.query, + access=global_view.access, + filters=global_view.query_data.get("filters", {}), + sort_order=global_view.sort_order, + created_by_id=global_view.created_by_id, + updated_by_id=global_view.updated_by_id, + ) + ) + IssueView.objects.bulk_create(updated_issue_views, batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0052_auto_20231220_1141"), + ] + + operations = [ + migrations.RunPython(workspace_user_properties), + migrations.RunPython(project_user_properties), + migrations.RunPython(issue_view), + ] diff --git a/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py b/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py new file mode 100644 index 00000000000..933c229a159 --- /dev/null +++ b/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py @@ -0,0 +1,77 @@ +# Generated by Django 4.2.7 on 2024-01-08 06:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0053_auto_20240102_1315'), + ] + + operations = [ + migrations.CreateModel( + name='Dashboard', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description_html', models.TextField(blank=True, default='

')), + ('identifier', models.UUIDField(null=True)), + ('is_default', models.BooleanField(default=False)), + ('type_identifier', models.CharField(choices=[('workspace', 'Workspace'), ('project', 'Project'), ('home', 'Home'), ('team', 'Team'), ('user', 'User')], default='home', max_length=30, verbose_name='Dashboard Type')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboards', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Dashboard', + 'verbose_name_plural': 'Dashboards', + 'db_table': 'dashboards', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Widget', + fields=[ + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('key', models.CharField(max_length=255)), + ('filters', models.JSONField(default=dict)), + ], + options={ + 'verbose_name': 'Widget', + 'verbose_name_plural': 'Widgets', + 'db_table': 'widgets', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='DashboardWidget', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('is_visible', models.BooleanField(default=True)), + ('sort_order', models.FloatField(default=65535)), + ('filters', models.JSONField(default=dict)), + ('properties', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('dashboard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_widgets', to='db.dashboard')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('widget', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_widgets', to='db.widget')), + ], + options={ + 'verbose_name': 'Dashboard Widget', + 'verbose_name_plural': 'Dashboard Widgets', + 'db_table': 'dashboard_widgets', + 'ordering': ('-created_at',), + 'unique_together': {('widget', 'dashboard')}, + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0055_auto_20240108_0648.py b/apiserver/plane/db/migrations/0055_auto_20240108_0648.py new file mode 100644 index 00000000000..e369c185d6b --- /dev/null +++ b/apiserver/plane/db/migrations/0055_auto_20240108_0648.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.7 on 2024-01-08 06:48 + +from django.db import migrations + + +def create_widgets(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + widgets_list = [ + {"key": "overview_stats", "filters": {}}, + { + "key": "assigned_issues", + "filters": { + "duration": "this_week", + "tab": "upcoming", + }, + }, + { + "key": "created_issues", + "filters": { + "duration": "this_week", + "tab": "upcoming", + }, + }, + { + "key": "issues_by_state_groups", + "filters": { + "duration": "this_week", + }, + }, + { + "key": "issues_by_priority", + "filters": { + "duration": "this_week", + }, + }, + {"key": "recent_activity", "filters": {}}, + {"key": "recent_projects", "filters": {}}, + {"key": "recent_collaborators", "filters": {}}, + ] + Widget.objects.bulk_create( + [ + Widget( + key=widget["key"], + filters=widget["filters"], + ) + for widget in widgets_list + ], + batch_size=10, + ) + + +def create_dashboards(apps, schema_editor): + Dashboard = apps.get_model("db", "Dashboard") + User = apps.get_model("db", "User") + Dashboard.objects.bulk_create( + [ + Dashboard( + name="Home dashboard", + description_html="

", + identifier=None, + owned_by_id=user_id, + type_identifier="home", + is_default=True, + ) + for user_id in User.objects.values_list('id', flat=True) + ], + batch_size=2000, + ) + + +def create_dashboard_widgets(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + Dashboard = apps.get_model("db", "Dashboard") + DashboardWidget = apps.get_model("db", "DashboardWidget") + + updated_dashboard_widget = [ + DashboardWidget( + widget_id=widget_id, + dashboard_id=dashboard_id, + ) + for widget_id in Widget.objects.values_list('id', flat=True) + for dashboard_id in Dashboard.objects.values_list('id', flat=True) + ] + + DashboardWidget.objects.bulk_create(updated_dashboard_widget, batch_size=2000) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0054_dashboard_widget_dashboardwidget"), + ] + + operations = [ + migrations.RunPython(create_widgets), + migrations.RunPython(create_dashboards), + migrations.RunPython(create_dashboard_widgets), + ] diff --git a/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py new file mode 100644 index 00000000000..2e6645945a4 --- /dev/null +++ b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py @@ -0,0 +1,184 @@ +# Generated by Django 4.2.7 on 2024-01-22 08:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0055_auto_20240108_0648"), + ] + + operations = [ + migrations.CreateModel( + name="UserNotificationPreference", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("property_change", models.BooleanField(default=True)), + ("state_change", models.BooleanField(default=True)), + ("comment", models.BooleanField(default=True)), + ("mention", models.BooleanField(default=True)), + ("issue_completed", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_notification_preferences", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notification_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_notification_preferences", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "UserNotificationPreference", + "verbose_name_plural": "UserNotificationPreferences", + "db_table": "user_notification_preferences", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="EmailNotificationLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ("entity_name", models.CharField(max_length=255)), + ("data", models.JSONField(null=True)), + ("processed_at", models.DateTimeField(null=True)), + ("sent_at", models.DateTimeField(null=True)), + ("entity", models.CharField(max_length=200)), + ( + "old_value", + models.CharField(blank=True, max_length=300, null=True), + ), + ( + "new_value", + models.CharField(blank=True, max_length=300, null=True), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "receiver", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "triggered_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="triggered_emails", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Email Notification Log", + "verbose_name_plural": "Email Notification Logs", + "db_table": "email_notification_logs", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0057_auto_20240122_0901.py b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py new file mode 100644 index 00000000000..9204d43b3f5 --- /dev/null +++ b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.7 on 2024-01-22 09:01 + +from django.db import migrations + +def create_notification_preferences(apps, schema_editor): + UserNotificationPreference = apps.get_model("db", "UserNotificationPreference") + User = apps.get_model("db", "User") + + bulk_notification_preferences = [] + for user_id in User.objects.filter(is_bot=False).values_list("id", flat=True): + bulk_notification_preferences.append( + UserNotificationPreference( + user_id=user_id, + created_by_id=user_id, + ) + ) + UserNotificationPreference.objects.bulk_create( + bulk_notification_preferences, batch_size=1000, ignore_conflicts=True + ) + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0056_usernotificationpreference_emailnotificationlog"), + ] + + operations = [ + migrations.RunPython(create_notification_preferences) + ] diff --git a/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py new file mode 100644 index 00000000000..6238ef8257c --- /dev/null +++ b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-24 18:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0057_auto_20240122_0901'), + ] + + operations = [ + migrations.AlterField( + model_name='moduleissue', + name='issue', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'), + ), + migrations.AlterUniqueTogether( + name='moduleissue', + unique_together={('issue', 'module')}, + ), + ] diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index 728cb993351..263f9ab9a5f 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -13,7 +13,9 @@ class TimeAuditModel(models.Model): auto_now_add=True, verbose_name="Created At", ) - updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + updated_at = models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ) class Meta: abstract = True diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index c76df6e5bfa..d9096bd01f0 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -9,6 +9,8 @@ WorkspaceMemberInvite, TeamMember, WorkspaceTheme, + WorkspaceUserProperties, + WorkspaceBaseModel, ) from .project import ( @@ -48,11 +50,18 @@ from .state import State -from .cycle import Cycle, CycleIssue, CycleFavorite +from .cycle import Cycle, CycleIssue, CycleFavorite, CycleUserProperties from .view import GlobalView, IssueView, IssueViewFavorite -from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite +from .module import ( + Module, + ModuleMember, + ModuleIssue, + ModuleLink, + ModuleFavorite, + ModuleUserProperties, +) from .api import APIToken, APIActivityLog @@ -76,8 +85,10 @@ from .analytic import AnalyticView -from .notification import Notification +from .notification import Notification, UserNotificationPreference, EmailNotificationLog from .exporter import ExporterHistory from .webhook import Webhook, WebhookLog + +from .dashboard import Dashboard, DashboardWidget, Widget \ No newline at end of file diff --git a/apiserver/plane/db/models/api.py b/apiserver/plane/db/models/api.py index 0fa1d4abae0..78da81814aa 100644 --- a/apiserver/plane/db/models/api.py +++ b/apiserver/plane/db/models/api.py @@ -38,7 +38,10 @@ class APIToken(BaseModel): choices=((0, "Human"), (1, "Bot")), default=0 ) workspace = models.ForeignKey( - "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True + "db.Workspace", + related_name="api_tokens", + on_delete=models.CASCADE, + null=True, ) expired_at = models.DateTimeField(blank=True, null=True) diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index ab3c38d9c2c..71350861380 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -34,7 +34,10 @@ class FileAsset(BaseModel): ], ) workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets" + "db.Workspace", + on_delete=models.CASCADE, + null=True, + related_name="assets", ) is_deleted = models.BooleanField(default=False) diff --git a/apiserver/plane/db/models/base.py b/apiserver/plane/db/models/base.py index d0531e881df..63c08afa49a 100644 --- a/apiserver/plane/db/models/base.py +++ b/apiserver/plane/db/models/base.py @@ -12,7 +12,11 @@ class BaseModel(AuditModel): id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, ) class Meta: diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index e5e2c355b3b..5251c68ec9f 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -6,10 +6,58 @@ from . import ProjectBaseModel +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + class Cycle(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Cycle Name") - description = models.TextField(verbose_name="Cycle Description", blank=True) - start_date = models.DateField(verbose_name="Start Date", blank=True, null=True) + description = models.TextField( + verbose_name="Cycle Description", blank=True + ) + start_date = models.DateField( + verbose_name="Start Date", blank=True, null=True + ) end_date = models.DateField(verbose_name="End Date", blank=True, null=True) owned_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -89,3 +137,31 @@ class Meta: def __str__(self): """Return user and the cycle""" return f"{self.user.email} <{self.cycle.name}>" + + +class CycleUserProperties(ProjectBaseModel): + cycle = models.ForeignKey( + "db.Cycle", + on_delete=models.CASCADE, + related_name="cycle_user_properties", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="cycle_user_properties", + ) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField( + default=get_default_display_properties + ) + + class Meta: + unique_together = ["cycle", "user"] + verbose_name = "Cycle User Property" + verbose_name_plural = "Cycle User Properties" + db_table = "cycle_user_properties" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle.name} {self.user.email}" diff --git a/apiserver/plane/db/models/dashboard.py b/apiserver/plane/db/models/dashboard.py new file mode 100644 index 00000000000..05c5a893f4e --- /dev/null +++ b/apiserver/plane/db/models/dashboard.py @@ -0,0 +1,89 @@ +import uuid + +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from . import BaseModel +from ..mixins import TimeAuditModel + +class Dashboard(BaseModel): + DASHBOARD_CHOICES = ( + ("workspace", "Workspace"), + ("project", "Project"), + ("home", "Home"), + ("team", "Team"), + ("user", "User"), + ) + name = models.CharField(max_length=255) + description_html = models.TextField(blank=True, default="

") + identifier = models.UUIDField(null=True) + owned_by = models.ForeignKey( + "db.User", + on_delete=models.CASCADE, + related_name="dashboards", + ) + is_default = models.BooleanField(default=False) + type_identifier = models.CharField( + max_length=30, + choices=DASHBOARD_CHOICES, + verbose_name="Dashboard Type", + default="home", + ) + + def __str__(self): + """Return name of the dashboard""" + return f"{self.name}" + + class Meta: + verbose_name = "Dashboard" + verbose_name_plural = "Dashboards" + db_table = "dashboards" + ordering = ("-created_at",) + + +class Widget(TimeAuditModel): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + key = models.CharField(max_length=255) + filters = models.JSONField(default=dict) + + def __str__(self): + """Return name of the widget""" + return f"{self.key}" + + class Meta: + verbose_name = "Widget" + verbose_name_plural = "Widgets" + db_table = "widgets" + ordering = ("-created_at",) + + +class DashboardWidget(BaseModel): + widget = models.ForeignKey( + Widget, + on_delete=models.CASCADE, + related_name="dashboard_widgets", + ) + dashboard = models.ForeignKey( + Dashboard, + on_delete=models.CASCADE, + related_name="dashboard_widgets", + ) + is_visible = models.BooleanField(default=True) + sort_order = models.FloatField(default=65535) + filters = models.JSONField(default=dict) + properties = models.JSONField(default=dict) + + def __str__(self): + """Return name of the dashboard""" + return f"{self.dashboard.name} {self.widget.key}" + + class Meta: + unique_together = ("widget", "dashboard") + verbose_name = "Dashboard Widget" + verbose_name_plural = "Dashboard Widgets" + db_table = "dashboard_widgets" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index d95a863162d..bb57e788c15 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -8,7 +8,9 @@ class Estimate(ProjectBaseModel): name = models.CharField(max_length=255) - description = models.TextField(verbose_name="Estimate Description", blank=True) + description = models.TextField( + verbose_name="Estimate Description", blank=True + ) def __str__(self): """Return name of the estimate""" diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py index 0383807b702..d427eb0f6d2 100644 --- a/apiserver/plane/db/models/exporter.py +++ b/apiserver/plane/db/models/exporter.py @@ -11,14 +11,20 @@ # Module imports from . import BaseModel + def generate_token(): return uuid4().hex + class ExporterHistory(BaseModel): workspace = models.ForeignKey( - "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters" + "db.WorkSpace", + on_delete=models.CASCADE, + related_name="workspace_exporters", + ) + project = ArrayField( + models.UUIDField(default=uuid.uuid4), blank=True, null=True ) - project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True) provider = models.CharField( max_length=50, choices=( @@ -40,9 +46,13 @@ class ExporterHistory(BaseModel): reason = models.TextField(blank=True) key = models.TextField(blank=True) url = models.URLField(max_length=800, blank=True, null=True) - token = models.CharField(max_length=255, default=generate_token, unique=True) + token = models.CharField( + max_length=255, default=generate_token, unique=True + ) initiated_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_exporters", ) class Meta: diff --git a/apiserver/plane/db/models/importer.py b/apiserver/plane/db/models/importer.py index a2d1d3166ac..651927458bd 100644 --- a/apiserver/plane/db/models/importer.py +++ b/apiserver/plane/db/models/importer.py @@ -25,7 +25,9 @@ class Importer(ProjectBaseModel): default="queued", ) initiated_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="imports", ) metadata = models.JSONField(default=dict) config = models.JSONField(default=dict) diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/inbox.py index 6ad88e681df..809a118219c 100644 --- a/apiserver/plane/db/models/inbox.py +++ b/apiserver/plane/db/models/inbox.py @@ -7,7 +7,9 @@ class Inbox(ProjectBaseModel): name = models.CharField(max_length=255) - description = models.TextField(verbose_name="Inbox Description", blank=True) + description = models.TextField( + verbose_name="Inbox Description", blank=True + ) is_default = models.BooleanField(default=False) view_props = models.JSONField(default=dict) @@ -31,12 +33,21 @@ class InboxIssue(ProjectBaseModel): "db.Issue", related_name="issue_inbox", on_delete=models.CASCADE ) status = models.IntegerField( - choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")), + choices=( + (-2, "Pending"), + (-1, "Rejected"), + (0, "Snoozed"), + (1, "Accepted"), + (2, "Duplicate"), + ), default=-2, ) snoozed_till = models.DateTimeField(null=True) duplicate_to = models.ForeignKey( - "db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True + "db.Issue", + related_name="inbox_duplicate", + on_delete=models.SET_NULL, + null=True, ) source = models.TextField(blank=True, null=True) external_source = models.CharField(max_length=255, null=True, blank=True) diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py index 3bef687084a..34b40e57d98 100644 --- a/apiserver/plane/db/models/integration/__init__.py +++ b/apiserver/plane/db/models/integration/__init__.py @@ -1,3 +1,8 @@ from .base import Integration, WorkspaceIntegration -from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync +from .github import ( + GithubRepository, + GithubRepositorySync, + GithubIssueSync, + GithubCommentSync, +) from .slack import SlackProjectSync diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py index 47db0483c48..0c68adfd2ea 100644 --- a/apiserver/plane/db/models/integration/base.py +++ b/apiserver/plane/db/models/integration/base.py @@ -11,7 +11,11 @@ class Integration(AuditModel): id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, ) title = models.CharField(max_length=400) provider = models.CharField(max_length=400, unique=True) @@ -40,14 +44,18 @@ class Meta: class WorkspaceIntegration(BaseModel): workspace = models.ForeignKey( - "db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE + "db.Workspace", + related_name="workspace_integrations", + on_delete=models.CASCADE, ) # Bot user actor = models.ForeignKey( "db.User", related_name="integrations", on_delete=models.CASCADE ) integration = models.ForeignKey( - "db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE + "db.Integration", + related_name="integrated_workspaces", + on_delete=models.CASCADE, ) api_token = models.ForeignKey( "db.APIToken", related_name="integrations", on_delete=models.CASCADE diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py index f4d152bb1b4..f3331c87451 100644 --- a/apiserver/plane/db/models/integration/github.py +++ b/apiserver/plane/db/models/integration/github.py @@ -36,10 +36,15 @@ class GithubRepositorySync(ProjectBaseModel): "db.User", related_name="user_syncs", on_delete=models.CASCADE ) workspace_integration = models.ForeignKey( - "db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE + "db.WorkspaceIntegration", + related_name="github_syncs", + on_delete=models.CASCADE, ) label = models.ForeignKey( - "db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs" + "db.Label", + on_delete=models.SET_NULL, + null=True, + related_name="repo_syncs", ) def __str__(self): @@ -62,7 +67,9 @@ class GithubIssueSync(ProjectBaseModel): "db.Issue", related_name="github_syncs", on_delete=models.CASCADE ) repository_sync = models.ForeignKey( - "db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE + "db.GithubRepositorySync", + related_name="issue_syncs", + on_delete=models.CASCADE, ) def __str__(self): @@ -80,10 +87,14 @@ class Meta: class GithubCommentSync(ProjectBaseModel): repo_comment_id = models.BigIntegerField() comment = models.ForeignKey( - "db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE + "db.IssueComment", + related_name="comment_syncs", + on_delete=models.CASCADE, ) issue_sync = models.ForeignKey( - "db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE + "db.GithubIssueSync", + related_name="comment_syncs", + on_delete=models.CASCADE, ) def __str__(self): diff --git a/apiserver/plane/db/models/integration/slack.py b/apiserver/plane/db/models/integration/slack.py index 6b29968f659..72df4dfd737 100644 --- a/apiserver/plane/db/models/integration/slack.py +++ b/apiserver/plane/db/models/integration/slack.py @@ -17,7 +17,9 @@ class SlackProjectSync(ProjectBaseModel): team_id = models.CharField(max_length=30) team_name = models.CharField(max_length=300) workspace_integration = models.ForeignKey( - "db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE + "db.WorkspaceIntegration", + related_name="slack_syncs", + on_delete=models.CASCADE, ) def __str__(self): diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 54acd5c5d0b..d5ed4247a74 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -9,6 +9,7 @@ from django.dispatch import receiver from django.core.validators import MinValueValidator, MaxValueValidator from django.core.exceptions import ValidationError +from django.utils import timezone # Module imports from . import ProjectBaseModel @@ -33,6 +34,50 @@ def get_default_properties(): } +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + # TODO: Handle identifiers for Bulk Inserts - nk class IssueManager(models.Manager): def get_queryset(self): @@ -73,7 +118,9 @@ class Issue(ProjectBaseModel): related_name="state_issue", ) estimate_point = models.IntegerField( - validators=[MinValueValidator(0), MaxValueValidator(7)], null=True, blank=True + validators=[MinValueValidator(0), MaxValueValidator(7)], + null=True, + blank=True, ) name = models.CharField(max_length=255, verbose_name="Issue Name") description = models.JSONField(blank=True, default=dict) @@ -94,7 +141,9 @@ class Issue(ProjectBaseModel): through="IssueAssignee", through_fields=("issue", "assignee"), ) - sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + sequence_id = models.IntegerField( + default=1, verbose_name="Issue Sequence ID" + ) labels = models.ManyToManyField( "db.Label", blank=True, related_name="labels", through="IssueLabel" ) @@ -121,7 +170,9 @@ def save(self, *args, **kwargs): from plane.db.models import State default_state = State.objects.filter( - ~models.Q(name="Triage"), project=self.project, default=True + ~models.Q(name="Triage"), + project=self.project, + default=True, ).first() # if there is no default state assign any random state if default_state is None: @@ -133,13 +184,23 @@ def save(self, *args, **kwargs): self.state = default_state except ImportError: pass + else: + try: + from plane.db.models import State + # Check if the current issue state group is completed or not + if self.state.group == "completed": + self.completed_at = timezone.now() + else: + self.completed_at = None + except ImportError: + pass if self._state.adding: # Get the maximum display_id value from the database - last_id = IssueSequence.objects.filter(project=self.project).aggregate( - largest=models.Max("sequence") - )["largest"] + last_id = IssueSequence.objects.filter( + project=self.project + ).aggregate(largest=models.Max("sequence"))["largest"] # aggregate can return None! Check it first. # If it isn't none, just use the last ID specified (which should be the greatest) and add one to it if last_id: @@ -212,8 +273,9 @@ class Meta: ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" - + return f"{self.issue.name} {self.related_issue.name}" + + class IssueMention(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_mention" @@ -223,6 +285,7 @@ class IssueMention(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_mention", ) + class Meta: unique_together = ["issue", "mention"] verbose_name = "Issue Mention" @@ -231,7 +294,7 @@ class Meta: ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.mention.email}" + return f"{self.issue.name} {self.mention.email}" class IssueAssignee(ProjectBaseModel): @@ -307,17 +370,28 @@ def __str__(self): class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( - Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" + Issue, + on_delete=models.SET_NULL, + null=True, + related_name="issue_activity", + ) + verb = models.CharField( + max_length=255, verbose_name="Action", default="created" ) - verb = models.CharField(max_length=255, verbose_name="Action", default="created") field = models.CharField( max_length=255, verbose_name="Field Name", blank=True, null=True ) - old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) - new_value = models.TextField(verbose_name="New Value", blank=True, null=True) + old_value = models.TextField( + verbose_name="Old Value", blank=True, null=True + ) + new_value = models.TextField( + verbose_name="New Value", blank=True, null=True + ) comment = models.TextField(verbose_name="Comment", blank=True) - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + attachments = ArrayField( + models.URLField(), size=10, blank=True, default=list + ) issue_comment = models.ForeignKey( "db.IssueComment", on_delete=models.SET_NULL, @@ -349,7 +423,9 @@ class IssueComment(ProjectBaseModel): comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

") - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + attachments = ArrayField( + models.URLField(), size=10, blank=True, default=list + ) issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_comments" ) @@ -394,7 +470,11 @@ class IssueProperty(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_property_user", ) - properties = models.JSONField(default=get_default_properties) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField( + default=get_default_display_properties + ) class Meta: verbose_name = "Issue Property" @@ -466,7 +546,10 @@ def __str__(self): class IssueSequence(ProjectBaseModel): issue = models.ForeignKey( - Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True + Issue, + on_delete=models.SET_NULL, + related_name="issue_sequence", + null=True, ) sequence = models.PositiveBigIntegerField(default=1) deleted = models.BooleanField(default=False) @@ -528,7 +611,9 @@ class CommentReaction(ProjectBaseModel): related_name="comment_reactions", ) comment = models.ForeignKey( - IssueComment, on_delete=models.CASCADE, related_name="comment_reactions" + IssueComment, + on_delete=models.CASCADE, + related_name="comment_reactions", ) reaction = models.CharField(max_length=20) @@ -544,9 +629,13 @@ def __str__(self): class IssueVote(ProjectBaseModel): - issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes") + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="votes" + ) actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="votes", ) vote = models.IntegerField( choices=( @@ -575,5 +664,7 @@ def __str__(self): def create_issue_sequence(sender, instance, created, **kwargs): if created: IssueSequence.objects.create( - issue=instance, sequence=instance.sequence_id, project=instance.project + issue=instance, + sequence=instance.sequence_id, + project=instance.project, ) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index e485eea6257..9af4e120e74 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -6,9 +6,55 @@ from . import ProjectBaseModel +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + class Module(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Module Name") - description = models.TextField(verbose_name="Module Description", blank=True) + description = models.TextField( + verbose_name="Module Description", blank=True + ) description_text = models.JSONField( verbose_name="Module Description RT", blank=True, null=True ) @@ -30,7 +76,10 @@ class Module(ProjectBaseModel): max_length=20, ) lead = models.ForeignKey( - "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True + "db.User", + on_delete=models.SET_NULL, + related_name="module_leads", + null=True, ) members = models.ManyToManyField( settings.AUTH_USER_MODEL, @@ -53,9 +102,9 @@ class Meta: def save(self, *args, **kwargs): if self._state.adding: - smallest_sort_order = Module.objects.filter(project=self.project).aggregate( - smallest=models.Min("sort_order") - )["smallest"] + smallest_sort_order = Module.objects.filter( + project=self.project + ).aggregate(smallest=models.Min("sort_order"))["smallest"] if smallest_sort_order is not None: self.sort_order = smallest_sort_order - 10000 @@ -85,11 +134,12 @@ class ModuleIssue(ProjectBaseModel): module = models.ForeignKey( "db.Module", on_delete=models.CASCADE, related_name="issue_module" ) - issue = models.OneToOneField( + issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_module" ) class Meta: + unique_together = ["issue", "module"] verbose_name = "Module Issue" verbose_name_plural = "Module Issues" db_table = "module_issues" @@ -141,3 +191,31 @@ class Meta: def __str__(self): """Return user and the module""" return f"{self.user.email} <{self.module.name}>" + + +class ModuleUserProperties(ProjectBaseModel): + module = models.ForeignKey( + "db.Module", + on_delete=models.CASCADE, + related_name="module_user_properties", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="module_user_properties", + ) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField( + default=get_default_display_properties + ) + + class Meta: + unique_together = ["module", "user"] + verbose_name = "Module User Property" + verbose_name_plural = "Module User Property" + db_table = "module_user_properties" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.user.email}" diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 3df93571802..b42ae54a927 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -1,16 +1,19 @@ # Django imports from django.db import models +from django.conf import settings -# Third party imports -from .base import BaseModel - +# Module imports +from . import BaseModel class Notification(BaseModel): workspace = models.ForeignKey( "db.Workspace", related_name="notifications", on_delete=models.CASCADE ) project = models.ForeignKey( - "db.Project", related_name="notifications", on_delete=models.CASCADE, null=True + "db.Project", + related_name="notifications", + on_delete=models.CASCADE, + null=True, ) data = models.JSONField(null=True) entity_identifier = models.UUIDField(null=True) @@ -20,8 +23,17 @@ class Notification(BaseModel): message_html = models.TextField(blank=True, default="

") message_stripped = models.TextField(blank=True, null=True) sender = models.CharField(max_length=255) - triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True) - receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) + triggered_by = models.ForeignKey( + "db.User", + related_name="triggered_notifications", + on_delete=models.SET_NULL, + null=True, + ) + receiver = models.ForeignKey( + "db.User", + related_name="received_notifications", + on_delete=models.CASCADE, + ) read_at = models.DateTimeField(null=True) snoozed_till = models.DateTimeField(null=True) archived_at = models.DateTimeField(null=True) @@ -35,3 +47,82 @@ class Meta: def __str__(self): """Return name of the notifications""" return f"{self.receiver.email} <{self.workspace.name}>" + + +def get_default_preference(): + return { + "property_change": { + "email": True, + }, + "state": { + "email": True, + }, + "comment": { + "email": True, + }, + "mentions": { + "email": True, + }, + } + + +class UserNotificationPreference(BaseModel): + # user it is related to + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notification_preferences", + ) + # workspace if it is applicable + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_notification_preferences", + null=True, + ) + # project + project = models.ForeignKey( + "db.Project", + on_delete=models.CASCADE, + related_name="project_notification_preferences", + null=True, + ) + + # preference fields + property_change = models.BooleanField(default=True) + state_change = models.BooleanField(default=True) + comment = models.BooleanField(default=True) + mention = models.BooleanField(default=True) + issue_completed = models.BooleanField(default=True) + + class Meta: + verbose_name = "UserNotificationPreference" + verbose_name_plural = "UserNotificationPreferences" + db_table = "user_notification_preferences" + ordering = ("-created_at",) + + def __str__(self): + """Return the user""" + return f"<{self.user}>" + +class EmailNotificationLog(BaseModel): + # receiver + receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="email_notifications") + triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_emails") + # entity - can be issues, pages, etc. + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=255) + # data + data = models.JSONField(null=True) + # sent at + processed_at = models.DateTimeField(null=True) + sent_at = models.DateTimeField(null=True) + entity = models.CharField(max_length=200) + old_value = models.CharField(max_length=300, blank=True, null=True) + new_value = models.CharField(max_length=300, blank=True, null=True) + + class Meta: + verbose_name = "Email Notification Log" + verbose_name_plural = "Email Notification Logs" + db_table = "email_notification_logs" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index de65cb98f92..6ed94798a2f 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -15,7 +15,9 @@ class Page(ProjectBaseModel): description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="pages", ) access = models.PositiveSmallIntegerField( choices=((0, "Public"), (1, "Private")), default=0 @@ -53,7 +55,7 @@ class PageLog(ProjectBaseModel): ("video", "Video"), ("file", "File"), ("link", "Link"), - ("cycle","Cycle"), + ("cycle", "Cycle"), ("module", "Module"), ("back_link", "Back Link"), ("forward_link", "Forward Link"), @@ -77,13 +79,15 @@ class Meta: verbose_name_plural = "Page Logs" db_table = "page_logs" ordering = ("-created_at",) - + def __str__(self): return f"{self.page.name} {self.type}" class PageBlock(ProjectBaseModel): - page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="blocks") + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="blocks" + ) name = models.CharField(max_length=255) description = models.JSONField(default=dict, blank=True) description_html = models.TextField(blank=True, default="

") @@ -118,7 +122,9 @@ def save(self, *args, **kwargs): group="completed", project=self.project ).first() if completed_state is not None: - Issue.objects.update(pk=self.issue_id, state=completed_state) + Issue.objects.update( + pk=self.issue_id, state=completed_state + ) except ImportError: pass super(PageBlock, self).save(*args, **kwargs) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index fe72c260b8e..b9317472433 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -35,7 +35,7 @@ def get_default_props(): }, "display_filters": { "group_by": None, - "order_by": '-created_at', + "order_by": "-created_at", "type": None, "sub_issue": True, "show_empty_groups": True, @@ -52,16 +52,22 @@ def get_default_preferences(): class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) name = models.CharField(max_length=255, verbose_name="Project Name") - description = models.TextField(verbose_name="Project Description", blank=True) + description = models.TextField( + verbose_name="Project Description", blank=True + ) description_text = models.JSONField( verbose_name="Project Description RT", blank=True, null=True ) description_html = models.JSONField( verbose_name="Project Description HTML", blank=True, null=True ) - network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) + network = models.PositiveSmallIntegerField( + default=2, choices=NETWORK_CHOICES + ) workspace = models.ForeignKey( - "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" + "db.WorkSpace", + on_delete=models.CASCADE, + related_name="workspace_project", ) identifier = models.CharField( max_length=12, @@ -90,7 +96,10 @@ class Project(BaseModel): inbox_view = models.BooleanField(default=False) cover_image = models.URLField(blank=True, null=True, max_length=800) estimate = models.ForeignKey( - "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True + "db.Estimate", + on_delete=models.SET_NULL, + related_name="projects", + null=True, ) archive_in = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] @@ -99,7 +108,10 @@ class Project(BaseModel): default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) default_state = models.ForeignKey( - "db.State", on_delete=models.SET_NULL, null=True, related_name="default_state" + "db.State", + on_delete=models.SET_NULL, + null=True, + related_name="default_state", ) def __str__(self): @@ -195,7 +207,10 @@ def __str__(self): # TODO: Remove workspace relation later class ProjectIdentifier(AuditModel): workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True + "db.Workspace", + models.CASCADE, + related_name="project_identifiers", + null=True, ) project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="project_identifier" @@ -250,7 +265,10 @@ class ProjectDeployBoard(ProjectBaseModel): comments = models.BooleanField(default=False) reactions = models.BooleanField(default=False) inbox = models.ForeignKey( - "db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True + "db.Inbox", + related_name="bord_inbox", + on_delete=models.SET_NULL, + null=True, ) votes = models.BooleanField(default=False) views = models.JSONField(default=get_default_views) diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index 3370f239d0e..ab9b780c8dc 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -8,7 +8,9 @@ class State(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="State Name") - description = models.TextField(verbose_name="State Description", blank=True) + description = models.TextField( + verbose_name="State Description", blank=True + ) color = models.CharField(max_length=255, verbose_name="State Color") slug = models.SlugField(max_length=100, blank=True) sequence = models.FloatField(default=65535) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index fe75a6a261f..6f8a82e5672 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -6,11 +6,15 @@ # Django imports from django.db import models +from django.contrib.auth.models import ( + AbstractBaseUser, + UserManager, + PermissionsMixin, +) from django.db.models.signals import post_save +from django.conf import settings from django.dispatch import receiver -from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin from django.utils import timezone -from django.conf import settings # Third party imports from sentry_sdk import capture_exception @@ -29,22 +33,34 @@ def get_default_onboarding(): class User(AbstractBaseUser, PermissionsMixin): id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + default=uuid.uuid4, + unique=True, + editable=False, + db_index=True, + primary_key=True, ) username = models.CharField(max_length=128, unique=True) # user fields mobile_number = models.CharField(max_length=255, blank=True, null=True) - email = models.CharField(max_length=255, null=True, blank=True, unique=True) + email = models.CharField( + max_length=255, null=True, blank=True, unique=True + ) first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) avatar = models.CharField(max_length=255, blank=True) cover_image = models.URLField(blank=True, null=True, max_length=800) # tracking metrics - date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") - updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + date_joined = models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ) + created_at = models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ) + updated_at = models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ) last_location = models.CharField(max_length=255, blank=True) created_location = models.CharField(max_length=255, blank=True) @@ -65,7 +81,9 @@ class User(AbstractBaseUser, PermissionsMixin): has_billing_address = models.BooleanField(default=False) USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) - user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES) + user_timezone = models.CharField( + max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES + ) last_active = models.DateTimeField(default=timezone.now, null=True) last_login_time = models.DateTimeField(null=True) @@ -115,7 +133,9 @@ def save(self, *args, **kwargs): self.display_name = ( self.email.split("@")[0] if len(self.email.split("@")) - else "".join(random.choice(string.ascii_letters) for _ in range(6)) + else "".join( + random.choice(string.ascii_letters) for _ in range(6) + ) ) if self.is_superuser: @@ -142,3 +162,14 @@ def send_welcome_slack(sender, instance, created, **kwargs): except Exception as e: capture_exception(e) return + + +@receiver(post_save, sender=User) +def create_user_notification(sender, instance, created, **kwargs): + # create preferences + if created and not instance.is_bot: + # Module imports + from plane.db.models import UserNotificationPreference + UserNotificationPreference.objects.create( + user=instance, + ) diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 44bc994d0c8..13500b5a481 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -3,7 +3,51 @@ from django.conf import settings # Module import -from . import ProjectBaseModel, BaseModel +from . import ProjectBaseModel, BaseModel, WorkspaceBaseModel + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } class GlobalView(BaseModel): @@ -24,7 +68,7 @@ class Meta: verbose_name_plural = "Global Views" db_table = "global_views" ordering = ("-created_at",) - + def save(self, *args, **kwargs): if self._state.adding: largest_sort_order = GlobalView.objects.filter( @@ -40,14 +84,19 @@ def __str__(self): return f"{self.name} <{self.workspace.name}>" -class IssueView(ProjectBaseModel): +class IssueView(WorkspaceBaseModel): name = models.CharField(max_length=255, verbose_name="View Name") description = models.TextField(verbose_name="View Description", blank=True) query = models.JSONField(verbose_name="View Query") + filters = models.JSONField(default=dict) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField( + default=get_default_display_properties + ) access = models.PositiveSmallIntegerField( default=1, choices=((0, "Private"), (1, "Public")) ) - query_data = models.JSONField(default=dict) + sort_order = models.FloatField(default=65535) class Meta: verbose_name = "Issue View" diff --git a/apiserver/plane/db/models/webhook.py b/apiserver/plane/db/models/webhook.py index ea2b508e54e..fbe74d03a00 100644 --- a/apiserver/plane/db/models/webhook.py +++ b/apiserver/plane/db/models/webhook.py @@ -17,7 +17,9 @@ def generate_token(): def validate_schema(value): parsed_url = urlparse(value) if parsed_url.scheme not in ["http", "https"]: - raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.") + raise ValidationError( + "Invalid schema. Only HTTP and HTTPS are allowed." + ) def validate_domain(value): @@ -63,7 +65,9 @@ class WebhookLog(BaseModel): "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" ) # Associated webhook - webhook = models.ForeignKey(Webhook, on_delete=models.CASCADE, related_name="logs") + webhook = models.ForeignKey( + Webhook, on_delete=models.CASCADE, related_name="logs" + ) # Basic request details event_type = models.CharField(max_length=255, blank=True, null=True) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 505bfbcfa63..7e5d6d90bcb 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -55,6 +55,54 @@ def get_default_props(): } +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "display_filters": { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + }, + } + + +def get_default_display_properties(): + return { + "display_properties": { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + }, + } + + def get_issue_props(): return { "subscribed": True, @@ -89,7 +137,14 @@ class Workspace(BaseModel): on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator,]) + slug = models.SlugField( + max_length=48, + db_index=True, + unique=True, + validators=[ + slug_validator, + ], + ) organization_size = models.CharField(max_length=20, blank=True, null=True) def __str__(self): @@ -103,9 +158,31 @@ class Meta: ordering = ("-created_at",) +class WorkspaceBaseModel(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" + ) + project = models.ForeignKey( + "db.Project", + models.CASCADE, + related_name="project_%(class)s", + null=True, + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + if self.project: + self.workspace = self.project.workspace + super(WorkspaceBaseModel, self).save(*args, **kwargs) + + class WorkspaceMember(BaseModel): workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_member", ) member = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -133,7 +210,9 @@ def __str__(self): class WorkspaceMemberInvite(BaseModel): workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_member_invite", ) email = models.CharField(max_length=255) accepted = models.BooleanField(default=False) @@ -183,9 +262,13 @@ class TeamMember(BaseModel): workspace = models.ForeignKey( Workspace, on_delete=models.CASCADE, related_name="team_member" ) - team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member") + team = models.ForeignKey( + Team, on_delete=models.CASCADE, related_name="team_member" + ) member = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="team_member", ) def __str__(self): @@ -205,7 +288,9 @@ class WorkspaceTheme(BaseModel): ) name = models.CharField(max_length=300) actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="themes", ) colors = models.JSONField(default=dict) @@ -218,3 +303,31 @@ class Meta: verbose_name_plural = "Workspace Themes" db_table = "workspace_themes" ordering = ("-created_at",) + + +class WorkspaceUserProperties(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_user_properties", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workspace_user_properties", + ) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField( + default=get_default_display_properties + ) + + class Meta: + unique_together = ["workspace", "user"] + verbose_name = "Workspace User Property" + verbose_name_plural = "Workspace User Property" + db_table = "Workspace_user_properties" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.workspace.name} {self.user.email}" diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py index dff16605a03..9ee85404b31 100644 --- a/apiserver/plane/license/api/permissions/instance.py +++ b/apiserver/plane/license/api/permissions/instance.py @@ -7,7 +7,6 @@ class InstanceAdminPermission(BasePermission): def has_permission(self, request, view): - if request.user.is_anonymous: return False diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py index b658ff1480d..e6beda0e9d9 100644 --- a/apiserver/plane/license/api/serializers/__init__.py +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -1 +1,5 @@ -from .instance import InstanceSerializer, InstanceAdminSerializer, InstanceConfigurationSerializer \ No newline at end of file +from .instance import ( + InstanceSerializer, + InstanceAdminSerializer, + InstanceConfigurationSerializer, +) diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py index 173d718d913..8a99acbaee3 100644 --- a/apiserver/plane/license/api/serializers/instance.py +++ b/apiserver/plane/license/api/serializers/instance.py @@ -4,8 +4,11 @@ from plane.app.serializers import UserAdminLiteSerializer from plane.license.utils.encryption import decrypt_data + class InstanceSerializer(BaseSerializer): - primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) + primary_owner_details = UserAdminLiteSerializer( + source="primary_owner", read_only=True + ) class Meta: model = Instance @@ -34,8 +37,8 @@ class Meta: "user", ] -class InstanceConfigurationSerializer(BaseSerializer): +class InstanceConfigurationSerializer(BaseSerializer): class Meta: model = InstanceConfiguration fields = "__all__" diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index c88b3b75fef..112c68bc89a 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -61,7 +61,9 @@ def get(self, request): def patch(self, request): # Get the instance instance = Instance.objects.first() - serializer = InstanceSerializer(instance, data=request.data, partial=True) + serializer = InstanceSerializer( + instance, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @@ -80,7 +82,8 @@ def post(self, request): if not email: return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Email is required"}, + status=status.HTTP_400_BAD_REQUEST, ) instance = Instance.objects.first() @@ -114,7 +117,9 @@ def get(self, request): def delete(self, request, pk): instance = Instance.objects.first() - instance_admin = InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() + instance_admin = InstanceAdmin.objects.filter( + instance=instance, pk=pk + ).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -125,7 +130,9 @@ class InstanceConfigurationEndpoint(BaseAPIView): def get(self, request): instance_configurations = InstanceConfiguration.objects.all() - serializer = InstanceConfigurationSerializer(instance_configurations, many=True) + serializer = InstanceConfigurationSerializer( + instance_configurations, many=True + ) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request): diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 67137d0d99b..f81d98cba5c 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -21,7 +21,7 @@ def handle(self, *args, **options): "key": "ENABLE_SIGNUP", "value": os.environ.get("ENABLE_SIGNUP", "1"), "category": "AUTHENTICATION", - "is_encrypted": False, + "is_encrypted": False, }, { "key": "ENABLE_EMAIL_PASSWORD", @@ -128,5 +128,7 @@ def handle(self, *args, **options): ) else: self.stdout.write( - self.style.WARNING(f"{obj.key} configuration already exists") + self.style.WARNING( + f"{obj.key} configuration already exists" + ) ) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index e6cfa716769..889cd46dc12 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -12,13 +12,15 @@ from plane.license.models import Instance from plane.db.models import User + class Command(BaseCommand): help = "Check if instance in registered else register" def add_arguments(self, parser): # Positional argument - parser.add_argument('machine_signature', type=str, help='Machine signature') - + parser.add_argument( + "machine_signature", type=str, help="Machine signature" + ) def handle(self, *args, **options): # Check if the instance is registered @@ -30,7 +32,9 @@ def handle(self, *args, **options): # Load JSON content from the file data = json.load(file) - machine_signature = options.get("machine_signature", "machine-signature") + machine_signature = options.get( + "machine_signature", "machine-signature" + ) if not machine_signature: raise CommandError("Machine signature is required") @@ -52,15 +56,9 @@ def handle(self, *args, **options): user_count=payload.get("user_count", 0), ) - self.stdout.write( - self.style.SUCCESS( - f"Instance registered" - ) - ) + self.stdout.write(self.style.SUCCESS(f"Instance registered")) else: self.stdout.write( - self.style.SUCCESS( - f"Instance already registered" - ) + self.style.SUCCESS(f"Instance already registered") ) return diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py index c8b5f1f0251..4eed3adf7b6 100644 --- a/apiserver/plane/license/migrations/0001_initial.py +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -7,7 +7,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -16,74 +15,220 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Instance', + name="Instance", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('instance_name', models.CharField(max_length=255)), - ('whitelist_emails', models.TextField(blank=True, null=True)), - ('instance_id', models.CharField(max_length=25, unique=True)), - ('license_key', models.CharField(blank=True, max_length=256, null=True)), - ('api_key', models.CharField(max_length=16)), - ('version', models.CharField(max_length=10)), - ('last_checked_at', models.DateTimeField()), - ('namespace', models.CharField(blank=True, max_length=50, null=True)), - ('is_telemetry_enabled', models.BooleanField(default=True)), - ('is_support_required', models.BooleanField(default=True)), - ('is_setup_done', models.BooleanField(default=False)), - ('is_signup_screen_visited', models.BooleanField(default=False)), - ('user_count', models.PositiveBigIntegerField(default=0)), - ('is_verified', models.BooleanField(default=False)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("instance_name", models.CharField(max_length=255)), + ("whitelist_emails", models.TextField(blank=True, null=True)), + ("instance_id", models.CharField(max_length=25, unique=True)), + ( + "license_key", + models.CharField(blank=True, max_length=256, null=True), + ), + ("api_key", models.CharField(max_length=16)), + ("version", models.CharField(max_length=10)), + ("last_checked_at", models.DateTimeField()), + ( + "namespace", + models.CharField(blank=True, max_length=50, null=True), + ), + ("is_telemetry_enabled", models.BooleanField(default=True)), + ("is_support_required", models.BooleanField(default=True)), + ("is_setup_done", models.BooleanField(default=False)), + ( + "is_signup_screen_visited", + models.BooleanField(default=False), + ), + ("user_count", models.PositiveBigIntegerField(default=0)), + ("is_verified", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Instance', - 'verbose_name_plural': 'Instances', - 'db_table': 'instances', - 'ordering': ('-created_at',), + "verbose_name": "Instance", + "verbose_name_plural": "Instances", + "db_table": "instances", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='InstanceConfiguration', + name="InstanceConfiguration", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('key', models.CharField(max_length=100, unique=True)), - ('value', models.TextField(blank=True, default=None, null=True)), - ('category', models.TextField()), - ('is_encrypted', models.BooleanField(default=False)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("key", models.CharField(max_length=100, unique=True)), + ( + "value", + models.TextField(blank=True, default=None, null=True), + ), + ("category", models.TextField()), + ("is_encrypted", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Instance Configuration', - 'verbose_name_plural': 'Instance Configurations', - 'db_table': 'instance_configurations', - 'ordering': ('-created_at',), + "verbose_name": "Instance Configuration", + "verbose_name_plural": "Instance Configurations", + "db_table": "instance_configurations", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='InstanceAdmin', + name="InstanceAdmin", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('role', models.PositiveIntegerField(choices=[(20, 'Admin')], default=20)), - ('is_verified', models.BooleanField(default=False)), - ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')), - ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_owner', to=settings.AUTH_USER_MODEL)), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "role", + models.PositiveIntegerField( + choices=[(20, "Admin")], default=20 + ), + ), + ("is_verified", models.BooleanField(default=False)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="admins", + to="license.instance", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="instance_owner", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'Instance Admin', - 'verbose_name_plural': 'Instance Admins', - 'db_table': 'instance_admins', - 'ordering': ('-created_at',), - 'unique_together': {('instance', 'user')}, + "verbose_name": "Instance Admin", + "verbose_name_plural": "Instance Admins", + "db_table": "instance_admins", + "ordering": ("-created_at",), + "unique_together": {("instance", "user")}, }, ), ] diff --git a/apiserver/plane/license/models/__init__.py b/apiserver/plane/license/models/__init__.py index 28f2c4352ed..0f35f718d74 100644 --- a/apiserver/plane/license/models/__init__.py +++ b/apiserver/plane/license/models/__init__.py @@ -1 +1 @@ -from .instance import Instance, InstanceAdmin, InstanceConfiguration \ No newline at end of file +from .instance import Instance, InstanceAdmin, InstanceConfiguration diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index 86845c34bcc..b8957e44fa4 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -5,9 +5,7 @@ # Module imports from plane.db.models import BaseModel -ROLE_CHOICES = ( - (20, "Admin"), -) +ROLE_CHOICES = ((20, "Admin"),) class Instance(BaseModel): @@ -46,7 +44,9 @@ class InstanceAdmin(BaseModel): null=True, related_name="instance_owner", ) - instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") + instance = models.ForeignKey( + Instance, on_delete=models.CASCADE, related_name="admins" + ) role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) is_verified = models.BooleanField(default=False) @@ -70,4 +70,3 @@ class Meta: verbose_name_plural = "Instance Configurations" db_table = "instance_configurations" ordering = ("-created_at",) - diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py index 807833a7e58..e6315e021da 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -10,32 +10,32 @@ urlpatterns = [ path( - "instances/", + "", InstanceEndpoint.as_view(), name="instance", ), path( - "instances/admins/", + "admins/", InstanceAdminEndpoint.as_view(), name="instance-admins", ), path( - "instances/admins//", + "admins//", InstanceAdminEndpoint.as_view(), name="instance-admins", ), path( - "instances/configurations/", + "configurations/", InstanceConfigurationEndpoint.as_view(), name="instance-configuration", ), path( - "instances/admins/sign-in/", + "admins/sign-in/", InstanceAdminSignInEndpoint.as_view(), name="instance-admin-sign-in", ), path( - "instances/admins/sign-up-screen-visited/", + "admins/sign-up-screen-visited/", SignUpScreenVisitedEndpoint.as_view(), name="instance-sign-up", ), diff --git a/apiserver/plane/license/utils/encryption.py b/apiserver/plane/license/utils/encryption.py index c2d369c2e19..11bd9000e43 100644 --- a/apiserver/plane/license/utils/encryption.py +++ b/apiserver/plane/license/utils/encryption.py @@ -6,9 +6,10 @@ def derive_key(secret_key): # Use a key derivation function to get a suitable encryption key - dk = hashlib.pbkdf2_hmac('sha256', secret_key.encode(), b'salt', 100000) + dk = hashlib.pbkdf2_hmac("sha256", secret_key.encode(), b"salt", 100000) return base64.urlsafe_b64encode(dk) + # Encrypt data def encrypt_data(data): if data: @@ -18,11 +19,14 @@ def encrypt_data(data): else: return "" -# Decrypt data + +# Decrypt data def decrypt_data(encrypted_data): if encrypted_data: cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) - decrypted_data = cipher_suite.decrypt(encrypted_data.encode()) # Convert string back to bytes + decrypted_data = cipher_suite.decrypt( + encrypted_data.encode() + ) # Convert string back to bytes return decrypted_data.decode() else: - return "" \ No newline at end of file + return "" diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py index e5652589347..bc4fd5d21f2 100644 --- a/apiserver/plane/license/utils/instance_value.py +++ b/apiserver/plane/license/utils/instance_value.py @@ -22,7 +22,9 @@ def get_configuration_value(keys): for item in instance_configuration: if key.get("key") == item.get("key"): if item.get("is_encrypted", False): - environment_list.append(decrypt_data(item.get("value"))) + environment_list.append( + decrypt_data(item.get("value")) + ) else: environment_list.append(item.get("value")) @@ -32,40 +34,41 @@ def get_configuration_value(keys): else: # Get the configuration from os for key in keys: - environment_list.append(os.environ.get(key.get("key"), key.get("default"))) + environment_list.append( + os.environ.get(key.get("key"), key.get("default")) + ) return tuple(environment_list) def get_email_configuration(): - return ( - get_configuration_value( - [ - { - "key": "EMAIL_HOST", - "default": os.environ.get("EMAIL_HOST"), - }, - { - "key": "EMAIL_HOST_USER", - "default": os.environ.get("EMAIL_HOST_USER"), - }, - { - "key": "EMAIL_HOST_PASSWORD", - "default": os.environ.get("EMAIL_HOST_PASSWORD"), - }, - { - "key": "EMAIL_PORT", - "default": os.environ.get("EMAIL_PORT", 587), - }, - { - "key": "EMAIL_USE_TLS", - "default": os.environ.get("EMAIL_USE_TLS", "1"), - }, - { - "key": "EMAIL_FROM", - "default": os.environ.get("EMAIL_FROM", "Team Plane "), - }, - ] - ) + return get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + { + "key": "EMAIL_PORT", + "default": os.environ.get("EMAIL_PORT", 587), + }, + { + "key": "EMAIL_USE_TLS", + "default": os.environ.get("EMAIL_USE_TLS", "1"), + }, + { + "key": "EMAIL_FROM", + "default": os.environ.get( + "EMAIL_FROM", "Team Plane " + ), + }, + ] ) - diff --git a/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py index a1894fad5c5..a49d43b55a1 100644 --- a/apiserver/plane/middleware/api_log_middleware.py +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -23,9 +23,13 @@ def process_request(self, request, response, request_body): method=request.method, query_params=request.META.get("QUERY_STRING", ""), headers=str(request.headers), - body=(request_body.decode('utf-8') if request_body else None), + body=( + request_body.decode("utf-8") if request_body else None + ), response_body=( - response.content.decode("utf-8") if response.content else None + response.content.decode("utf-8") + if response.content + else None ), response_code=response.status_code, ip_address=request.META.get("REMOTE_ADDR", None), diff --git a/apiserver/plane/middleware/apps.py b/apiserver/plane/middleware/apps.py index 3da4958c18c..9deac8091d3 100644 --- a/apiserver/plane/middleware/apps.py +++ b/apiserver/plane/middleware/apps.py @@ -2,4 +2,4 @@ class Middleware(AppConfig): - name = 'plane.middleware' + name = "plane.middleware" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 971ed554300..444248382ff 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -71,13 +71,19 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), - "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_PERMISSION_CLASSES": ( + "rest_framework.permissions.IsAuthenticated", + ), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), - "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), + "DEFAULT_FILTER_BACKENDS": ( + "django_filters.rest_framework.DjangoFilterBackend", + ), } # Django Auth Backend -AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default +AUTHENTICATION_BACKENDS = ( + "django.contrib.auth.backends.ModelBackend", +) # default # Root Urls ROOT_URLCONF = "plane.urls" @@ -229,9 +235,9 @@ AWS_DEFAULT_ACL = "public-read" AWS_QUERYSTRING_AUTH = False AWS_S3_FILE_OVERWRITE = False -AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get( - "MINIO_ENDPOINT_URL", None -) +AWS_S3_ENDPOINT_URL = os.environ.get( + "AWS_S3_ENDPOINT_URL", None +) or os.environ.get("MINIO_ENDPOINT_URL", None) if AWS_S3_ENDPOINT_URL: parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" @@ -274,9 +280,7 @@ if REDIS_SSL: redis_url = os.environ.get("REDIS_URL") - broker_url = ( - f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" - ) + broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" CELERY_BROKER_URL = broker_url CELERY_RESULT_BACKEND = broker_url else: @@ -287,6 +291,7 @@ "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", + "plane.bgtasks.email_notification_task", ) # Sentry Settings @@ -310,7 +315,7 @@ # Application Envs PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External -SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) + FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Unsplash Access key @@ -331,7 +336,8 @@ # instance key INSTANCE_KEY = os.environ.get( - "INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3" + "INSTANCE_KEY", + "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3", ) # Skip environment variable configuration diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py index 34ae1655528..1e2a5514424 100644 --- a/apiserver/plane/settings/test.py +++ b/apiserver/plane/settings/test.py @@ -1,9 +1,11 @@ """Test Settings""" -from .common import * # noqa +from .common import * # noqa DEBUG = True # Send it in a dummy outbox EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" -INSTALLED_APPS.append("plane.tests",) +INSTALLED_APPS.append( + "plane.tests", +) diff --git a/apiserver/plane/space/serializer/base.py b/apiserver/plane/space/serializer/base.py index 89c9725d951..4b92b06fc3d 100644 --- a/apiserver/plane/space/serializer/base.py +++ b/apiserver/plane/space/serializer/base.py @@ -4,8 +4,8 @@ class BaseSerializer(serializers.ModelSerializer): id = serializers.PrimaryKeyRelatedField(read_only=True) -class DynamicBaseSerializer(BaseSerializer): +class DynamicBaseSerializer(BaseSerializer): def __init__(self, *args, **kwargs): # If 'fields' is provided in the arguments, remove it and store it separately. # This is done so as not to pass this custom argument up to the superclass. @@ -31,7 +31,7 @@ def _filter_fields(self, fields): # loop through its keys and values. if isinstance(field_name, dict): for key, value in field_name.items(): - # If the value of this nested field is a list, + # If the value of this nested field is a list, # perform a recursive filter on it. if isinstance(value, list): self._filter_fields(self.fields[key], value) @@ -52,7 +52,7 @@ def _filter_fields(self, fields): allowed = set(allowed) # Remove fields from the serializer that aren't in the 'allowed' list. - for field_name in (existing - allowed): + for field_name in existing - allowed: self.fields.pop(field_name) return self.fields diff --git a/apiserver/plane/space/serializer/cycle.py b/apiserver/plane/space/serializer/cycle.py index ab4d9441ded..d4f5d86e076 100644 --- a/apiserver/plane/space/serializer/cycle.py +++ b/apiserver/plane/space/serializer/cycle.py @@ -4,6 +4,7 @@ Cycle, ) + class CycleBaseSerializer(BaseSerializer): class Meta: model = Cycle @@ -15,4 +16,4 @@ class Meta: "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/space/serializer/inbox.py b/apiserver/plane/space/serializer/inbox.py index 05d99ac5514..48ec7c89d72 100644 --- a/apiserver/plane/space/serializer/inbox.py +++ b/apiserver/plane/space/serializer/inbox.py @@ -36,12 +36,16 @@ class Meta: class IssueStateInboxSerializer(BaseSerializer): state_detail = StateLiteSerializer(read_only=True, source="state") project_detail = ProjectLiteSerializer(read_only=True, source="project") - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + 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) bridge_id = serializers.UUIDField(read_only=True) issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) class Meta: model = Issue - fields = "__all__" \ No newline at end of file + fields = "__all__" diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index 1a9a872efbe..c7b044b2160 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -1,4 +1,3 @@ - # Django imports from django.utils import timezone @@ -47,7 +46,9 @@ class Meta: class LabelSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + workspace_detail = WorkspaceLiteSerializer( + source="workspace", read_only=True + ) project_detail = ProjectLiteSerializer(source="project", read_only=True) class Meta: @@ -74,7 +75,9 @@ class Meta: class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + issue_detail = IssueProjectLiteSerializer( + read_only=True, source="related_issue" + ) class Meta: model = IssueRelation @@ -83,13 +86,14 @@ class Meta: "relation_type", "related_issue", "issue", - "id" + "id", ] read_only_fields = [ "workspace", "project", ] + class RelatedIssueSerializer(BaseSerializer): issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") @@ -100,7 +104,7 @@ class Meta: "relation_type", "related_issue", "issue", - "id" + "id", ] read_only_fields = [ "workspace", @@ -159,7 +163,8 @@ class Meta: # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + url=validated_data.get("url"), + issue_id=validated_data.get("issue_id"), ).exists(): raise serializers.ValidationError( {"error": "URL already exists for this Issue"} @@ -183,9 +188,8 @@ class Meta: class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -202,9 +206,15 @@ class IssueSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) - issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) + assignee_details = UserLiteSerializer( + read_only=True, source="assignees", many=True + ) + related_issues = IssueRelationSerializer( + read_only=True, source="issue_relation", many=True + ) + issue_relations = RelatedIssueSerializer( + read_only=True, source="issue_related", many=True + ) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) issue_link = IssueLinkSerializer(read_only=True, many=True) @@ -261,8 +271,12 @@ class IssueCommentSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) + comment_reactions = CommentReactionLiteSerializer( + read_only=True, many=True + ) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -285,7 +299,9 @@ class IssueCreateSerializer(BaseSerializer): state_detail = StateSerializer(read_only=True, source="state") created_by_detail = UserLiteSerializer(read_only=True, source="created_by") project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + workspace_detail = WorkspaceLiteSerializer( + read_only=True, source="workspace" + ) assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), @@ -313,8 +329,10 @@ class Meta: def to_representation(self, instance): data = super().to_representation(instance) - data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] - data['labels'] = [str(label.id) for label in instance.labels.all()] + data["assignees"] = [ + str(assignee.id) for assignee in instance.assignees.all() + ] + data["labels"] = [str(label.id) for label in instance.labels.all()] return data def validate(self, data): @@ -323,7 +341,9 @@ def validate(self, data): and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None) ): - raise serializers.ValidationError("Start date cannot exceed target date") + raise serializers.ValidationError( + "Start date cannot exceed target date" + ) return data def create(self, validated_data): @@ -432,12 +452,11 @@ def update(self, instance, validated_data): # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) - + class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -457,19 +476,27 @@ class Meta: class IssueVoteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + fields = [ + "issue", + "vote", + "workspace", + "project", + "actor", + "actor_detail", + ] read_only_fields = fields class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") - reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") + reactions = IssueReactionSerializer( + read_only=True, many=True, source="issue_reactions" + ) votes = IssueVoteSerializer(read_only=True, many=True) class Meta: @@ -500,7 +527,3 @@ class Meta: "name", "color", ] - - - - diff --git a/apiserver/plane/space/serializer/module.py b/apiserver/plane/space/serializer/module.py index 39ce9ec32af..dda1861d145 100644 --- a/apiserver/plane/space/serializer/module.py +++ b/apiserver/plane/space/serializer/module.py @@ -4,6 +4,7 @@ Module, ) + class ModuleBaseSerializer(BaseSerializer): class Meta: model = Module @@ -15,4 +16,4 @@ class Meta: "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/space/serializer/state.py b/apiserver/plane/space/serializer/state.py index 903bcc2f457..55064ed0e3d 100644 --- a/apiserver/plane/space/serializer/state.py +++ b/apiserver/plane/space/serializer/state.py @@ -6,7 +6,6 @@ class StateSerializer(BaseSerializer): - class Meta: model = State fields = "__all__" diff --git a/apiserver/plane/space/serializer/workspace.py b/apiserver/plane/space/serializer/workspace.py index ecf99079fd5..a31bb37447e 100644 --- a/apiserver/plane/space/serializer/workspace.py +++ b/apiserver/plane/space/serializer/workspace.py @@ -4,6 +4,7 @@ Workspace, ) + class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace @@ -12,4 +13,4 @@ class Meta: "slug", "id", ] - read_only_fields = fields \ No newline at end of file + read_only_fields = fields diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py index b1d749a09f4..b75f3dd1844 100644 --- a/apiserver/plane/space/views/base.py +++ b/apiserver/plane/space/views/base.py @@ -59,7 +59,9 @@ def get_queryset(self): return self.model.objects.all() except Exception as e: capture_exception(e) - raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST) + raise APIException( + "Please check the view", status.HTTP_400_BAD_REQUEST + ) def handle_exception(self, exc): """ @@ -83,23 +85,27 @@ def handle_exception(self, exc): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] + model_name = str(exc).split(" matching query does not exist.")[ + 0 + ] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): capture_exception(e) return Response( - {"error": f"key {e} does not exist"}, + {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - + print(e) if settings.DEBUG else print("Server Error") capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) def dispatch(self, request, *args, **kwargs): try: @@ -172,20 +178,24 @@ def handle_exception(self, exc): ) if isinstance(e, ObjectDoesNotExist): - model_name = str(exc).split(" matching query does not exist.")[0] return Response( - {"error": f"{model_name} does not exist."}, + {"error": f"The required object does not exist."}, status=status.HTTP_404_NOT_FOUND, ) - + if isinstance(e, KeyError): - return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "The required key does not exist."}, + status=status.HTTP_400_BAD_REQUEST, + ) if settings.DEBUG: print(e) capture_exception(e) - return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) def dispatch(self, request, *args, **kwargs): try: diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 53960f6724c..2bf8f8303fa 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -48,7 +48,8 @@ def get_queryset(self): super() .get_queryset() .filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + Q(snoozed_till__gte=timezone.now()) + | Q(snoozed_till__isnull=True), project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), inbox_id=self.kwargs.get("inbox_id"), @@ -80,7 +81,9 @@ def list(self, request, slug, project_id, inbox_id): .prefetch_related("assignees", "labels") .order_by("issue_inbox__snoozed_till", "issue_inbox__status") .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -92,7 +95,9 @@ def list(self, request, slug, project_id, inbox_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -124,7 +129,8 @@ def create(self, request, slug, project_id, inbox_id): if not request.data.get("issue", {}).get("name", False): return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Name is required"}, + status=status.HTTP_400_BAD_REQUEST, ) # Check for valid priority @@ -136,7 +142,8 @@ def create(self, request, slug, project_id, inbox_id): "none", ]: return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Invalid priority"}, + status=status.HTTP_400_BAD_REQUEST, ) # Create or get state @@ -192,7 +199,10 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): ) inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) # Get the project member if str(inbox_issue.created_by_id) != str(request.user.id): @@ -205,7 +215,9 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): issue_data = request.data.pop("issue", False) issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, ) # viewers and guests since only viewers and guests issue_data = { @@ -216,7 +228,9 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): "description": issue_data.get("description", issue.description), } - issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True) + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True + ) if issue_serializer.is_valid(): current_instance = issue @@ -237,7 +251,9 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) - return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response( + issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) def retrieve(self, request, slug, project_id, inbox_id, pk): project_deploy_board = ProjectDeployBoard.objects.get( @@ -250,10 +266,15 @@ def retrieve(self, request, slug, project_id, inbox_id, pk): ) inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) issue = Issue.objects.get( - pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, ) serializer = IssueStateInboxSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) @@ -269,7 +290,10 @@ def destroy(self, request, slug, project_id, inbox_id, pk): ) inbox_issue = InboxIssue.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + pk=pk, + workspace__slug=slug, + project_id=project_id, + inbox_id=inbox_id, ) if str(inbox_issue.created_by_id) != str(request.user.id): diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index faab8834db7..8f7fc0eaa0a 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -128,7 +128,9 @@ def create(self, request, slug, project_id, issue_id): ) issue_activity.delay( type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + serializer.data, cls=DjangoJSONEncoder + ), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), @@ -162,7 +164,9 @@ def partial_update(self, request, slug, project_id, issue_id, pk): comment = IssueComment.objects.get( workspace__slug=slug, pk=pk, actor=request.user ) - serializer = IssueCommentSerializer(comment, data=request.data, partial=True) + serializer = IssueCommentSerializer( + comment, data=request.data, partial=True + ) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -191,7 +195,10 @@ def destroy(self, request, slug, project_id, issue_id, pk): status=status.HTTP_400_BAD_REQUEST, ) comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user + workspace__slug=slug, + pk=pk, + project_id=project_id, + actor=request.user, ) issue_activity.delay( type="comment.activity.deleted", @@ -261,7 +268,9 @@ def create(self, request, slug, project_id, issue_id): ) issue_activity.delay( type="issue_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), @@ -343,7 +352,9 @@ def create(self, request, slug, project_id, comment_id): serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, comment_id=comment_id, actor=request.user + project_id=project_id, + comment_id=comment_id, + actor=request.user, ) if not ProjectMember.objects.filter( project_id=project_id, @@ -357,7 +368,9 @@ def create(self, request, slug, project_id, comment_id): ) issue_activity.delay( type="comment_reaction.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=None, project_id=str(self.kwargs.get("project_id", None)), @@ -445,7 +458,9 @@ def create(self, request, slug, project_id, issue_id): issue_vote.save() issue_activity.delay( type="issue_vote.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), @@ -507,13 +522,21 @@ def get(self, request, slug, project_id): # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -544,7 +567,9 @@ def get(self, request, slug, project_id): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id")) + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -554,7 +579,9 @@ def get(self, request, slug, project_id): # 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] + priority_order + if order_by_param == "priority" + else priority_order[::-1] ) issue_queryset = issue_queryset.annotate( priority_order=Case( @@ -602,7 +629,9 @@ def get(self, request, slug, project_id): else order_by_param ) ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" + "-max_values" + if order_by_param.startswith("-") + else "max_values" ) else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -653,4 +682,4 @@ def get(self, request, slug, project_id): "labels": labels, }, status=status.HTTP_200_OK, - ) \ No newline at end of file + ) diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py index e3209a2818b..f6843c1b662 100644 --- a/apiserver/plane/tests/api/base.py +++ b/apiserver/plane/tests/api/base.py @@ -8,7 +8,9 @@ class BaseAPITest(APITestCase): def setUp(self): - self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") + self.client = APIClient( + HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10" + ) class AuthenticatedAPITest(BaseAPITest): diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py index 51a36ba2f62..b15d32e40e4 100644 --- a/apiserver/plane/tests/api/test_asset.py +++ b/apiserver/plane/tests/api/test_asset.py @@ -1 +1 @@ -# TODO: Tests for File Asset Uploads \ No newline at end of file +# TODO: Tests for File Asset Uploads diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py index 92ad92d6e91..af6450ef435 100644 --- a/apiserver/plane/tests/api/test_auth_extended.py +++ b/apiserver/plane/tests/api/test_auth_extended.py @@ -1 +1 @@ -#TODO: Tests for ChangePassword and other Endpoints \ No newline at end of file +# TODO: Tests for ChangePassword and other Endpoints diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py index 4fc46e00880..36a0f7a2410 100644 --- a/apiserver/plane/tests/api/test_authentication.py +++ b/apiserver/plane/tests/api/test_authentication.py @@ -21,16 +21,16 @@ def setUp(self): user.save() def test_without_data(self): - url = reverse("sign-in") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_email_validity(self): - url = reverse("sign-in") response = self.client.post( - url, {"email": "useremail.com", "password": "user@123"}, format="json" + url, + {"email": "useremail.com", "password": "user@123"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( @@ -40,7 +40,9 @@ def test_email_validity(self): def test_password_validity(self): url = reverse("sign-in") response = self.client.post( - url, {"email": "user@plane.so", "password": "user123"}, format="json" + url, + {"email": "user@plane.so", "password": "user123"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual( @@ -53,7 +55,9 @@ def test_password_validity(self): def test_user_exists(self): url = reverse("sign-in") response = self.client.post( - url, {"email": "user@email.so", "password": "user123"}, format="json" + url, + {"email": "user@email.so", "password": "user123"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual( @@ -87,15 +91,15 @@ def setUp(self): user.save() def test_without_data(self): - url = reverse("magic-generate") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) def test_email_validity(self): - url = reverse("magic-generate") - response = self.client.post(url, {"email": "useremail.com"}, format="json") + response = self.client.post( + url, {"email": "useremail.com"}, format="json" + ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.data, {"error": "Please provide a valid email address."} @@ -107,7 +111,9 @@ def test_magic_generate(self): ri = redis_instance() ri.delete("magic_user@plane.so") - response = self.client.post(url, {"email": "user@plane.so"}, format="json") + response = self.client.post( + url, {"email": "user@plane.so"}, format="json" + ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_max_generate_attempt(self): @@ -131,7 +137,8 @@ def test_max_generate_attempt(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"error": "Max attempts exhausted. Please try again later."} + response.data, + {"error": "Max attempts exhausted. Please try again later."}, ) @@ -143,14 +150,14 @@ def setUp(self): user.save() def test_without_data(self): - url = reverse("magic-sign-in") response = self.client.post(url, {}, format="json") self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"error": "User token and key are required"}) + self.assertEqual( + response.data, {"error": "User token and key are required"} + ) def test_expired_invalid_magic_link(self): - ri = redis_instance() ri.delete("magic_user@plane.so") @@ -162,11 +169,11 @@ def test_expired_invalid_magic_link(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"error": "The magic code/link has expired please try again"} + response.data, + {"error": "The magic code/link has expired please try again"}, ) def test_invalid_magic_code(self): - ri = redis_instance() ri.delete("magic_user@plane.so") ## Create Token @@ -181,11 +188,11 @@ def test_invalid_magic_code(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"error": "Your login code was incorrect. Please try again."} + response.data, + {"error": "Your login code was incorrect. Please try again."}, ) def test_magic_code_sign_in(self): - ri = redis_instance() ri.delete("magic_user@plane.so") ## Create Token diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py index 04c2d6ba263..72b580c99bb 100644 --- a/apiserver/plane/tests/api/test_cycle.py +++ b/apiserver/plane/tests/api/test_cycle.py @@ -1 +1 @@ -# TODO: Write Test for Cycle Endpoints \ No newline at end of file +# TODO: Write Test for Cycle Endpoints diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py index 3e59613e01c..a45ff36b1d1 100644 --- a/apiserver/plane/tests/api/test_issue.py +++ b/apiserver/plane/tests/api/test_issue.py @@ -1 +1 @@ -# TODO: Write Test for Issue Endpoints \ No newline at end of file +# TODO: Write Test for Issue Endpoints diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py index e70e4fccb25..1e7dac0ef34 100644 --- a/apiserver/plane/tests/api/test_oauth.py +++ b/apiserver/plane/tests/api/test_oauth.py @@ -1 +1 @@ -#TODO: Tests for OAuth Authentication Endpoint \ No newline at end of file +# TODO: Tests for OAuth Authentication Endpoint diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py index c4750f9b8e9..624281a2ff1 100644 --- a/apiserver/plane/tests/api/test_people.py +++ b/apiserver/plane/tests/api/test_people.py @@ -1 +1 @@ -# TODO: Write Test for people Endpoint \ No newline at end of file +# TODO: Write Test for people Endpoint diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py index 49dae5581eb..9a7c50f1943 100644 --- a/apiserver/plane/tests/api/test_project.py +++ b/apiserver/plane/tests/api/test_project.py @@ -1 +1 @@ -# TODO: Write Tests for project endpoints \ No newline at end of file +# TODO: Write Tests for project endpoints diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py index 2e939af70d7..5103b505943 100644 --- a/apiserver/plane/tests/api/test_shortcut.py +++ b/apiserver/plane/tests/api/test_shortcut.py @@ -1 +1 @@ -# TODO: Write Test for shortcuts \ No newline at end of file +# TODO: Write Test for shortcuts diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py index ef9631bc2e5..a336d955af1 100644 --- a/apiserver/plane/tests/api/test_state.py +++ b/apiserver/plane/tests/api/test_state.py @@ -1 +1 @@ -# TODO: Wrote test for state endpoints \ No newline at end of file +# TODO: Wrote test for state endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py index a1da2997a11..c1e487fbed5 100644 --- a/apiserver/plane/tests/api/test_workspace.py +++ b/apiserver/plane/tests/api/test_workspace.py @@ -14,7 +14,6 @@ def setUp(self): super().setUp() def test_create_workspace(self): - url = reverse("workspace") # Test with empty data @@ -32,7 +31,9 @@ def test_create_workspace(self): # Check other values workspace = Workspace.objects.get(pk=response.data["id"]) - workspace_member = WorkspaceMember.objects.get(workspace=workspace, member_id=self.user_id) + workspace_member = WorkspaceMember.objects.get( + workspace=workspace, member_id=self.user_id + ) self.assertEqual(workspace.owner_id, self.user_id) self.assertEqual(workspace_member.role, 20) diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index e437da0787f..669f3ea73de 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -12,7 +12,7 @@ path("", TemplateView.as_view(template_name="index.html")), path("api/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), - path("api/licenses/", include("plane.license.urls")), + path("api/instances/", include("plane.license.urls")), path("api/v1/", include("plane.api.urls")), path("", include("plane.web.urls")), ] diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index be52bcce445..948eb1b9162 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -4,9 +4,15 @@ # Django import from django.db import models +from django.utils import timezone from django.db.models.functions import TruncDate from django.db.models import Count, F, Sum, Value, Case, When, CharField -from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat +from django.db.models.functions import ( + Coalesce, + ExtractMonth, + ExtractYear, + Concat, +) # Module imports from plane.db.models import Issue @@ -21,14 +27,18 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute): # Annotate the dimension return queryset.annotate(**{attribute: dimension}) + def extract_axis(queryset, x_axis): # Format the dimension when the axis is in date if x_axis in ["created_at", "start_date", "target_date", "completed_at"]: - queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension") + queryset = annotate_with_monthly_dimension( + queryset, x_axis, "dimension" + ) return queryset, "dimension" else: return queryset.annotate(dimension=F(x_axis)), "dimension" + def sort_data(data, temp_axis): # When the axis is in priority order by if temp_axis == "priority": @@ -37,6 +47,7 @@ def sort_data(data, temp_axis): else: return dict(sorted(data.items(), key=lambda x: (x[0] == "none", x[0]))) + def build_graph_plot(queryset, x_axis, y_axis, segment=None): # temp x_axis temp_axis = x_axis @@ -45,9 +56,11 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): if x_axis == "dimension": queryset = queryset.exclude(dimension__isnull=True) - # + # if segment in ["created_at", "start_date", "target_date", "completed_at"]: - queryset = annotate_with_monthly_dimension(queryset, segment, "segmented") + queryset = annotate_with_monthly_dimension( + queryset, segment, "segmented" + ) segment = "segmented" queryset = queryset.values(x_axis) @@ -62,21 +75,41 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): ), dimension_ex=Coalesce("dimension", Value("null")), ).values("dimension") - queryset = queryset.annotate(segment=F(segment)) if segment else queryset - queryset = queryset.values("dimension", "segment") if segment else queryset.values("dimension") + queryset = ( + queryset.annotate(segment=F(segment)) if segment else queryset + ) + queryset = ( + queryset.values("dimension", "segment") + if segment + else queryset.values("dimension") + ) queryset = queryset.annotate(count=Count("*")).order_by("dimension") # Estimate else: - queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(x_axis) - queryset = queryset.annotate(segment=F(segment)) if segment else queryset - queryset = queryset.values("dimension", "segment", "estimate") if segment else queryset.values("dimension", "estimate") + queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by( + x_axis + ) + queryset = ( + queryset.annotate(segment=F(segment)) if segment else queryset + ) + queryset = ( + queryset.values("dimension", "segment", "estimate") + if segment + else queryset.values("dimension", "estimate") + ) result_values = list(queryset) - grouped_data = {str(key): list(items) for key, items in groupby(result_values, key=lambda x: x[str("dimension")])} + grouped_data = { + str(key): list(items) + for key, items in groupby( + result_values, key=lambda x: x[str("dimension")] + ) + } return sort_data(grouped_data, temp_axis) + def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): # Total Issues in Cycle or Module total_issues = queryset.total_issues @@ -107,7 +140,9 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): # Get all dates between the two dates date_range = [ queryset.start_date + timedelta(days=x) - for x in range((queryset.target_date - queryset.start_date).days + 1) + for x in range( + (queryset.target_date - queryset.start_date).days + 1 + ) ] chart_data = {str(date): 0 for date in date_range} @@ -134,6 +169,9 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): if item["date"] is not None and item["date"] <= date ) cumulative_pending_issues -= total_completed - chart_data[str(date)] = cumulative_pending_issues + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues return chart_data diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 853874b3178..edc7adc15b9 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -40,77 +40,144 @@ def group_results(results_data, group_by, sub_group_by=False): for value in results_data: main_group_attribute = resolve_keys(sub_group_by, value) group_attribute = resolve_keys(group_by, value) - if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list): + if isinstance(main_group_attribute, list) and not isinstance( + group_attribute, list + ): if len(main_group_attribute): for attrib in main_group_attribute: if str(attrib) not in main_responsive_dict: main_responsive_dict[str(attrib)] = {} - if str(group_attribute) in main_responsive_dict[str(attrib)]: - main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + if ( + str(group_attribute) + in main_responsive_dict[str(attrib)] + ): + main_responsive_dict[str(attrib)][ + str(group_attribute) + ].append(value) else: - main_responsive_dict[str(attrib)][str(group_attribute)] = [] - main_responsive_dict[str(attrib)][str(group_attribute)].append(value) + main_responsive_dict[str(attrib)][ + str(group_attribute) + ] = [] + main_responsive_dict[str(attrib)][ + str(group_attribute) + ].append(value) else: if str(None) not in main_responsive_dict: main_responsive_dict[str(None)] = {} if str(group_attribute) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(group_attribute)].append(value) + main_responsive_dict[str(None)][ + str(group_attribute) + ].append(value) else: - main_responsive_dict[str(None)][str(group_attribute)] = [] - main_responsive_dict[str(None)][str(group_attribute)].append(value) - - elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list): + main_responsive_dict[str(None)][ + str(group_attribute) + ] = [] + main_responsive_dict[str(None)][ + str(group_attribute) + ].append(value) + + elif isinstance(group_attribute, list) and not isinstance( + main_group_attribute, list + ): if str(main_group_attribute) not in main_responsive_dict: main_responsive_dict[str(main_group_attribute)] = {} if len(group_attribute): for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(main_group_attribute)]: - main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + if ( + str(attrib) + in main_responsive_dict[str(main_group_attribute)] + ): + main_responsive_dict[str(main_group_attribute)][ + str(attrib) + ].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(attrib)] = [] - main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value) + main_responsive_dict[str(main_group_attribute)][ + str(attrib) + ] = [] + main_responsive_dict[str(main_group_attribute)][ + str(attrib) + ].append(value) else: - if str(None) in main_responsive_dict[str(main_group_attribute)]: - main_responsive_dict[str(main_group_attribute)][str(None)].append(value) + if ( + str(None) + in main_responsive_dict[str(main_group_attribute)] + ): + main_responsive_dict[str(main_group_attribute)][ + str(None) + ].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(None)] = [] - main_responsive_dict[str(main_group_attribute)][str(None)].append(value) - - elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list): + main_responsive_dict[str(main_group_attribute)][ + str(None) + ] = [] + main_responsive_dict[str(main_group_attribute)][ + str(None) + ].append(value) + + elif isinstance(group_attribute, list) and isinstance( + main_group_attribute, list + ): if len(main_group_attribute): for main_attrib in main_group_attribute: if str(main_attrib) not in main_responsive_dict: main_responsive_dict[str(main_attrib)] = {} if len(group_attribute): for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(main_attrib)]: - main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + if ( + str(attrib) + in main_responsive_dict[str(main_attrib)] + ): + main_responsive_dict[str(main_attrib)][ + str(attrib) + ].append(value) else: - main_responsive_dict[str(main_attrib)][str(attrib)] = [] - main_responsive_dict[str(main_attrib)][str(attrib)].append(value) + main_responsive_dict[str(main_attrib)][ + str(attrib) + ] = [] + main_responsive_dict[str(main_attrib)][ + str(attrib) + ].append(value) else: - if str(None) in main_responsive_dict[str(main_attrib)]: - main_responsive_dict[str(main_attrib)][str(None)].append(value) + if ( + str(None) + in main_responsive_dict[str(main_attrib)] + ): + main_responsive_dict[str(main_attrib)][ + str(None) + ].append(value) else: - main_responsive_dict[str(main_attrib)][str(None)] = [] - main_responsive_dict[str(main_attrib)][str(None)].append(value) + main_responsive_dict[str(main_attrib)][ + str(None) + ] = [] + main_responsive_dict[str(main_attrib)][ + str(None) + ].append(value) else: if str(None) not in main_responsive_dict: main_responsive_dict[str(None)] = {} if len(group_attribute): for attrib in group_attribute: if str(attrib) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(attrib)].append(value) + main_responsive_dict[str(None)][ + str(attrib) + ].append(value) else: - main_responsive_dict[str(None)][str(attrib)] = [] - main_responsive_dict[str(None)][str(attrib)].append(value) + main_responsive_dict[str(None)][ + str(attrib) + ] = [] + main_responsive_dict[str(None)][ + str(attrib) + ].append(value) else: if str(None) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(None)].append(value) + main_responsive_dict[str(None)][str(None)].append( + value + ) else: main_responsive_dict[str(None)][str(None)] = [] - main_responsive_dict[str(None)][str(None)].append(value) + main_responsive_dict[str(None)][str(None)].append( + value + ) else: main_group_attribute = resolve_keys(sub_group_by, value) group_attribute = resolve_keys(group_by, value) @@ -118,13 +185,22 @@ def group_results(results_data, group_by, sub_group_by=False): if str(main_group_attribute) not in main_responsive_dict: main_responsive_dict[str(main_group_attribute)] = {} - if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]: - main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) + if ( + str(group_attribute) + in main_responsive_dict[str(main_group_attribute)] + ): + main_responsive_dict[str(main_group_attribute)][ + str(group_attribute) + ].append(value) else: - main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = [] - main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value) - - return main_responsive_dict + main_responsive_dict[str(main_group_attribute)][ + str(group_attribute) + ] = [] + main_responsive_dict[str(main_group_attribute)][ + str(group_attribute) + ].append(value) + + return main_responsive_dict else: response_dict = {} diff --git a/apiserver/plane/utils/html_processor.py b/apiserver/plane/utils/html_processor.py index 5f61607e9fb..18d103b6455 100644 --- a/apiserver/plane/utils/html_processor.py +++ b/apiserver/plane/utils/html_processor.py @@ -1,15 +1,17 @@ from io import StringIO from html.parser import HTMLParser + class MLStripper(HTMLParser): """ Markup Language Stripper """ + def __init__(self): super().__init__() self.reset() self.strict = False - self.convert_charrefs= True + self.convert_charrefs = True self.text = StringIO() def handle_data(self, d): @@ -18,6 +20,7 @@ def handle_data(self, d): def get_data(self): return self.text.getvalue() + def strip_tags(html): s = MLStripper() s.feed(html) diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py index b427ba14f11..6f3a7c21783 100644 --- a/apiserver/plane/utils/importers/jira.py +++ b/apiserver/plane/utils/importers/jira.py @@ -1,35 +1,97 @@ import requests +import re from requests.auth import HTTPBasicAuth from sentry_sdk import capture_exception +from urllib.parse import urlparse, urljoin + + +def is_allowed_hostname(hostname): + allowed_domains = [ + "atl-paas.net", + "atlassian.com", + "atlassian.net", + "jira.com", + ] + parsed_uri = urlparse(f"https://{hostname}") + domain = parsed_uri.netloc.split(":")[0] # Ensures no port is included + base_domain = ".".join(domain.split(".")[-2:]) + return base_domain in allowed_domains + + +def is_valid_project_key(project_key): + if project_key: + project_key = project_key.strip().upper() + # Adjust the regular expression as needed based on your specific requirements. + if len(project_key) > 30: + return False + # Check the validity of the key as well + pattern = re.compile(r"^[A-Z0-9]{1,10}$") + return pattern.match(project_key) is not None + else: + False + + +def generate_valid_project_key(project_key): + return project_key.strip().upper() + + +def generate_url(hostname, path): + if not is_allowed_hostname(hostname): + raise ValueError("Invalid or unauthorized hostname") + return urljoin(f"https://{hostname}", path) def jira_project_issue_summary(email, api_token, project_key, hostname): try: + if not is_allowed_hostname(hostname): + return {"error": "Invalid or unauthorized hostname"} + + if not is_valid_project_key(project_key): + return {"error": "Invalid project key"} + auth = HTTPBasicAuth(email, api_token) headers = {"Accept": "application/json"} - issue_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Story" + # make the project key upper case + project_key = generate_valid_project_key(project_key) + + # issues + issue_url = generate_url( + hostname, + f"/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic", + ) issue_response = requests.request( "GET", issue_url, headers=headers, auth=auth ).json()["total"] - module_url = f"https://{hostname}/rest/api/3/search?jql=project={project_key} AND issuetype=Epic" + # modules + module_url = generate_url( + hostname, + f"/rest/api/3/search?jql=project={project_key} AND issuetype=Epic", + ) module_response = requests.request( "GET", module_url, headers=headers, auth=auth ).json()["total"] - status_url = f"https://{hostname}/rest/api/3/status/?jql=project={project_key}" + # status + status_url = generate_url( + hostname, f"/rest/api/3/project/${project_key}/statuses" + ) status_response = requests.request( "GET", status_url, headers=headers, auth=auth ).json() - labels_url = f"https://{hostname}/rest/api/3/label/?jql=project={project_key}" + # labels + labels_url = generate_url( + hostname, f"/rest/api/3/label/?jql=project={project_key}" + ) labels_response = requests.request( "GET", labels_url, headers=headers, auth=auth ).json()["total"] - users_url = ( - f"https://{hostname}/rest/api/3/users/search?jql=project={project_key}" + # users + users_url = generate_url( + hostname, f"/rest/api/3/users/search?jql=project={project_key}" ) users_response = requests.request( "GET", users_url, headers=headers, auth=auth @@ -50,4 +112,6 @@ def jira_project_issue_summary(email, api_token, project_key, hostname): } except Exception as e: capture_exception(e) - return {"error": "Something went wrong could not fetch information from jira"} + return { + "error": "Something went wrong could not fetch information from jira" + } diff --git a/apiserver/plane/utils/imports.py b/apiserver/plane/utils/imports.py index 5f9f1c98c56..89753ef1d45 100644 --- a/apiserver/plane/utils/imports.py +++ b/apiserver/plane/utils/imports.py @@ -8,13 +8,12 @@ def import_submodules(context, root_module, path): >>> import_submodules(locals(), __name__, __path__) """ for loader, module_name, is_pkg in pkgutil.walk_packages( - path, - root_module + - '.'): + path, root_module + "." + ): # this causes a Runtime error with model conflicts # module = loader.find_module(module_name).load_module(module_name) - module = __import__(module_name, globals(), locals(), ['__name__']) + module = __import__(module_name, globals(), locals(), ["__name__"]) for k, v in six.iteritems(vars(module)): - if not k.startswith('_'): + if not k.startswith("_"): context[k] = v context[module_name] = module diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py index 45cb5925a4d..5a7ce2aa29f 100644 --- a/apiserver/plane/utils/integrations/github.py +++ b/apiserver/plane/utils/integrations/github.py @@ -10,7 +10,9 @@ def get_jwt_token(): app_id = os.environ.get("GITHUB_APP_ID", "") - secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8") + secret = bytes( + os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8" + ) current_timestamp = int(datetime.now().timestamp()) due_date = datetime.now() + timedelta(minutes=10) expiry = int(due_date.timestamp()) diff --git a/apiserver/plane/utils/integrations/slack.py b/apiserver/plane/utils/integrations/slack.py index 70f26e16091..0cc5b93b27e 100644 --- a/apiserver/plane/utils/integrations/slack.py +++ b/apiserver/plane/utils/integrations/slack.py @@ -1,6 +1,7 @@ import os import requests + def slack_oauth(code): SLACK_OAUTH_URL = os.environ.get("SLACK_OAUTH_URL", False) SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", False) diff --git a/apiserver/plane/utils/ip_address.py b/apiserver/plane/utils/ip_address.py index 06ca4353d64..01789c431ef 100644 --- a/apiserver/plane/utils/ip_address.py +++ b/apiserver/plane/utils/ip_address.py @@ -1,7 +1,7 @@ def get_client_ip(request): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: - ip = x_forwarded_for.split(',')[0] + ip = x_forwarded_for.split(",")[0] else: - ip = request.META.get('REMOTE_ADDR') + ip = request.META.get("REMOTE_ADDR") return ip diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 2da24092a27..87284ff24e0 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -3,10 +3,10 @@ from datetime import timedelta from django.utils import timezone - # The date from pattern pattern = re.compile(r"\d+_(weeks|months)$") + # check the valid uuids def filter_valid_uuids(uuid_list): valid_uuids = [] @@ -21,19 +21,29 @@ def filter_valid_uuids(uuid_list): # Get the 2_weeks, 3_months -def string_date_filter(filter, duration, subsequent, term, date_filter, offset): +def string_date_filter( + filter, duration, subsequent, term, date_filter, offset +): now = timezone.now().date() if term == "months": if subsequent == "after": if offset == "fromnow": - filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30) + filter[f"{date_filter}__gte"] = now + timedelta( + days=duration * 30 + ) else: - filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30) + filter[f"{date_filter}__gte"] = now - timedelta( + days=duration * 30 + ) else: if offset == "fromnow": - filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30) + filter[f"{date_filter}__lte"] = now + timedelta( + days=duration * 30 + ) else: - filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30) + filter[f"{date_filter}__lte"] = now - timedelta( + days=duration * 30 + ) if term == "weeks": if subsequent == "after": if offset == "fromnow": @@ -49,7 +59,7 @@ def string_date_filter(filter, duration, subsequent, term, date_filter, offset): def date_filter(filter, date_term, queries): """ - Handle all date filters + Handle all date filters """ for query in queries: date_query = query.split(";") @@ -75,41 +85,67 @@ def date_filter(filter, date_term, queries): def filter_state(params, filter, method): if method == "GET": - states = [item for item in params.get("state").split(",") if item != 'null'] + states = [ + item for item in params.get("state").split(",") if item != "null" + ] states = filter_valid_uuids(states) if len(states) and "" not in states: filter["state__in"] = states else: - if params.get("state", None) and len(params.get("state")) and params.get("state") != 'null': + if ( + params.get("state", None) + and len(params.get("state")) + and params.get("state") != "null" + ): filter["state__in"] = params.get("state") return filter def filter_state_group(params, filter, method): if method == "GET": - state_group = [item for item in params.get("state_group").split(",") if item != 'null'] + state_group = [ + item + for item in params.get("state_group").split(",") + if item != "null" + ] if len(state_group) and "" not in state_group: filter["state__group__in"] = state_group else: - if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != 'null': + if ( + params.get("state_group", None) + and len(params.get("state_group")) + and params.get("state_group") != "null" + ): filter["state__group__in"] = params.get("state_group") return filter def filter_estimate_point(params, filter, method): if method == "GET": - estimate_points = [item for item in params.get("estimate_point").split(",") if item != 'null'] + estimate_points = [ + item + for item in params.get("estimate_point").split(",") + if item != "null" + ] if len(estimate_points) and "" not in estimate_points: filter["estimate_point__in"] = estimate_points else: - if params.get("estimate_point", None) and len(params.get("estimate_point")) and params.get("estimate_point") != 'null': + if ( + params.get("estimate_point", None) + and len(params.get("estimate_point")) + and params.get("estimate_point") != "null" + ): filter["estimate_point__in"] = params.get("estimate_point") return filter def filter_priority(params, filter, method): if method == "GET": - priorities = [item for item in params.get("priority").split(",") if item != 'null'] + priorities = [ + item + for item in params.get("priority").split(",") + if item != "null" + ] if len(priorities) and "" not in priorities: filter["priority__in"] = priorities return filter @@ -117,59 +153,96 @@ def filter_priority(params, filter, method): def filter_parent(params, filter, method): if method == "GET": - parents = [item for item in params.get("parent").split(",") if item != 'null'] + parents = [ + item for item in params.get("parent").split(",") if item != "null" + ] parents = filter_valid_uuids(parents) if len(parents) and "" not in parents: filter["parent__in"] = parents else: - if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != 'null': + if ( + params.get("parent", None) + and len(params.get("parent")) + and params.get("parent") != "null" + ): filter["parent__in"] = params.get("parent") return filter def filter_labels(params, filter, method): if method == "GET": - labels = [item for item in params.get("labels").split(",") if item != 'null'] + labels = [ + item for item in params.get("labels").split(",") if item != "null" + ] labels = filter_valid_uuids(labels) if len(labels) and "" not in labels: filter["labels__in"] = labels else: - if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != 'null': + if ( + params.get("labels", None) + and len(params.get("labels")) + and params.get("labels") != "null" + ): filter["labels__in"] = params.get("labels") return filter def filter_assignees(params, filter, method): if method == "GET": - assignees = [item for item in params.get("assignees").split(",") if item != 'null'] + assignees = [ + item + for item in params.get("assignees").split(",") + if item != "null" + ] assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: filter["assignees__in"] = assignees else: - if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != 'null': + if ( + params.get("assignees", None) + and len(params.get("assignees")) + and params.get("assignees") != "null" + ): filter["assignees__in"] = params.get("assignees") return filter + def filter_mentions(params, filter, method): if method == "GET": - mentions = [item for item in params.get("mentions").split(",") if item != 'null'] + mentions = [ + item + for item in params.get("mentions").split(",") + if item != "null" + ] mentions = filter_valid_uuids(mentions) if len(mentions) and "" not in mentions: filter["issue_mention__mention__id__in"] = mentions else: - if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null': + if ( + params.get("mentions", None) + and len(params.get("mentions")) + and params.get("mentions") != "null" + ): filter["issue_mention__mention__id__in"] = params.get("mentions") return filter def filter_created_by(params, filter, method): if method == "GET": - created_bys = [item for item in params.get("created_by").split(",") if item != 'null'] + created_bys = [ + item + for item in params.get("created_by").split(",") + if item != "null" + ] created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: filter["created_by__in"] = created_bys else: - if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != 'null': + if ( + params.get("created_by", None) + and len(params.get("created_by")) + and params.get("created_by") != "null" + ): filter["created_by__in"] = params.get("created_by") return filter @@ -184,10 +257,18 @@ def filter_created_at(params, filter, method): if method == "GET": created_ats = params.get("created_at").split(",") if len(created_ats) and "" not in created_ats: - date_filter(filter=filter, date_term="created_at__date", queries=created_ats) + date_filter( + filter=filter, + date_term="created_at__date", + queries=created_ats, + ) else: if params.get("created_at", None) and len(params.get("created_at")): - date_filter(filter=filter, date_term="created_at__date", queries=params.get("created_at", [])) + date_filter( + filter=filter, + date_term="created_at__date", + queries=params.get("created_at", []), + ) return filter @@ -195,10 +276,18 @@ def filter_updated_at(params, filter, method): if method == "GET": updated_ats = params.get("updated_at").split(",") if len(updated_ats) and "" not in updated_ats: - date_filter(filter=filter, date_term="created_at__date", queries=updated_ats) + date_filter( + filter=filter, + date_term="created_at__date", + queries=updated_ats, + ) else: if params.get("updated_at", None) and len(params.get("updated_at")): - date_filter(filter=filter, date_term="created_at__date", queries=params.get("updated_at", [])) + date_filter( + filter=filter, + date_term="created_at__date", + queries=params.get("updated_at", []), + ) return filter @@ -206,7 +295,9 @@ def filter_start_date(params, filter, method): if method == "GET": start_dates = params.get("start_date").split(",") if len(start_dates) and "" not in start_dates: - date_filter(filter=filter, date_term="start_date", queries=start_dates) + date_filter( + filter=filter, date_term="start_date", queries=start_dates + ) else: if params.get("start_date", None) and len(params.get("start_date")): filter["start_date"] = params.get("start_date") @@ -217,7 +308,9 @@ def filter_target_date(params, filter, method): if method == "GET": target_dates = params.get("target_date").split(",") if len(target_dates) and "" not in target_dates: - date_filter(filter=filter, date_term="target_date", queries=target_dates) + date_filter( + filter=filter, date_term="target_date", queries=target_dates + ) else: if params.get("target_date", None) and len(params.get("target_date")): filter["target_date"] = params.get("target_date") @@ -228,10 +321,20 @@ def filter_completed_at(params, filter, method): if method == "GET": completed_ats = params.get("completed_at").split(",") if len(completed_ats) and "" not in completed_ats: - date_filter(filter=filter, date_term="completed_at__date", queries=completed_ats) + date_filter( + filter=filter, + date_term="completed_at__date", + queries=completed_ats, + ) else: - if params.get("completed_at", None) and len(params.get("completed_at")): - date_filter(filter=filter, date_term="completed_at__date", queries=params.get("completed_at", [])) + if params.get("completed_at", None) and len( + params.get("completed_at") + ): + date_filter( + filter=filter, + date_term="completed_at__date", + queries=params.get("completed_at", []), + ) return filter @@ -249,47 +352,73 @@ def filter_issue_state_type(params, filter, method): def filter_project(params, filter, method): if method == "GET": - projects = [item for item in params.get("project").split(",") if item != 'null'] + projects = [ + item for item in params.get("project").split(",") if item != "null" + ] projects = filter_valid_uuids(projects) if len(projects) and "" not in projects: filter["project__in"] = projects else: - if params.get("project", None) and len(params.get("project")) and params.get("project") != 'null': + if ( + params.get("project", None) + and len(params.get("project")) + and params.get("project") != "null" + ): filter["project__in"] = params.get("project") return filter def filter_cycle(params, filter, method): if method == "GET": - cycles = [item for item in params.get("cycle").split(",") if item != 'null'] + cycles = [ + item for item in params.get("cycle").split(",") if item != "null" + ] cycles = filter_valid_uuids(cycles) if len(cycles) and "" not in cycles: filter["issue_cycle__cycle_id__in"] = cycles else: - if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != 'null': + if ( + params.get("cycle", None) + and len(params.get("cycle")) + and params.get("cycle") != "null" + ): filter["issue_cycle__cycle_id__in"] = params.get("cycle") return filter def filter_module(params, filter, method): if method == "GET": - modules = [item for item in params.get("module").split(",") if item != 'null'] + modules = [ + item for item in params.get("module").split(",") if item != "null" + ] modules = filter_valid_uuids(modules) if len(modules) and "" not in modules: filter["issue_module__module_id__in"] = modules else: - if params.get("module", None) and len(params.get("module")) and params.get("module") != 'null': + if ( + params.get("module", None) + and len(params.get("module")) + and params.get("module") != "null" + ): filter["issue_module__module_id__in"] = params.get("module") return filter def filter_inbox_status(params, filter, method): if method == "GET": - status = [item for item in params.get("inbox_status").split(",") if item != 'null'] + status = [ + item + for item in params.get("inbox_status").split(",") + if item != "null" + ] if len(status) and "" not in status: filter["issue_inbox__status__in"] = status else: - if params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != 'null': + if ( + params.get("inbox_status", None) + and len(params.get("inbox_status")) + and params.get("inbox_status") != "null" + ): filter["issue_inbox__status__in"] = params.get("inbox_status") return filter @@ -308,13 +437,23 @@ def filter_sub_issue_toggle(params, filter, method): def filter_subscribed_issues(params, filter, method): if method == "GET": - subscribers = [item for item in params.get("subscriber").split(",") if item != 'null'] + subscribers = [ + item + for item in params.get("subscriber").split(",") + if item != "null" + ] subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: filter["issue_subscribers__subscriber_id__in"] = subscribers else: - if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != 'null': - filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber") + if ( + params.get("subscriber", None) + and len(params.get("subscriber")) + and params.get("subscriber") != "null" + ): + filter["issue_subscribers__subscriber_id__in"] = params.get( + "subscriber" + ) return filter @@ -324,7 +463,7 @@ def filter_start_target_date_issues(params, filter, method): filter["target_date__isnull"] = False filter["start_date__isnull"] = False return filter - + def issue_filters(query_params, method): filter = {} diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index 40f85dde449..d38b1f4c32a 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -12,8 +12,8 @@ def search_issues(query, queryset): fields = ["name", "sequence_id"] q = Q() for field in fields: - if field == "sequence_id": - sequences = re.findall(r"\d+\.\d+|\d+", query) + if field == "sequence_id" and len(query) <= 20: + sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query) for sequence_id in sequences: q |= Q(**{"sequence_id": sequence_id}) else: diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 793614cc08b..6b2b49c15f0 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -31,8 +31,10 @@ def from_string(cls, value): try: bits = value.split(":") if len(bits) != 3: - raise ValueError("Cursor must be in the format 'value:offset:is_prev'") - + raise ValueError( + "Cursor must be in the format 'value:offset:is_prev'" + ) + value = float(bits[0]) if "." in bits[0] else int(bits[0]) return cls(value, int(bits[1]), bool(int(bits[2]))) except (TypeError, ValueError) as e: @@ -178,7 +180,9 @@ def paginate( input_cursor = None if request.GET.get(self.cursor_name): try: - input_cursor = cursor_cls.from_string(request.GET.get(self.cursor_name)) + input_cursor = cursor_cls.from_string( + request.GET.get(self.cursor_name) + ) except ValueError: raise ParseError(detail="Invalid cursor parameter.") @@ -186,9 +190,11 @@ def paginate( paginator = paginator_cls(**paginator_kwargs) try: - cursor_result = paginator.get_result(limit=per_page, cursor=input_cursor) + cursor_result = paginator.get_result( + limit=per_page, cursor=input_cursor + ) except BadPaginationError as e: - raise ParseError(detail=str(e)) + raise ParseError(detail="Error in parsing") # Serialize result according to the on_result function if on_results: diff --git a/apiserver/plane/web/apps.py b/apiserver/plane/web/apps.py index 76ca3c4e6ad..a5861f9b5ff 100644 --- a/apiserver/plane/web/apps.py +++ b/apiserver/plane/web/apps.py @@ -2,4 +2,4 @@ class WebConfig(AppConfig): - name = 'plane.web' + name = "plane.web" diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py index 568b9903750..24a3e7b575f 100644 --- a/apiserver/plane/web/urls.py +++ b/apiserver/plane/web/urls.py @@ -2,6 +2,5 @@ from django.views.generic import TemplateView urlpatterns = [ - path('about/', TemplateView.as_view(template_name='about.html')) - + path("about/", TemplateView.as_view(template_name="about.html")) ] diff --git a/apiserver/plane/wsgi.py b/apiserver/plane/wsgi.py index ef3ea27809f..b3051f9ff7b 100644 --- a/apiserver/plane/wsgi.py +++ b/apiserver/plane/wsgi.py @@ -9,7 +9,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', - 'plane.settings.production') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") application = get_wsgi_application() diff --git a/apiserver/pyproject.toml b/apiserver/pyproject.toml new file mode 100644 index 00000000000..773d6090e47 --- /dev/null +++ b/apiserver/pyproject.toml @@ -0,0 +1,18 @@ +[tool.black] +line-length = 79 +target-version = ['py36'] +include = '\.pyi?$' +exclude = ''' + /( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | venv + )/ +''' diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index dfe813b8606..d45f665dee8 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.6 \ No newline at end of file +python-3.11.7 \ No newline at end of file diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html new file mode 100644 index 00000000000..fa50631c557 --- /dev/null +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -0,0 +1,1127 @@ + + + + + + Updates on issue + + + + + +
+ +
+ + + + +
+
+ +
+
+
+ +
+
+ + + + +
+

+ {{ issue.issue_identifier }} updates +

+

+ {{workspace}}/{{project}}/{{issue.issue_identifier}}: {{ issue.name }} +

+
+
+ {% if actors_involved == 1 %} +

+ {{summary}} + + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + . +

+ {% else %} +

+ {{summary}} + + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name }} + and others. +

+ {% endif %} + + + + {% for update in data %} {% if update.changes.name %} + +

+ The issue title has been updated to {{ issue.name}} +

+ {% endif %} + + {% if data %} +
+ +
+

+ Updates +

+
+ +
+ + + + + + + +
+ {% if update.actor_detail.avatar_url %} + + {% else %} + + + + +
+ + {{ update.actor_detail.first_name.0 }} + +
+ {% endif %} +
+

+ {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} +

+
+

+ {{ update.activity_time }} +

+
+ {% if update.changes.target_date %} + + + + + + + +
+ + +
+

+ Due Date: +

+
+
+ {% if update.changes.target_date.new_value.0 %} +

+ {{ update.changes.target_date.new_value.0 }} +

+ {% else %} +

+ {{ update.changes.target_date.old_value.0 }} +

+ {% endif %} +
+ {% endif %} {% if update.changes.duplicate %} + + + + + {% if update.changes.duplicate.new_value.0 %} + + {% endif %} + {% if update.changes.duplicate.new_value.2 %} + + {% endif %} + {% if update.changes.duplicate.old_value.0 %} + + {% endif %} + {% if update.changes.duplicate.old_value.2 %} + + {% endif %} + +
+ + + Duplicate: + + + {% for duplicate in update.changes.duplicate.new_value|slice:":2" %} + + {{ duplicate }} + + {% endfor %} + + + +{{ update.changes.duplicate.new_value|length|add:"-2" }} + more + + + {% for duplicate in update.changes.duplicate.old_value|slice:":2" %} + + {{ duplicate }} + + {% endfor %} + + + +{{ update.changes.duplicate.old_value|length|add:"-2" }} + more + +
+ {% endif %} + + {% if update.changes.assignees %} + + + + + +
+ + + Assignee: + + + {% if update.changes.assignees.new_value.0 %} + + {{update.changes.assignees.new_value.0}} + + {% endif %} + {% if update.changes.assignees.new_value.1 %} + + +{{ update.changes.assignees.new_value|length|add:"-1"}} more + + {% endif %} + {% if update.changes.assignees.old_value.0 %} + + {{update.changes.assignees.old_value.0}} + + {% endif %} + {% if update.changes.assignees.old_value.1 %} + + +{{ update.changes.assignees.old_value|length|add:"-1"}} more + + {% endif %} +
+ {% endif %} {% if update.changes.labels %} + + + + + + +
+ + + Labels: + + + {% if update.changes.labels.new_value.0 %} + + {{update.changes.labels.new_value.0}} + + {% endif %} + {% if update.changes.labels.new_value.1 %} + + +{{ update.changes.labels.new_value|length|add:"-1"}} more + + {% endif %} + {% if update.changes.labels.old_value.0 %} + + {{update.changes.labels.old_value.0}} + + {% endif %} + {% if update.changes.labels.old_value.1 %} + + +{{ update.changes.labels.old_value|length|add:"-1"}} more + + {% endif %} +
+ {% endif %} + + {% if update.changes.state %} + + + + + + + + + + +
+ + +

+ State: +

+
+ + +

+ {{ update.changes.state.old_value.0 }} +

+
+ + + + +

+ {{update.changes.state.new_value|last }} +

+
+ {% endif %} {% if update.changes.link %} + + + + + + + +
+ + +

+ Links: +

+
+ {% for link in update.changes.link.new_value %} + + {{ link }} + + {% endfor %} + {% if update.changes.link.old_value|length > 0 %} + {% if update.changes.link.old_value.0 != "None" %} +

+ 2 Links were removed +

+ {% endif %} + {% endif %} +
+ {% endif %} + {% if update.changes.priority %} + + + + + + + + + +
+ + +

+ Priority: +

+
+

+ {{ update.changes.priority.old_value.0 }} +

+
+ + +

+ {{ update.changes.priority.new_value|last }} +

+
+ {% endif %} + {% if update.changes.blocking.new_value %} + + + + + {% if update.changes.blocking.new_value.0 %} + + {% endif %} + {% if update.changes.blocking.new_value.2 %} + + {% endif %} + {% if update.changes.blocking.old_value.0 %} + + {% endif %} + {% if update.changes.blocking.old_value.2 %} + + {% endif %} + +
+ + + Blocking: + + + {% for blocking in update.changes.blocking.new_value|slice:":2" %} + + {{ blocking }} + + {% endfor %} + + + +{{ update.changes.blocking.new_value|length|add:"-2" }} + more + + + {% for blocking in update.changes.blocking.old_value|slice:":2" %} + + {{ blocking }} + + {% endfor %} + + + +{{ update.changes.blocking.old_value|length|add:"-2" }} + more + +
+ {% endif %} +
+
+ {% endif %} + + {% endfor %} {% if comments.0 %} + +
+ +

+ Comments +

+ + {% for comment in comments %} + + + + + +
+ {% if comment.actor_detail.avatar_url %} + + {% else %} + + + + +
+ + {{ comment.actor_detail.first_name.0 }} + +
+ {% endif %} +
+ + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + {% endfor %} +
+

+ {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }} +

+
+
+

+ {{ actor_comment|safe }} +

+
+
+
+ {% endfor %} +
+ {% endif %} +
+ +
+ View issue +
+
+
+ + + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the issue + or + manage your email preferences. + + +
+
+
+ + \ No newline at end of file diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh new file mode 100644 index 00000000000..f32be504d0f --- /dev/null +++ b/deploy/1-click/install.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +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) +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) +fi + +sudo chmod +x /usr/local/bin/plane-app +sudo sed -i 's/export BRANCH=${BRANCH:-master}/export BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app + +sudo plane-app --help \ No newline at end of file diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app new file mode 100644 index 00000000000..445f39d697e --- /dev/null +++ b/deploy/1-click/plane-app @@ -0,0 +1,713 @@ +#!/bin/bash + +function print_header() { +clear + +cat <<"EOF" +--------------------------------------- + ____ _ +| _ \| | __ _ _ __ ___ +| |_) | |/ _` | '_ \ / _ \ +| __/| | (_| | | | | __/ +|_| |_|\__,_|_| |_|\___| + +--------------------------------------- +Project management tool from the future +--------------------------------------- + +EOF +} +function update_env_files() { + config_file=$1 + key=$2 + value=$3 + + # Check if the config file exists + if [ ! -f "$config_file" ]; then + echo "Config file not found. Creating a new one..." >&2 + touch "$config_file" + fi + + # Check if the key already exists in the config file + if grep -q "^$key=" "$config_file"; then + awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" + else + echo "$key=$value" >> "$config_file" + fi +} +function read_env_file() { + config_file=$1 + key=$2 + + # Check if the config file exists + if [ ! -f "$config_file" ]; then + echo "Config file not found. Creating a new one..." >&2 + touch "$config_file" + fi + + # Check if the key already exists in the config file + if grep -q "^$key=" "$config_file"; then + value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") + echo "$value" + else + echo "" + fi +} +function update_config() { + config_file="$PLANE_INSTALL_DIR/config.env" + update_env_files "$config_file" "$1" "$2" +} +function read_config() { + config_file="$PLANE_INSTALL_DIR/config.env" + read_env_file "$config_file" "$1" +} +function update_env() { + config_file="$PLANE_INSTALL_DIR/.env" + update_env_files "$config_file" "$1" "$2" +} +function read_env() { + config_file="$PLANE_INSTALL_DIR/.env" + read_env_file "$config_file" "$1" +} +function show_message() { + print_header + + if [ "$2" == "replace_last_line" ]; then + PROGRESS_MSG[-1]="$1" + else + PROGRESS_MSG+=("$1") + fi + + for statement in "${PROGRESS_MSG[@]}"; do + echo "$statement" + done + +} +function prepare_environment() { + show_message "Prepare Environment..." >&2 + + show_message "- Updating OS with required tools ✋" >&2 + sudo apt-get update -y &> /dev/null + sudo apt-get upgrade -y &> /dev/null + + required_tools=("curl" "awk" "wget" "nano" "dialog" "git") + + for tool in "${required_tools[@]}"; do + if ! command -v $tool &> /dev/null; then + sudo apt install -y $tool &> /dev/null + fi + done + + show_message "- OS Updated ✅" "replace_last_line" >&2 + + # Install Docker if not installed + if ! command -v docker &> /dev/null; then + show_message "- Installing Docker ✋" >&2 + sudo curl -o- https://get.docker.com | bash - + + if [ "$EUID" -ne 0 ]; then + dockerd-rootless-setuptool.sh install &> /dev/null + fi + show_message "- Docker Installed ✅" "replace_last_line" >&2 + else + show_message "- Docker is already installed ✅" >&2 + fi + + update_config "PLANE_ARCH" "$CPU_ARCH" + update_config "DOCKER_VERSION" "$(docker -v | awk '{print $3}' | sed 's/,//g')" + update_config "PLANE_DATA_DIR" "$DATA_DIR" + update_config "PLANE_LOG_DIR" "$LOG_DIR" + + # echo "TRUE" + echo "Environment prepared successfully ✅" + show_message "Environment prepared successfully ✅" >&2 + show_message "" >&2 + return 0 +} +function download_plane() { + # Download Docker Compose File from github url + show_message "Downloading Plane Setup Files ✋" >&2 + curl -H 'Cache-Control: no-cache, no-store' \ + -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ + https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s) + + curl -H 'Cache-Control: no-cache, no-store' \ + -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ + https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s) + + # if .env does not exists rename variables-upgrade.env to .env + if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then + mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env + fi + + show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2 + show_message "" >&2 + + echo "PLANE_DOWNLOADED" + return 0 +} +function printUsageInstructions() { + show_message "" >&2 + show_message "----------------------------------" >&2 + show_message "Usage Instructions" >&2 + show_message "----------------------------------" >&2 + show_message "" >&2 + show_message "To use the Plane Setup utility, use below commands" >&2 + show_message "" >&2 + + show_message "Usage: plane-app [OPTION]" >&2 + show_message "" >&2 + show_message " start Start Server" >&2 + show_message " stop Stop Server" >&2 + show_message " restart Restart Server" >&2 + show_message "" >&2 + show_message "other options" >&2 + show_message " -i, --install Install Plane" >&2 + show_message " -c, --configure Configure Plane" >&2 + show_message " -up, --upgrade Upgrade Plane" >&2 + show_message " -un, --uninstall Uninstall Plane" >&2 + show_message " -ui, --update-installer Update Plane Installer" >&2 + show_message " -h, --help Show help" >&2 + show_message "" >&2 + show_message "" >&2 + show_message "Application Data is stored in mentioned folders" >&2 + show_message " - DB Data: $DATA_DIR/postgres" >&2 + show_message " - Redis Data: $DATA_DIR/redis" >&2 + show_message " - Minio Data: $DATA_DIR/minio" >&2 + show_message "" >&2 + show_message "" >&2 + show_message "----------------------------------" >&2 + show_message "" >&2 +} +function build_local_image() { + show_message "- Downloading Plane Source Code ✋" >&2 + REPO=https://github.com/makeplane/plane.git + CURR_DIR=$PWD + PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp + sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null + + sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null + + sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml + + show_message "- Plane Source Code Downloaded ✅" "replace_last_line" >&2 + + show_message "- Building Docker Images ✋" >&2 + sudo docker compose --env-file=$PLANE_INSTALL_DIR/.env -f $PLANE_TEMP_CODE_DIR/build.yml build --no-cache +} +function check_for_docker_images() { + show_message "" >&2 + # show_message "Building Plane Images" >&2 + + update_env "DOCKERHUB_USER" "makeplane" + update_env "PULL_POLICY" "always" + CURR_DIR=$(pwd) + + if [ "$BRANCH" == "master" ]; then + update_env "APP_RELEASE" "latest" + export APP_RELEASE=latest + else + update_env "APP_RELEASE" "$BRANCH" + export APP_RELEASE=$BRANCH + fi + + if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then + # show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2 + echo "Building Plane Images for $CPU_ARCH is not required. Skipping..." + else + export DOCKERHUB_USER=myplane + show_message "Building Plane Images for $CPU_ARCH " >&2 + update_env "DOCKERHUB_USER" "myplane" + update_env "PULL_POLICY" "never" + + build_local_image + + sudo rm -rf $PLANE_INSTALL_DIR/temp > /dev/null + + show_message "- Docker Images Built ✅" "replace_last_line" >&2 + sudo cd $CURR_DIR + fi + + sudo sed -i "s|- pgdata:|- $DATA_DIR/postgres:|g" $PLANE_INSTALL_DIR/docker-compose.yaml + sudo sed -i "s|- redisdata:|- $DATA_DIR/redis:|g" $PLANE_INSTALL_DIR/docker-compose.yaml + sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml + + show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2 + docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull + show_message "Plane Images Downloaded ✅" "replace_last_line" >&2 +} +function configure_plane() { + show_message "" >&2 + show_message "Configuring Plane" >&2 + show_message "" >&2 + + exec 3>&1 + + nginx_port=$(read_env "NGINX_PORT") + domain_name=$(read_env "DOMAIN_NAME") + upload_limit=$(read_env "FILE_SIZE_LIMIT") + + NGINX_SETTINGS=$(dialog \ + --ok-label "Next" \ + --cancel-label "Skip" \ + --backtitle "Plane Configuration" \ + --title "Nginx Settings" \ + --form "" \ + 0 0 0 \ + "Port:" 1 1 "${nginx_port:-80}" 1 10 50 0 \ + "Domain:" 2 1 "${domain_name:-localhost}" 2 10 50 0 \ + "Upload Limit:" 3 1 "${upload_limit:-5242880}" 3 10 15 0 \ + 2>&1 1>&3) + + save_nginx_settings=0 + if [ $? -eq 0 ]; then + save_nginx_settings=1 + nginx_port=$(echo "$NGINX_SETTINGS" | sed -n 1p) + domain_name=$(echo "$NGINX_SETTINGS" | sed -n 2p) + upload_limit=$(echo "$NGINX_SETTINGS" | sed -n 3p) + fi + + + smtp_host=$(read_env "EMAIL_HOST") + smtp_user=$(read_env "EMAIL_HOST_USER") + smtp_password=$(read_env "EMAIL_HOST_PASSWORD") + smtp_port=$(read_env "EMAIL_PORT") + smtp_from=$(read_env "EMAIL_FROM") + smtp_tls=$(read_env "EMAIL_USE_TLS") + smtp_ssl=$(read_env "EMAIL_USE_SSL") + + SMTP_SETTINGS=$(dialog \ + --ok-label "Next" \ + --cancel-label "Skip" \ + --backtitle "Plane Configuration" \ + --title "SMTP Settings" \ + --form "" \ + 0 0 0 \ + "Host:" 1 1 "$smtp_host" 1 10 80 0 \ + "User:" 2 1 "$smtp_user" 2 10 80 0 \ + "Password:" 3 1 "$smtp_password" 3 10 80 0 \ + "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ + "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ + "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ + "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ + 2>&1 1>&3) + + save_smtp_settings=0 + if [ $? -eq 0 ]; then + save_smtp_settings=1 + smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) + smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) + smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) + smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) + smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) + smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) + fi + external_pgdb_url=$(dialog \ + --backtitle "Plane Configuration" \ + --title "Using External Postgres Database ?" \ + --ok-label "Next" \ + --cancel-label "Skip" \ + --inputbox "Enter your external database url" \ + 8 60 3>&1 1>&2 2>&3) + + + external_redis_url=$(dialog \ + --backtitle "Plane Configuration" \ + --title "Using External Redis Database ?" \ + --ok-label "Next" \ + --cancel-label "Skip" \ + --inputbox "Enter your external redis url" \ + 8 60 3>&1 1>&2 2>&3) + + + aws_region=$(read_env "AWS_REGION") + aws_access_key=$(read_env "AWS_ACCESS_KEY_ID") + aws_secret_key=$(read_env "AWS_SECRET_ACCESS_KEY") + aws_bucket=$(read_env "AWS_S3_BUCKET_NAME") + + + AWS_S3_SETTINGS=$(dialog \ + --ok-label "Next" \ + --cancel-label "Skip" \ + --backtitle "Plane Configuration" \ + --title "AWS S3 Bucket Configuration" \ + --form "" \ + 0 0 0 \ + "Region:" 1 1 "$aws_region" 1 10 50 0 \ + "Access Key:" 2 1 "$aws_access_key" 2 10 50 0 \ + "Secret Key:" 3 1 "$aws_secret_key" 3 10 50 0 \ + "Bucket:" 4 1 "$aws_bucket" 4 10 50 0 \ + 2>&1 1>&3) + + save_aws_settings=0 + if [ $? -eq 0 ]; then + save_aws_settings=1 + aws_region=$(echo "$AWS_S3_SETTINGS" | sed -n 1p) + aws_access_key=$(echo "$AWS_S3_SETTINGS" | sed -n 2p) + aws_secret_key=$(echo "$AWS_S3_SETTINGS" | sed -n 3p) + aws_bucket=$(echo "$AWS_S3_SETTINGS" | sed -n 4p) + fi + + # display dialogbox asking for confirmation to continue + CONFIRM_CONFIG=$(dialog \ + --title "Confirm Configuration" \ + --backtitle "Plane Configuration" \ + --yes-label "Confirm" \ + --no-label "Cancel" \ + --yesno \ + " + save_ngnix_settings: $save_nginx_settings + nginx_port: $nginx_port + 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 + aws_secret_key: $aws_secret_key + aws_bucket: $aws_bucket + + pdgb_url: $external_pgdb_url + redis_url: $external_redis_url + " \ + 0 0 3>&1 1>&2 2>&3) + + if [ $? -eq 0 ]; then + if [ $save_nginx_settings == 1 ]; then + update_env "NGINX_PORT" "$nginx_port" + update_env "DOMAIN_NAME" "$domain_name" + update_env "WEB_URL" "http://$domain_name" + update_env "CORS_ALLOWED_ORIGINS" "http://$domain_name" + update_env "FILE_SIZE_LIMIT" "$upload_limit" + 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 + + # check enable aws settings value + if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then + update_env "USE_MINIO" "0" + update_env "AWS_REGION" "$aws_region" + update_env "AWS_ACCESS_KEY_ID" "$aws_access_key" + update_env "AWS_SECRET_ACCESS_KEY" "$aws_secret_key" + update_env "AWS_S3_BUCKET_NAME" "$aws_bucket" + elif [[ -z $aws_access_key || -z $aws_secret_key ]] ; then + update_env "USE_MINIO" "1" + update_env "AWS_REGION" "" + update_env "AWS_ACCESS_KEY_ID" "" + update_env "AWS_SECRET_ACCESS_KEY" "" + update_env "AWS_S3_BUCKET_NAME" "uploads" + fi + + if [ "$external_pgdb_url" != "" ]; then + update_env "DATABASE_URL" "$external_pgdb_url" + fi + if [ "$external_redis_url" != "" ]; then + update_env "REDIS_URL" "$external_redis_url" + fi + fi + + exec 3>&- +} +function upgrade_configuration() { + upg_env_file="$PLANE_INSTALL_DIR/variables-upgrade.env" + # Check if the file exists + if [ -f "$upg_env_file" ]; then + # Read each line from the file + while IFS= read -r line; do + # Skip comments and empty lines + if [[ "$line" =~ ^\s*#.*$ ]] || [[ -z "$line" ]]; then + continue + fi + + # Split the line into key and value + key=$(echo "$line" | cut -d'=' -f1) + value=$(echo "$line" | cut -d'=' -f2-) + + current_value=$(read_env "$key") + + if [ -z "$current_value" ]; then + update_env "$key" "$value" + fi + done < "$upg_env_file" + fi +} +function install() { + show_message "" + if [ "$(uname)" == "Linux" ]; then + OS="linux" + OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) + # check the OS + if [ "$OS_NAME" == "ubuntu" ]; then + OS_SUPPORTED=true + show_message "******** Installing Plane ********" + show_message "" + + prepare_environment + + if [ $? -eq 0 ]; then + download_plane + if [ $? -eq 0 ]; then + # create_service + 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)" + + show_message "Plane Installed Successfully ✅" + show_message "" + else + show_message "Download Failed ❌" + exit 1 + fi + else + show_message "Initialization Failed ❌" + exit 1 + fi + + else + PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌" + show_message "" + exit 1 + fi + else + PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" + show_message "" + exit 1 + fi +} +function upgrade() { + if [ "$(uname)" == "Linux" ]; then + OS="linux" + OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) + # check the OS + if [ "$OS_NAME" == "ubuntu" ]; then + OS_SUPPORTED=true + + prepare_environment + + if [ $? -eq 0 ]; then + download_plane + if [ $? -eq 0 ]; then + check_for_docker_images + upgrade_configuration + update_config "UPGRADE_DATE" "$(date)" + + show_message "" + show_message "Plane Upgraded Successfully ✅" + show_message "" + printUsageInstructions + else + show_message "Download Failed ❌" + exit 1 + fi + else + show_message "Initialization Failed ❌" + exit 1 + fi + else + PROGRESS_MSG="Unsupported OS Detected" + show_message "" + exit 1 + fi + else + PROGRESS_MSG="Unsupported OS Detected : $(uname)" + show_message "" + exit 1 + fi +} +function uninstall() { + if [ "$(uname)" == "Linux" ]; then + OS="linux" + OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) + # check the OS + if [ "$OS_NAME" == "ubuntu" ]; then + OS_SUPPORTED=true + show_message "******** Uninstalling Plane ********" + show_message "" + + stop_server + # CHECK IF PLANE SERVICE EXISTS + # if [ -f "/etc/systemd/system/plane.service" ]; then + # sudo systemctl stop plane.service &> /dev/null + # sudo systemctl disable plane.service &> /dev/null + # sudo rm /etc/systemd/system/plane.service &> /dev/null + # sudo systemctl daemon-reload &> /dev/null + # fi + # show_message "- Plane Service removed ✅" + + if ! [ -x "$(command -v docker)" ]; then + echo "DOCKER_NOT_INSTALLED" &> /dev/null + else + # Ask of user input to confirm uninstall docker ? + CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3) + if [ $? -eq 0 ]; then + show_message "- Uninstalling Docker ✋" + sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null + sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null + show_message "- Docker Uninstalled ✅" "replace_last_line" >&2 + fi + fi + + rm $PLANE_INSTALL_DIR/.env &> /dev/null + rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null + rm $PLANE_INSTALL_DIR/config.env &> /dev/null + rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null + + # rm -rf $PLANE_INSTALL_DIR &> /dev/null + show_message "- Configuration Cleaned ✅" + + show_message "" + show_message "******** Plane Uninstalled ********" + show_message "" + show_message "" + show_message "Plane Configuration Cleaned with some exceptions" + show_message "- DB Data: $DATA_DIR/postgres" + show_message "- Redis Data: $DATA_DIR/redis" + show_message "- Minio Data: $DATA_DIR/minio" + show_message "" + show_message "" + show_message "Thank you for using Plane. We hope to see you again soon." + show_message "" + show_message "" + else + PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" + show_message "" + exit 1 + fi + else + PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" + show_message "" + exit 1 + fi +} +function start_server() { + docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml" + env_file="$PLANE_INSTALL_DIR/.env" + # check if both the files exits + if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then + show_message "Starting Plane Server ✋" + docker compose -f $docker_compose_file --env-file=$env_file up -d + + # Wait for containers to be running + echo "Waiting for containers to start..." + while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do + sleep 1 + done + show_message "Plane Server Started ✅" "replace_last_line" >&2 + else + show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 + fi +} +function stop_server() { + docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml" + env_file="$PLANE_INSTALL_DIR/.env" + # check if both the files exits + if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then + show_message "Stopping Plane Server ✋" + docker compose -f $docker_compose_file --env-file=$env_file down + show_message "Plane Server Stopped ✅" "replace_last_line" >&2 + else + show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 + fi +} +function restart_server() { + docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml" + env_file="$PLANE_INSTALL_DIR/.env" + # check if both the files exits + if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then + show_message "Restarting Plane Server ✋" + docker compose -f $docker_compose_file --env-file=$env_file restart + show_message "Plane Server Restarted ✅" "replace_last_line" >&2 + else + show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 + fi +} +function show_help() { + # print_header + show_message "Usage: plane-app [OPTION]" >&2 + show_message "" >&2 + show_message " start Start Server" >&2 + show_message " stop Stop Server" >&2 + show_message " restart Restart Server" >&2 + show_message "" >&2 + show_message "other options" >&2 + show_message " -i, --install Install Plane" >&2 + show_message " -c, --configure Configure Plane" >&2 + show_message " -up, --upgrade Upgrade Plane" >&2 + show_message " -un, --uninstall Uninstall Plane" >&2 + show_message " -ui, --update-installer Update Plane Installer" >&2 + show_message " -h, --help Show help" >&2 + show_message "" >&2 + exit 1 + +} +function update_installer() { + show_message "Updating Plane Installer ✋" >&2 + curl -H 'Cache-Control: no-cache, no-store' \ + -s -o /usr/local/bin/plane-app \ + https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s) + + chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null + show_message "Plane Installer Updated ✅" "replace_last_line" >&2 +} + +export BRANCH=${BRANCH:-master} +export APP_RELEASE=$BRANCH +export DOCKERHUB_USER=makeplane +export PULL_POLICY=always + +PLANE_INSTALL_DIR=/opt/plane +DATA_DIR=$PLANE_INSTALL_DIR/data +LOG_DIR=$PLANE_INSTALL_DIR/log +OS_SUPPORTED=false +CPU_ARCH=$(uname -m) +PROGRESS_MSG="" +USE_GLOBAL_IMAGES=1 + +mkdir -p $PLANE_INSTALL_DIR/{data,log} + +if [ "$1" == "start" ]; then + start_server +elif [ "$1" == "stop" ]; then + stop_server +elif [ "$1" == "restart" ]; then + restart_server +elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then + install +elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then + configure_plane + printUsageInstructions +elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then + upgrade +elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then + uninstall +elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then + update_installer +elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then + show_help +else + show_help +fi diff --git a/deploy/selfhost/build.yml b/deploy/selfhost/build.yml new file mode 100644 index 00000000000..92533a73bb4 --- /dev/null +++ b/deploy/selfhost/build.yml @@ -0,0 +1,26 @@ +version: "3.8" + +services: + web: + image: ${DOCKERHUB_USER:-local}/plane-frontend:${APP_RELEASE:-latest} + build: + context: . + dockerfile: ./web/Dockerfile.web + + space: + image: ${DOCKERHUB_USER:-local}/plane-space:${APP_RELEASE:-latest} + build: + context: ./ + dockerfile: ./space/Dockerfile.space + + api: + image: ${DOCKERHUB_USER:-local}/plane-backend:${APP_RELEASE:-latest} + build: + context: ./apiserver + dockerfile: ./Dockerfile.api + + proxy: + image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest} + build: + context: ./nginx + dockerfile: ./Dockerfile diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 8b4ff77ef02..b223e722ab0 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -65,8 +65,8 @@ x-app-env : &app-env services: web: <<: *app-env - platform: linux/amd64 - image: makeplane/plane-frontend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: /usr/local/bin/start.sh web/server.js web deploy: @@ -77,8 +77,8 @@ services: space: <<: *app-env - platform: linux/amd64 - image: makeplane/plane-space:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: /usr/local/bin/start.sh space/server.js space deploy: @@ -90,8 +90,8 @@ services: api: <<: *app-env - platform: linux/amd64 - image: makeplane/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/takeoff deploy: @@ -102,8 +102,8 @@ services: worker: <<: *app-env - platform: linux/amd64 - image: makeplane/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/worker depends_on: @@ -113,8 +113,8 @@ services: beat-worker: <<: *app-env - platform: linux/amd64 - image: makeplane/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/beat depends_on: @@ -122,9 +122,22 @@ services: - plane-db - plane-redis + migrator: + <<: *app-env + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} + restart: no + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate" + depends_on: + - plane-db + - plane-redis + plane-db: <<: *app-env image: postgres:15.2-alpine + pull_policy: if_not_present restart: unless-stopped command: postgres -c 'max_connections=1000' volumes: @@ -133,6 +146,7 @@ services: plane-redis: <<: *app-env image: redis:6.2.7-alpine + pull_policy: if_not_present restart: unless-stopped volumes: - redisdata:/data @@ -140,6 +154,7 @@ services: plane-minio: <<: *app-env image: minio/minio + pull_policy: if_not_present restart: unless-stopped command: server /export --console-address ":9090" volumes: @@ -148,8 +163,8 @@ services: # Comment this if you already have a reverse proxy running proxy: <<: *app-env - platform: linux/amd64 - image: makeplane/plane-proxy:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest} + pull_policy: ${PULL_POLICY:-always} ports: - ${NGINX_PORT}:80 depends_on: diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 15150aa40e7..3f306c559f1 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -3,13 +3,75 @@ BRANCH=master SCRIPT_DIR=$PWD PLANE_INSTALL_DIR=$PWD/plane-app +export APP_RELEASE=$BRANCH +export DOCKERHUB_USER=makeplane +export PULL_POLICY=always +USE_GLOBAL_IMAGES=1 -function install(){ - echo - echo "Installing on $PLANE_INSTALL_DIR" +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +function buildLocalImage() { + if [ "$1" == "--force-build" ]; then + DO_BUILD="1" + elif [ "$1" == "--skip-build" ]; then + DO_BUILD="2" + else + printf "\n" >&2 + printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2 + printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2 + printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2 + printf "\n" >&2 + printf "${GREEN}Select an option to proceed: ${NC}\n" >&2 + printf " 1) Build Fresh Images \n" >&2 + printf " 2) Skip Building Images \n" >&2 + printf " 3) Exit \n" >&2 + printf "\n" >&2 + read -p "Select Option [1]: " DO_BUILD + until [[ -z "$DO_BUILD" || "$DO_BUILD" =~ ^[1-3]$ ]]; do + echo "$DO_BUILD: invalid selection." >&2 + read -p "Select Option [1]: " DO_BUILD + done + echo "" >&2 + fi + + if [ "$DO_BUILD" == "1" ] || [ "$DO_BUILD" == "" ]; + then + REPO=https://github.com/makeplane/plane.git + CURR_DIR=$PWD + PLANE_TEMP_CODE_DIR=$(mktemp -d) + git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch + + cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml + + cd $PLANE_TEMP_CODE_DIR + if [ "$BRANCH" == "master" ]; + then + APP_RELEASE=latest + fi + + docker compose -f build.yml build --no-cache >&2 + # cd $CURR_DIR + # rm -rf $PLANE_TEMP_CODE_DIR + echo "build_completed" + elif [ "$DO_BUILD" == "2" ]; + then + printf "${YELLOW}Build action skipped by you in lieu of using existing images. ${NC} \n" >&2 + echo "build_skipped" + elif [ "$DO_BUILD" == "3" ]; + then + echo "build_exited" + else + printf "INVALID OPTION SUPPLIED" >&2 + fi +} +function install() { + echo "Installing Plane.........." download } -function download(){ +function download() { cd $SCRIPT_DIR TS=$(date +%s) if [ -f "$PLANE_INSTALL_DIR/docker-compose.yaml" ] @@ -35,30 +97,45 @@ function download(){ rm $PLANE_INSTALL_DIR/temp.yaml fi + + if [ $USE_GLOBAL_IMAGES == 0 ]; then + local res=$(buildLocalImage) + # echo $res + + if [ "$res" == "build_exited" ]; + then + echo + echo "Install action cancelled by you. Exiting now." + echo + exit 0 + fi + else + docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull + fi echo "" echo "Latest version is now available for you to use" echo "" - echo "In case of Upgrade, your new setting file is available as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file." + echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file." echo "" } -function startServices(){ +function startServices() { cd $PLANE_INSTALL_DIR - docker compose up -d + docker compose up -d --quiet-pull cd $SCRIPT_DIR } -function stopServices(){ +function stopServices() { cd $PLANE_INSTALL_DIR docker compose down cd $SCRIPT_DIR } -function restartServices(){ +function restartServices() { cd $PLANE_INSTALL_DIR docker compose restart cd $SCRIPT_DIR } -function upgrade(){ +function upgrade() { echo "***** STOPPING SERVICES ****" stopServices @@ -69,10 +146,10 @@ function upgrade(){ echo "***** PLEASE VALIDATE AND START SERVICES ****" } -function askForAction(){ +function askForAction() { echo echo "Select a Action you want to perform:" - echo " 1) Install" + echo " 1) Install (${ARCH})" echo " 2) Start" echo " 3) Stop" echo " 4) Restart" @@ -115,6 +192,20 @@ function askForAction(){ fi } +# CPU ARCHITECHTURE BASED SETTINGS +ARCH=$(uname -m) +if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ]; +then + USE_GLOBAL_IMAGES=1 + DOCKERHUB_USER=makeplane + PULL_POLICY=always +else + USE_GLOBAL_IMAGES=0 + DOCKERHUB_USER=myplane + PULL_POLICY=never +fi + +# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME if [ "$BRANCH" != "master" ]; then PLANE_INSTALL_DIR=$PWD/plane-app-$(echo $BRANCH | sed -r 's@(\/|" "|\.)@-@g') diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 4e1e3b39f3d..a2e518708df 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -44,9 +44,6 @@ services: env_file: - .env environment: - POSTGRES_USER: ${PGUSER} - POSTGRES_DB: ${PGDATABASE} - POSTGRES_PASSWORD: ${PGPASSWORD} PGDATA: /var/lib/postgresql/data web: @@ -89,7 +86,7 @@ services: - dev_env volumes: - ./apiserver:/code - # command: /bin/sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local" + command: ./bin/takeoff.local env_file: - ./apiserver/.env depends_on: @@ -107,7 +104,7 @@ services: - dev_env volumes: - ./apiserver:/code - command: /bin/sh -c "celery -A plane worker -l info" + command: ./bin/worker env_file: - ./apiserver/.env depends_on: @@ -126,7 +123,7 @@ services: - dev_env volumes: - ./apiserver:/code - command: /bin/sh -c "celery -A plane beat -l info" + command: ./bin/beat env_file: - ./apiserver/.env depends_on: @@ -134,6 +131,26 @@ services: - plane-db - plane-redis + migrator: + build: + context: ./apiserver + dockerfile: Dockerfile.dev + args: + DOCKER_BUILDKIT: 1 + restart: no + networks: + - dev_env + volumes: + - ./apiserver:/code + command: > + sh -c "python manage.py wait_for_db --settings=plane.settings.local && + python manage.py migrate --settings=plane.settings.local" + env_file: + - ./apiserver/.env + depends_on: + - plane-db + - plane-redis + proxy: build: context: ./nginx diff --git a/nginx/env.sh b/nginx/env.sh index 59e4a46a048..7db471ecaae 100644 --- a/nginx/env.sh +++ b/nginx/env.sh @@ -1,4 +1,6 @@ #!/bin/sh +export dollar="$" +export http_upgrade="http_upgrade" envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf exec nginx -g 'daemon off;' diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index 182fc4d83f0..f86c84aa809 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -19,7 +19,7 @@ http { location / { proxy_pass http://web:3000/; proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; + proxy_set_header Upgrade ${dollar}http_upgrade; proxy_set_header Connection "upgrade"; } diff --git a/package.json b/package.json index b5d99766252..64bd220589a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.14.0", + "version": "0.15.0", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -10,11 +10,12 @@ "packages/eslint-config-custom", "packages/tailwind-config-custom", "packages/tsconfig", - "packages/ui" + "packages/ui", + "packages/types" ], "scripts": { "build": "turbo run build", - "dev": "turbo run dev", + "dev": "turbo run dev --concurrency=13", "start": "turbo run start", "lint": "turbo run lint", "clean": "turbo run clean", @@ -27,10 +28,10 @@ "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", - "turbo": "^1.11.2" + "turbo": "^1.11.3" }, "resolutions": { "@types/react": "18.2.42" }, "packageManager": "yarn@1.22.19" -} +} \ No newline at end of file diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index ef2be61e36e..8b31acdaf6d 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.14.0", + "version": "0.15.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -33,7 +33,6 @@ "@tiptap/extension-code-block-lowlight": "^2.1.13", "@tiptap/extension-color": "^2.1.13", "@tiptap/extension-image": "^2.1.13", - "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-task-item": "^2.1.13", @@ -48,6 +47,7 @@ "clsx": "^1.2.1", "highlight.js": "^11.8.0", "jsx-dom-cjs": "^8.0.3", + "linkifyjs": "^4.1.3", "lowlight": "^3.0.0", "lucide-react": "^0.294.0", "react-moveable": "^0.54.2", diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 147797e2d16..4a56f07c2dc 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -34,8 +34,32 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { }; export const toggleCodeBlock = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); - else editor.chain().focus().toggleCodeBlock().run(); + // Check if code block is active then toggle code block + if (editor.isActive("codeBlock")) { + if (range) { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + return; + } + editor.chain().focus().toggleCodeBlock().run(); + return; + } + + // Check if user hasn't selected any text + const isSelectionEmpty = editor.state.selection.empty; + + if (isSelectionEmpty) { + if (range) { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + return; + } + editor.chain().focus().toggleCodeBlock().run(); + } else { + if (range) { + editor.chain().focus().deleteRange(range).toggleCode().run(); + return; + } + editor.chain().focus().toggleCode().run(); + } }; export const toggleOrderedList = (editor: Editor, range?: Range) => { @@ -59,8 +83,8 @@ export const toggleStrike = (editor: Editor, range?: Range) => { }; export const toggleBlockquote = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(); - else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(); + if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run(); + else editor.chain().focus().toggleBlockquote().run(); }; export const insertTableCommand = (editor: Editor, range?: Range) => { diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index 86822664b46..b0d2a10213a 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -12,6 +12,11 @@ display: none; } +.ProseMirror code::before, +.ProseMirror code::after { + display: none; +} + .ProseMirror .is-empty::before { content: attr(data-placeholder); float: left; diff --git a/packages/editor/core/src/ui/components/editor-container.tsx b/packages/editor/core/src/ui/components/editor-container.tsx index 8de6298b57e..5480a51e931 100644 --- a/packages/editor/core/src/ui/components/editor-container.tsx +++ b/packages/editor/core/src/ui/components/editor-container.tsx @@ -5,13 +5,17 @@ interface EditorContainerProps { editor: Editor | null; editorClassNames: string; children: ReactNode; + hideDragHandle?: () => void; } -export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => ( +export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
{ - editor?.chain().focus().run(); + editor?.chain().focus(undefined, { scrollIntoView: false }).run(); + }} + onMouseLeave={() => { + hideDragHandle?.(); }} className={`cursor-text ${editorClassNames}`} > diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/core/src/ui/extensions/code-inline/index.tsx new file mode 100644 index 00000000000..1c5d341090b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code-inline/index.tsx @@ -0,0 +1,95 @@ +import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core"; + +export interface CodeOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + code: { + /** + * Set a code mark + */ + setCode: () => ReturnType; + /** + * Toggle inline code + */ + toggleCode: () => ReturnType; + /** + * Unset a code mark + */ + unsetCode: () => ReturnType; + }; + } +} + +export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/; +export const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g; + +export const CustomCodeInlineExtension = Mark.create({ + name: "code", + + addOptions() { + return { + HTMLAttributes: { + class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000", + spellcheck: "false", + }, + }; + }, + + excludes: "_", + + code: true, + + exitable: true, + + parseHTML() { + return [{ tag: "code" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["code", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setCode: + () => + ({ commands }) => + commands.setMark(this.name), + toggleCode: + () => + ({ commands }) => + commands.toggleMark(this.name), + unsetCode: + () => + ({ commands }) => + commands.unsetMark(this.name), + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-e": () => this.editor.commands.toggleCode(), + }; + }, + + addInputRules() { + return [ + markInputRule({ + find: inputRegex, + type: this.type, + }), + ]; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: pasteRegex, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/code/index.tsx b/packages/editor/core/src/ui/extensions/code/index.tsx index 016cec2c31b..64a1740cb29 100644 --- a/packages/editor/core/src/ui/extensions/code/index.tsx +++ b/packages/editor/core/src/ui/extensions/code/index.tsx @@ -6,10 +6,61 @@ import ts from "highlight.js/lib/languages/typescript"; const lowlight = createLowlight(common); lowlight.register("ts", ts); -export const CustomCodeBlock = CodeBlockLowlight.extend({ +import { Selection } from "@tiptap/pm/state"; + +export const CustomCodeBlockExtension = CodeBlockLowlight.extend({ addKeyboardShortcuts() { return { Tab: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + // Use ProseMirror's insertText transaction to insert the tab character + const tr = state.tr.insertText("\t", $from.pos, $from.pos); + editor.view.dispatch(tr); + + return true; + }, + ArrowUp: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtStart = $from.parentOffset === 0; + + if (!isAtStart) { + return false; + } + + // Check if codeBlock is the first node + const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0; + + if (isFirstNode) { + // Insert a new paragraph at the start of the document and move the cursor to it + return editor.commands.command(({ tr }) => { + const node = editor.schema.nodes.paragraph.create(); + tr.insert(0, node); + tr.setSelection(Selection.near(tr.doc.resolve(1))); + return true; + }); + } + + return false; + }, + ArrowDown: ({ editor }) => { + if (!this.options.exitOnArrowDown) { + return false; + } + const { state } = editor; const { selection, doc } = state; const { $from, empty } = selection; @@ -18,7 +69,28 @@ export const CustomCodeBlock = CodeBlockLowlight.extend({ return false; } - return editor.commands.insertContent(" "); + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + + if (!isAtEnd) { + return false; + } + + const after = $from.after(); + + if (after === undefined) { + return false; + } + + const nodeAfter = doc.nodeAt(after); + + if (nodeAfter) { + return editor.commands.command(({ tr }) => { + tr.setSelection(Selection.near(doc.resolve(after))); + return true; + }); + } + + return editor.commands.exitCode(); }, }; }, diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts new file mode 100644 index 00000000000..cf67e13d90b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts @@ -0,0 +1,118 @@ +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, + getMarksBetween, + NodeWithPos, +} from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type AutolinkOptions = { + type: MarkType; + validate?: (url: string) => boolean; +}; + +export function autolink(options: AutolinkOptions): Plugin { + return new Plugin({ + key: new PluginKey("autolink"), + appendTransaction: (transactions, oldState, newState) => { + const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc); + const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink")); + + if (!docChanges || preventAutolink) { + return; + } + + const { tr } = newState; + const transform = combineTransactionSteps(oldState.doc, [...transactions]); + const changes = getChangedRanges(transform); + + changes.forEach(({ newRange }) => { + // Now let’s see if we can add new links. + const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock); + + let textBlock: NodeWithPos | undefined; + let textBeforeWhitespace: string | undefined; + + if (nodesInChangedRanges.length > 1) { + // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter). + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween( + textBlock.pos, + textBlock.pos + textBlock.node.nodeSize, + undefined, + " " + ); + } else if ( + nodesInChangedRanges.length && + // We want to make sure to include the block seperator argument to treat hard breaks like spaces. + newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ") + ) { + textBlock = nodesInChangedRanges[0]; + textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " "); + } + + if (textBlock && textBeforeWhitespace) { + const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== ""); + + if (wordsBeforeWhitespace.length <= 0) { + return false; + } + + const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1]; + const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace); + + if (!lastWordBeforeSpace) { + return false; + } + + find(lastWordBeforeSpace) + .filter((link) => link.isLink) + // Calculate link position. + .map((link) => ({ + ...link, + from: lastWordAndBlockOffset + link.start + 1, + to: lastWordAndBlockOffset + link.end + 1, + })) + // ignore link inside code mark + .filter((link) => { + if (!newState.schema.marks.code) { + return true; + } + + return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code); + }) + // validate link + .filter((link) => { + if (options.validate) { + return options.validate(link.value); + } + return true; + }) + // Add link mark. + .forEach((link) => { + if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) { + return; + } + + tr.addMark( + link.from, + link.to, + options.type.create({ + href: link.href, + }) + ); + }); + } + }); + + if (!tr.steps.length) { + return; + } + + return tr; + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts new file mode 100644 index 00000000000..0854092a9e4 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts @@ -0,0 +1,42 @@ +import { getAttributes } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +type ClickHandlerOptions = { + type: MarkType; +}; + +export function clickHandler(options: ClickHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handleClickLink"), + props: { + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return false; + } + + const eventTarget = event.target as HTMLElement; + + if (eventTarget.nodeName !== "A") { + return false; + } + + const attrs = getAttributes(view.state, options.type.name); + const link = event.target as HTMLLinkElement; + + const href = link?.href ?? attrs.href; + const target = link?.target ?? attrs.target; + + if (link && href) { + if (view.editable) { + window.open(href, target); + } + + return true; + } + + return false; + }, + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts new file mode 100644 index 00000000000..83e38054c74 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts @@ -0,0 +1,52 @@ +import { Editor } from "@tiptap/core"; +import { MarkType } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { find } from "linkifyjs"; + +type PasteHandlerOptions = { + editor: Editor; + type: MarkType; +}; + +export function pasteHandler(options: PasteHandlerOptions): Plugin { + return new Plugin({ + key: new PluginKey("handlePasteLink"), + props: { + handlePaste: (view, event, slice) => { + const { state } = view; + const { selection } = state; + const { empty } = selection; + + if (empty) { + return false; + } + + let textContent = ""; + + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + const link = find(textContent).find((item) => item.isLink && item.value === textContent); + + if (!textContent || !link) { + return false; + } + + const html = event.clipboardData?.getData("text/html"); + + const hrefRegex = /href="([^"]*)"/; + + const existingLink = html?.match(hrefRegex); + + const url = existingLink ? existingLink[1] : link.href; + + options.editor.commands.setMark(options.type, { + href: url, + }); + + return true; + }, + }, + }); +} diff --git a/packages/editor/core/src/ui/extensions/custom-link/index.tsx b/packages/editor/core/src/ui/extensions/custom-link/index.tsx new file mode 100644 index 00000000000..e66d18904f8 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-link/index.tsx @@ -0,0 +1,219 @@ +import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { find, registerCustomProtocol, reset } from "linkifyjs"; + +import { autolink } from "src/ui/extensions/custom-link/helpers/autolink"; +import { clickHandler } from "src/ui/extensions/custom-link/helpers/clickHandler"; +import { pasteHandler } from "src/ui/extensions/custom-link/helpers/pasteHandler"; + +export interface LinkProtocolOptions { + scheme: string; + optionalSlashes?: boolean; +} + +export interface LinkOptions { + autolink: boolean; + inclusive: boolean; + protocols: Array; + openOnClick: boolean; + linkOnPaste: boolean; + HTMLAttributes: Record; + validate?: (url: string) => boolean; +} + +declare module "@tiptap/core" { + interface Commands { + link: { + setLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + toggleLink: (attributes: { + href: string; + target?: string | null; + rel?: string | null; + class?: string | null; + }) => ReturnType; + unsetLink: () => ReturnType; + }; + } +} + +export const CustomLinkExtension = Mark.create({ + name: "link", + + priority: 1000, + + keepOnSplit: false, + + onCreate() { + this.options.protocols.forEach((protocol) => { + if (typeof protocol === "string") { + registerCustomProtocol(protocol); + return; + } + registerCustomProtocol(protocol.scheme, protocol.optionalSlashes); + }); + }, + + onDestroy() { + reset(); + }, + + inclusive() { + return this.options.inclusive; + }, + + addOptions() { + return { + openOnClick: true, + linkOnPaste: true, + autolink: true, + inclusive: false, + protocols: [], + HTMLAttributes: { + target: "_blank", + rel: "noopener noreferrer nofollow", + class: null, + }, + validate: undefined, + }; + }, + + addAttributes() { + return { + href: { + default: null, + }, + target: { + default: this.options.HTMLAttributes.target, + }, + rel: { + default: this.options.HTMLAttributes.rel, + }, + class: { + default: this.options.HTMLAttributes.class, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "a[href]", + getAttrs: (node) => { + if (typeof node === "string" || !(node instanceof HTMLElement)) { + return null; + } + const href = node.getAttribute("href")?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return false; + } + return {}; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const href = HTMLAttributes.href?.toLowerCase() || ""; + if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) { + return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0]; + } + return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setLink: + (attributes) => + ({ chain }) => + chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(), + + toggleLink: + (attributes) => + ({ chain }) => + chain() + .toggleMark(this.name, attributes, { extendEmptyMarkRange: true }) + .setMeta("preventAutolink", true) + .run(), + + unsetLink: + () => + ({ chain }) => + chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(), + }; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: (text) => + find(text) + .filter((link) => { + if (this.options.validate) { + return this.options.validate(link.value); + } + return true; + }) + .filter((link) => link.isLink) + .map((link) => ({ + text: link.value, + index: link.start, + data: link, + })), + type: this.type, + getAttributes: (match, pasteEvent) => { + const html = pasteEvent?.clipboardData?.getData("text/html"); + const hrefRegex = /href="([^"]*)"/; + + const existingLink = html?.match(hrefRegex); + + if (existingLink) { + return { + href: existingLink[1], + }; + } + + return { + href: match.data?.href, + }; + }, + }), + ]; + }, + + addProseMirrorPlugins() { + const plugins: Plugin[] = []; + + if (this.options.autolink) { + plugins.push( + autolink({ + type: this.type, + validate: this.options.validate, + }) + ); + } + + if (this.options.openOnClick) { + plugins.push( + clickHandler({ + type: this.type, + }) + ); + } + + if (this.options.linkOnPaste) { + plugins.push( + pasteHandler({ + editor: this.editor, + type: this.type, + }) + ); + } + + return plugins; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule.tsx b/packages/editor/core/src/ui/extensions/horizontal-rule.tsx deleted file mode 100644 index cee0ded8354..00000000000 --- a/packages/editor/core/src/ui/extensions/horizontal-rule.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { TextSelection } from "prosemirror-state"; - -import { InputRule, mergeAttributes, Node, nodeInputRule, wrappingInputRule } from "@tiptap/core"; - -/** - * Extension based on: - * - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule) - */ - -export interface HorizontalRuleOptions { - HTMLAttributes: Record; -} - -declare module "@tiptap/core" { - interface Commands { - horizontalRule: { - /** - * Add a horizontal rule - */ - setHorizontalRule: () => ReturnType; - }; - } -} - -export const HorizontalRule = Node.create({ - name: "horizontalRule", - - addOptions() { - return { - HTMLAttributes: {}, - }; - }, - - group: "block", - - addAttributes() { - return { - color: { - default: "#dddddd", - }, - }; - }, - - parseHTML() { - return [ - { - tag: `div[data-type="${this.name}"]`, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - "div", - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - "data-type": this.name, - }), - ["div", {}], - ]; - }, - - addCommands() { - return { - setHorizontalRule: - () => - ({ chain }) => { - return ( - chain() - .insertContent({ type: this.name }) - // set cursor after horizontal rule - .command(({ tr, dispatch }) => { - if (dispatch) { - const { $to } = tr.selection; - const posAfter = $to.end(); - - if ($to.nodeAfter) { - tr.setSelection(TextSelection.create(tr.doc, $to.pos)); - } else { - // add node after horizontal rule if it’s the end of the document - const node = $to.parent.type.contentMatch.defaultType?.create(); - - if (node) { - tr.insert(posAfter, node); - tr.setSelection(TextSelection.create(tr.doc, posAfter)); - } - } - - tr.scrollIntoView(); - } - - return true; - }) - .run() - ); - }, - }; - }, - - addInputRules() { - return [ - new InputRule({ - find: /^(?:---|—-|___\s|\*\*\*\s)$/, - handler: ({ state, range, match }) => { - state.tr.replaceRangeWith(range.from, range.to, this.type.create()); - }, - }), - ]; - }, -}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 4ae55f00c06..5bfba3b0f55 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -1,31 +1,31 @@ -import StarterKit from "@tiptap/starter-kit"; -import TiptapLink from "@tiptap/extension-link"; -import TiptapUnderline from "@tiptap/extension-underline"; -import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; +import TextStyle from "@tiptap/extension-text-style"; +import TiptapUnderline from "@tiptap/extension-underline"; +import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; -import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { Table } from "src/ui/extensions/table/table"; import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; +import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { TableRow } from "src/ui/extensions/table/table-row/table-row"; -import { HorizontalRule } from "src/ui/extensions/horizontal-rule"; import { ImageExtension } from "src/ui/extensions/image"; import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; +import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; import { CustomKeymap } from "src/ui/extensions/keymap"; -import { CustomCodeBlock } from "src/ui/extensions/code"; import { CustomQuoteExtension } from "src/ui/extensions/quote"; -import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; 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"; export const CoreEditorExtensions = ( mentionConfig: { @@ -52,14 +52,12 @@ export const CoreEditorExtensions = ( class: "leading-normal -mb-2", }, }, - code: { - HTMLAttributes: { - class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, + code: false, codeBlock: false, - horizontalRule: false, + horizontalRule: { + HTMLAttributes: { class: "mt-4 mb-4" }, + }, + blockquote: false, dropcursor: { color: "rgba(var(--color-text-100))", width: 2, @@ -70,9 +68,12 @@ export const CoreEditorExtensions = ( }), CustomKeymap, ListKeymap, - TiptapLink.configure({ + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url), HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", @@ -91,19 +92,19 @@ export const CoreEditorExtensions = ( class: "not-prose pl-2", }, }), - CustomCodeBlock, TaskItem.configure({ HTMLAttributes: { class: "flex items-start my-4", }, nested: true, }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, Markdown.configure({ html: true, transformCopiedText: true, transformPastedText: true, }), - HorizontalRule, Table, TableHeader, TableCell, diff --git a/packages/editor/core/src/ui/extensions/quote/index.tsx b/packages/editor/core/src/ui/extensions/quote/index.tsx index a2c968401db..9dcae6ad7a5 100644 --- a/packages/editor/core/src/ui/extensions/quote/index.tsx +++ b/packages/editor/core/src/ui/extensions/quote/index.tsx @@ -1,10 +1,9 @@ -import { isAtStartOfNode } from "@tiptap/core"; import Blockquote from "@tiptap/extension-blockquote"; export const CustomQuoteExtension = Blockquote.extend({ addKeyboardShortcuts() { return { - Enter: ({ editor }) => { + Enter: () => { const { $from, $to, $head } = this.editor.state.selection; const parent = $head.node(-1); diff --git a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx index bc42b49ff20..bd96ff1b1d3 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx +++ b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx @@ -1,5 +1,5 @@ import { h } from "jsx-dom-cjs"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model"; import { Decoration, NodeView } from "@tiptap/pm/view"; import tippy, { Instance, Props } from "tippy.js"; @@ -8,6 +8,12 @@ import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/table import { icons } from "src/ui/extensions/table/table/icons"; +type ToolboxItem = { + label: string; + icon: string; + action: (args: any) => void; +}; + export function updateColumns( node: ProseMirrorNode, colgroup: HTMLElement, @@ -75,7 +81,7 @@ const defaultTippyOptions: Partial = { placement: "right", }; -function setCellsBackgroundColor(editor: Editor, backgroundColor) { +function setCellsBackgroundColor(editor: Editor, backgroundColor: string) { return editor .chain() .focus() @@ -88,7 +94,7 @@ function setCellsBackgroundColor(editor: Editor, backgroundColor) { .run(); } -const columnsToolboxItems = [ +const columnsToolboxItems: ToolboxItem[] = [ { label: "Add Column Before", icon: icons.insertLeftTableIcon, @@ -109,7 +115,7 @@ const columnsToolboxItems = [ }: { editor: Editor; triggerButton: HTMLElement; - controlsContainer; + controlsContainer: Element; }) => { createColorPickerToolbox({ triggerButton, @@ -127,7 +133,7 @@ const columnsToolboxItems = [ }, ]; -const rowsToolboxItems = [ +const rowsToolboxItems: ToolboxItem[] = [ { label: "Add Row Above", icon: icons.insertTopTableIcon, @@ -172,11 +178,12 @@ function createToolbox({ tippyOptions, onClickItem, }: { - triggerButton: HTMLElement; - items: { icon: string; label: string }[]; + triggerButton: Element | null; + items: ToolboxItem[]; tippyOptions: any; - onClickItem: any; + onClickItem: (item: ToolboxItem) => void; }): Instance { + // @ts-expect-error const toolbox = tippy(triggerButton, { content: h( "div", @@ -278,14 +285,14 @@ export class TableView implements NodeView { decorations: Decoration[]; editor: Editor; getPos: () => number; - hoveredCell; + hoveredCell: ResolvedPos | null = null; map: TableMap; root: HTMLElement; - table: HTMLElement; - colgroup: HTMLElement; + table: HTMLTableElement; + colgroup: HTMLTableColElement; tbody: HTMLElement; - rowsControl?: HTMLElement; - columnsControl?: HTMLElement; + rowsControl?: HTMLElement | null; + columnsControl?: HTMLElement | null; columnsToolbox?: Instance; rowsToolbox?: Instance; controls?: HTMLElement; @@ -398,13 +405,13 @@ export class TableView implements NodeView { this.render(); } - update(node: ProseMirrorNode, decorations) { + update(node: ProseMirrorNode, decorations: readonly Decoration[]) { if (node.type !== this.node.type) { return false; } this.node = node; - this.decorations = decorations; + this.decorations = [...decorations]; this.map = TableMap.get(this.node); if (this.editor.isEditable) { @@ -430,19 +437,16 @@ 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"); @@ -453,14 +457,21 @@ export class TableView implements NodeView { const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; + if (!this.table) { + return; + } + const tableRect = this.table.getBoundingClientRect(); const cellRect = cellDom.getBoundingClientRect(); - this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; - this.columnsControl.style.width = `${cellRect.width}px`; - - this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`; - this.rowsControl.style.height = `${cellRect.height}px`; + if (this.columnsControl) { + this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; + this.columnsControl.style.width = `${cellRect.width}px`; + } + if (this.rowsControl) { + this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`; + this.rowsControl.style.height = `${cellRect.height}px`; + } } selectColumn() { @@ -471,10 +482,7 @@ export class TableView implements NodeView { const headCellPos = this.map.map[colIndex + this.map.width * (this.map.height - 1)] + (this.getPos() + 1); const cellSelection = CellSelection.create(this.editor.view.state.doc, anchorCellPos, headCellPos); - this.editor.view.dispatch( - // @ts-ignore - this.editor.state.tr.setSelection(cellSelection) - ); + this.editor.view.dispatch(this.editor.state.tr.setSelection(cellSelection)); } selectRow() { @@ -485,9 +493,6 @@ export class TableView implements NodeView { const headCellPos = this.map.map[anchorCellIndex + (this.map.width - 1)] + (this.getPos() + 1); const cellSelection = CellSelection.create(this.editor.state.doc, anchorCellPos, headCellPos); - this.editor.view.dispatch( - // @ts-ignore - this.editor.view.state.tr.setSelection(cellSelection) - ); + this.editor.view.dispatch(this.editor.view.state.tr.setSelection(cellSelection)); } } diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index 6a47d79f09d..e723ca0d7f9 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -10,6 +10,11 @@ export interface CustomMentionOptions extends MentionOptions { } export const CustomMention = Mention.extend({ + addStorage(this) { + return { + mentionsOpen: false, + }; + }, addAttributes() { return { id: { diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts index 6d706cb7997..40e75a1e381 100644 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -14,6 +14,7 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ return { onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + props.editor.storage.mentionsOpen = true; reactRenderer = new ReactRenderer(MentionList, { props, editor: props.editor, @@ -45,10 +46,18 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ return true; } - // @ts-ignore - return reactRenderer?.ref?.onKeyDown(props); + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + // @ts-ignore + reactRenderer?.ref?.onKeyDown(props); + event?.stopPropagation(); + return true; + } + return false; }, - onExit: () => { + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; popup?.[0].destroy(); reactRenderer?.destroy(); }, diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.tsx index 610d677f825..f60febc59d6 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -106,7 +106,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem => ({ export const CodeItem = (editor: Editor): EditorMenuItem => ({ name: "code", - isActive: () => editor?.isActive("code"), + isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), command: () => toggleCodeBlock(editor), icon: CodeIcon, }); @@ -120,7 +120,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ export const QuoteItem = (editor: Editor): EditorMenuItem => ({ name: "quote", - isActive: () => editor?.isActive("quote"), + isActive: () => editor?.isActive("blockquote"), command: () => toggleBlockquote(editor), icon: QuoteIcon, }); diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 5795d6c4a6f..cf7c4ee1823 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -1,5 +1,4 @@ import StarterKit from "@tiptap/starter-kit"; -import TiptapLink from "@tiptap/extension-link"; import TiptapUnderline from "@tiptap/extension-underline"; import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; @@ -12,12 +11,12 @@ import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { Table } from "src/ui/extensions/table/table"; import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; import { TableRow } from "src/ui/extensions/table/table-row/table-row"; -import { HorizontalRule } from "src/ui/extensions/horizontal-rule"; import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image"; import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { CustomLinkExtension } from "src/ui/extensions/custom-link"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionSuggestions: IMentionSuggestion[]; @@ -51,7 +50,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { }, }, codeBlock: false, - horizontalRule: false, + horizontalRule: { + HTMLAttributes: { class: "mt-4 mb-4" }, + }, dropcursor: { color: "rgba(var(--color-text-100))", width: 2, @@ -59,7 +60,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { gapcursor: false, }), Gapcursor, - TiptapLink.configure({ + CustomLinkExtension.configure({ protocols: ["http", "https"], validate: (url) => isValidHttpUrl(url), HTMLAttributes: { @@ -72,7 +73,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { class: "rounded-lg border border-custom-border-300", }, }), - HorizontalRule, TiptapUnderline, TextStyle, Color, diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 72dfab954e8..7a3e9bdadf8 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.14.0", + "version": "0.15.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -28,14 +28,17 @@ "react-dom": "18.2.0" }, "dependencies": { + "@floating-ui/react": "^0.26.4", "@plane/editor-core": "*", "@plane/editor-extensions": "*", "@plane/ui": "*", + "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.1.13", "@tiptap/extension-placeholder": "^2.1.13", "@tiptap/pm": "^2.1.13", "@tiptap/suggestion": "^2.1.13", "eslint-config-next": "13.2.4", + "lucide-react": "^0.309.0", "react-popper": "^2.3.0", "tippy.js": "^6.3.7", "uuid": "^9.0.1" 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 new file mode 100644 index 00000000000..136d04e01e0 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -0,0 +1,148 @@ +import { isValidHttpUrl } from "@plane/editor-core"; +import { Node } from "@tiptap/pm/model"; +import { Link2Off } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { LinkViewProps } from "./link-view"; + +const InputView = ({ + label, + defaultValue, + placeholder, + onChange, +}: { + label: string; + defaultValue: string; + placeholder: string; + onChange: (e: React.ChangeEvent) => void; +}) => ( +
+ + { + e.stopPropagation(); + }} + className="w-[280px] outline-none bg-custom-background-90 text-custom-text-900 text-sm" + defaultValue={defaultValue} + onChange={onChange} + /> +
+); + +export const LinkEditView = ({ + viewProps, +}: { + viewProps: LinkViewProps; + switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; +}) => { + const { editor, from, to } = viewProps; + + const [positionRef, setPositionRef] = useState({ from: from, to: to }); + const [localUrl, setLocalUrl] = useState(viewProps.url); + + const linkRemoved = useRef(); + + const getText = (from: number, to: number) => { + const text = editor.state.doc.textBetween(from, to, "\n"); + return text; + }; + + const isValidUrl = (urlString: string) => { + var urlPattern = new RegExp( + "^(https?:\\/\\/)?" + // validate protocol + "([\\w-]+\\.)+[\\w-]{2,}" + // validate domain name + "|((\\d{1,3}\\.){3}\\d{1,3})" + // validate IP (v4) address + "(\\:\\d+)?(\\/[-\\w.%]+)*" + // validate port and path + "(\\?[;&\\w.%=-]*)?" + // validate query string + "(\\#[-\\w]*)?$", // validate fragment locator + "i" + ); + const regexTest = urlPattern.test(urlString); + const urlTest = isValidHttpUrl(urlString); // Ensure you have defined isValidHttpUrl + return regexTest && urlTest; + }; + + const handleUpdateLink = (url: string) => { + setLocalUrl(url); + }; + + useEffect( + () => () => { + if (linkRemoved.current) return; + + const url = isValidUrl(localUrl) ? localUrl : viewProps.url; + + 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] + ); + + const handleUpdateText = (text: string) => { + if (text === "") { + return; + } + + const node = editor.view.state.doc.nodeAt(from) as Node; + if (!node) return; + const marks = node.marks; + if (!marks) return; + + editor.chain().setTextSelection(from).run(); + + editor.chain().deleteRange({ from: positionRef.from, to: positionRef.to }).run(); + editor.chain().insertContent(text).run(); + + editor + .chain() + .setTextSelection({ + from: from, + to: from + text.length, + }) + .run(); + + setPositionRef({ from: from, to: from + text.length }); + + marks.forEach((mark) => { + editor.chain().setMark(mark.type.name, mark.attrs).run(); + }); + }; + + const removeLink = () => { + editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); + linkRemoved.current = true; + viewProps.onActionCompleteHandler({ + title: "Link successfully removed", + message: "The link was removed from the text.", + type: "success", + }); + viewProps.closeLinkView(); + }; + + return ( +
e.key === "Enter" && viewProps.closeLinkView()} + className="shadow-md rounded p-2 flex flex-col gap-3 bg-custom-background-90 border-custom-border-100 border-2" + > + handleUpdateLink(e.target.value)} + /> + handleUpdateText(e.target.value)} + /> +
+
+ + +
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx new file mode 100644 index 00000000000..fa73adbe1b2 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx @@ -0,0 +1,9 @@ +import { LinkViewProps } from "./link-view"; + +export const LinkInputView = ({ + viewProps, + switchView, +}: { + viewProps: LinkViewProps; + switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; +}) =>

LinkInputView

; diff --git a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx new file mode 100644 index 00000000000..ff3fd0263b5 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx @@ -0,0 +1,52 @@ +import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react"; +import { LinkViewProps } from "./link-view"; + +export const LinkPreview = ({ + viewProps, + switchView, +}: { + viewProps: LinkViewProps; + switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; +}) => { + const { editor, from, to, url } = viewProps; + + const removeLink = () => { + editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); + viewProps.onActionCompleteHandler({ + title: "Link successfully removed", + message: "The link was removed from the text.", + type: "success", + }); + viewProps.closeLinkView(); + }; + + const copyLinkToClipboard = () => { + navigator.clipboard.writeText(url); + viewProps.onActionCompleteHandler({ + title: "Link successfully copied", + message: "The link was copied to the clipboard.", + type: "success", + }); + viewProps.closeLinkView(); + }; + + return ( +
+
+ +

{url.length > 40 ? url.slice(0, 40) + "..." : url}

+
+ + + +
+
+
+ ); +}; diff --git a/packages/editor/document-editor/src/ui/components/links/link-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-view.tsx new file mode 100644 index 00000000000..f1d22a68e26 --- /dev/null +++ b/packages/editor/document-editor/src/ui/components/links/link-view.tsx @@ -0,0 +1,48 @@ +import { Editor } from "@tiptap/react"; +import { CSSProperties, useEffect, useState } from "react"; +import { LinkEditView } from "./link-edit-view"; +import { LinkInputView } from "./link-input-view"; +import { LinkPreview } from "./link-preview"; + +export interface LinkViewProps { + view?: "LinkPreview" | "LinkEditView" | "LinkInputView"; + editor: Editor; + from: number; + to: number; + url: string; + closeLinkView: () => void; + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; +} + +export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => { + const [currentView, setCurrentView] = useState(props.view ?? "LinkInputView"); + const [prevFrom, setPrevFrom] = useState(props.from); + + const switchView = (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => { + setCurrentView(view); + }; + + useEffect(() => { + if (props.from !== prevFrom) { + setCurrentView("LinkPreview"); + setPrevFrom(props.from); + } + }, []); + + const renderView = () => { + switch (currentView) { + case "LinkPreview": + return ; + case "LinkEditView": + return ; + case "LinkInputView": + return ; + } + }; + + return renderView(); +}; diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index c2d001abeed..c60ac0e7aee 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -1,43 +1,158 @@ import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; -import { Editor } from "@tiptap/react"; -import { useState } from "react"; +import { Node } from "@tiptap/pm/model"; +import { EditorView } from "@tiptap/pm/view"; +import { Editor, ReactRenderer } from "@tiptap/react"; +import { useCallback, useRef, useState } from "react"; import { DocumentDetails } from "src/types/editor-types"; +import { LinkView, LinkViewProps } from "./links/link-view"; +import { + autoUpdate, + computePosition, + flip, + hide, + shift, + useDismiss, + useFloating, + useInteractions, +} from "@floating-ui/react"; type IPageRenderer = { documentDetails: DocumentDetails; - updatePageTitle: (title: string) => Promise; + updatePageTitle: (title: string) => void; editor: Editor; + onActionCompleteHandler: (action: { + title: string; + message: string; + type: "success" | "error" | "warning" | "info"; + }) => void; editorClassNames: string; editorContentCustomClassNames?: string; + hideDragHandle?: () => void; readonly: boolean; }; -const debounce = (func: (...args: any[]) => void, wait: number) => { - let timeout: NodeJS.Timeout | null = null; - return function executedFunction(...args: any[]) { - const later = () => { - if (timeout) clearTimeout(timeout); - func(...args); - }; - if (timeout) clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -}; - export const PageRenderer = (props: IPageRenderer) => { - const { documentDetails, editor, editorClassNames, editorContentCustomClassNames, updatePageTitle, readonly } = props; + const { + documentDetails, + editor, + editorClassNames, + editorContentCustomClassNames, + updatePageTitle, + readonly, + hideDragHandle, + } = props; const [pageTitle, setPagetitle] = useState(documentDetails.title); - const debouncedUpdatePageTitle = debounce(updatePageTitle, 300); + const [linkViewProps, setLinkViewProps] = useState(); + const [isOpen, setIsOpen] = useState(false); + const [coordinates, setCoordinates] = useState<{ x: number; y: number }>(); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [flip(), shift(), hide({ strategy: "referenceHidden" })], + whileElementsMounted: autoUpdate, + }); + + const dismiss = useDismiss(context, { + ancestorScroll: true, + }); + + const { getFloatingProps } = useInteractions([dismiss]); const handlePageTitleChange = (title: string) => { setPagetitle(title); - debouncedUpdatePageTitle(title); + updatePageTitle(title); + }; + + const [cleanup, setcleanup] = useState(() => () => {}); + + const floatingElementRef = useRef(null); + + const closeLinkView = () => { + setIsOpen(false); }; + const handleLinkHover = useCallback( + (event: React.MouseEvent) => { + if (!editor) return; + const target = event.target as HTMLElement; + const view = editor.view as EditorView; + + if (!target || !view) return; + const pos = view.posAtDOM(target, 0); + if (!pos || pos < 0) return; + + if (target.nodeName !== "A") return; + + const node = view.state.doc.nodeAt(pos) as Node; + if (!node || !node.isAtom) return; + + // we need to check if any of the marks are links + const marks = node.marks; + + if (!marks) return; + + const linkMark = marks.find((mark) => mark.type.name === "link"); + + if (!linkMark) return; + + if (floatingElementRef.current) { + floatingElementRef.current?.remove(); + } + + if (cleanup) cleanup(); + + const href = linkMark.attrs.href; + const componentLink = new ReactRenderer(LinkView, { + props: { + view: "LinkPreview", + url: href, + editor: editor, + from: pos, + to: pos + node.nodeSize, + }, + editor, + }); + + const referenceElement = target as HTMLElement; + const floatingElement = componentLink.element as HTMLElement; + + floatingElementRef.current = floatingElement; + + const cleanupFunc = autoUpdate(referenceElement, floatingElement, () => { + computePosition(referenceElement, floatingElement, { + placement: "bottom", + middleware: [ + flip(), + shift(), + hide({ + strategy: "referenceHidden", + }), + ], + }).then(({ x, y }) => { + setCoordinates({ x: x - 300, y: y - 50 }); + setIsOpen(true); + setLinkViewProps({ + onActionCompleteHandler: props.onActionCompleteHandler, + closeLinkView: closeLinkView, + view: "LinkPreview", + url: href, + editor: editor, + from: pos, + to: pos + node.nodeSize, + }); + }); + }); + + setcleanup(cleanupFunc); + }, + [editor, cleanup] + ); + return ( -
+
{!readonly ? ( handlePageTitleChange(e.target.value)} @@ -52,11 +167,20 @@ export const PageRenderer = (props: IPageRenderer) => { disabled /> )} -
- +
+
+ {isOpen && linkViewProps && coordinates && ( +
+ +
+ )}
); }; diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index 155245f9e93..2576d0d74e7 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -1,55 +1,29 @@ import Placeholder from "@tiptap/extension-placeholder"; -import { IssueWidgetExtension } from "src/ui/extensions/widgets/issue-embed-widget"; - -import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types"; +import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget"; import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; -import { ISlashCommandItem, UploadImage } from "@plane/editor-core"; -import { IssueSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list"; -import { LayersIcon } from "@plane/ui"; +import { UploadImage } from "@plane/editor-core"; export const DocumentEditorExtensions = ( uploadFile: UploadImage, - issueEmbedConfig?: IIssueEmbedConfig, + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -) => { - const additionalOptions: ISlashCommandItem[] = [ - { - key: "issue_embed", - title: "Issue embed", - description: "Embed an issue from the project.", - searchTerms: ["issue", "link", "embed"], - icon: , - command: ({ editor, range }) => { - editor - .chain() - .focus() - .insertContentAt( - range, - "

#issue_

" - ) - .run(); - }, - }, - ]; +) => [ + SlashCommand(uploadFile, setIsSubmitting), + DragAndDrop(setHideDragHandle), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + if (node.type.name === "image" || node.type.name === "table") { + return ""; + } - return [ - SlashCommand(uploadFile, setIsSubmitting, additionalOptions), - DragAndDrop, - Placeholder.configure({ - placeholder: ({ node }) => { - if (node.type.name === "heading") { - return `Heading ${node.attrs.level}`; - } - if (node.type.name === "image" || node.type.name === "table") { - return ""; - } + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + IssueWidgetPlaceholder(), +]; - return "Press '/' for commands..."; - }, - includeChildren: true, - }), - IssueWidgetExtension({ issueEmbedConfig }), - IssueSuggestions(issueEmbedConfig ? issueEmbedConfig.issues : []), - ]; -}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx index acc6213c275..35a09bcc29a 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx @@ -24,7 +24,7 @@ export const IssueSuggestions = (suggestions: any[]) => { title: suggestion.name, priority: suggestion.priority.toString(), identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`, - state: suggestion.state_detail.name, + state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo", command: ({ editor, range }) => { editor .chain() diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx index 75d977e4916..96a5c1325b7 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx @@ -9,6 +9,8 @@ export const IssueEmbedSuggestions = Extension.create({ addOptions() { return { suggestion: { + char: "#issue_", + allowSpaces: true, command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { props.command({ editor, range }); }, @@ -18,11 +20,8 @@ export const IssueEmbedSuggestions = Extension.create({ addProseMirrorPlugins() { return [ Suggestion({ - char: "#issue_", pluginKey: new PluginKey("issue-embed-suggestions"), editor: this.editor, - allowSpaces: true, - ...this.options.suggestion, }), ]; 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 0a166c3e397..869c7a8c6f3 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 @@ -53,7 +53,7 @@ const IssueSuggestionList = ({ const commandListContainer = useRef(null); useEffect(() => { - let newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; + const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; let totalLength = 0; sections.forEach((section) => { newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5); @@ -65,8 +65,8 @@ const IssueSuggestionList = ({ }, [items]); const selectItem = useCallback( - (index: number) => { - const item = displayedItems[currentSection][index]; + (section: string, index: number) => { + const item = displayedItems[section][index]; if (item) { command(item); } @@ -78,7 +78,6 @@ const IssueSuggestionList = ({ const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; const onKeyDown = (e: KeyboardEvent) => { if (navigationKeys.includes(e.key)) { - e.preventDefault(); // if (editor.isFocused) { // editor.chain().blur(); // commandListContainer.current?.focus(); @@ -104,7 +103,7 @@ const IssueSuggestionList = ({ return true; } if (e.key === "Enter") { - selectItem(selectedIndex); + selectItem(currentSection, selectedIndex); return true; } if (e.key === "Tab") { @@ -146,7 +145,7 @@ const IssueSuggestionList = ({
{sections.map((section) => { const sectionItems = displayedItems[section]; @@ -172,7 +171,7 @@ const IssueSuggestionList = ({ } )} key={item.identifier} - onClick={() => selectItem(index)} + onClick={() => selectItem(section, index)} >
{item.identifier}
@@ -189,31 +188,37 @@ const IssueSuggestionList = ({
) : null; }; - export const IssueListRenderer = () => { let component: ReactRenderer | null = null; let popup: any | null = null; return { - onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + const container = document.querySelector(".frame-renderer") as HTMLElement; component = new ReactRenderer(IssueSuggestionList, { props, // @ts-ignore editor: props.editor, }); - // @ts-ignore - popup = tippy("body", { + popup = tippy(".frame-renderer", { + flipbehavior: ["bottom", "top"], + appendTo: () => document.querySelector(".frame-renderer") as HTMLElement, + flip: true, + flipOnUpdate: true, getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector("#editor-container"), content: component.element, showOnCreate: true, interactive: true, trigger: "manual", - placement: "right", + placement: "bottom-start", + }); + + container.addEventListener("scroll", () => { + popup?.[0].destroy(); }); }, - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component?.updateProps(props); popup && @@ -226,10 +231,20 @@ export const IssueListRenderer = () => { popup?.[0].hide(); return true; } - // @ts-ignore - return component?.ref?.onKeyDown(props); + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; + if (navigationKeys.includes(props.event.key)) { + // @ts-ignore + component?.ref?.onKeyDown(props); + return true; + } + return false; }, onExit: (e) => { + const container = document.querySelector(".frame-renderer") as HTMLElement; + if (container) { + container.removeEventListener("scroll", () => {}); + } popup?.[0].destroy(); setTimeout(() => { component?.destroy(); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx index 9bbb34aa523..264a701521e 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx @@ -1,11 +1,3 @@ import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node"; -import { IIssueEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types"; -interface IssueWidgetExtensionProps { - issueEmbedConfig?: IIssueEmbedConfig; -} - -export const IssueWidgetExtension = ({ issueEmbedConfig }: IssueWidgetExtensionProps) => - IssueWidget.configure({ - issueEmbedConfig, - }); +export const IssueWidgetPlaceholder = () => IssueWidget.configure({}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx index 78554c26d22..d3b6fd04f7d 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx @@ -1,76 +1,33 @@ // @ts-nocheck -import { useState, useEffect } from "react"; +import { Button } from "@plane/ui"; import { NodeViewWrapper } from "@tiptap/react"; -import { Avatar, AvatarGroup, Loader, PriorityIcon } from "@plane/ui"; -import { Calendar, AlertTriangle } from "lucide-react"; +import { Crown } from "lucide-react"; -export const IssueWidgetCard = (props) => { - const [loading, setLoading] = useState(1); - const [issueDetails, setIssueDetails] = useState(); - - useEffect(() => { - props.issueEmbedConfig - .fetchIssue(props.node.attrs.entity_identifier) - .then((issue) => { - setIssueDetails(issue); - setLoading(0); - }) - .catch((error) => { - console.log(error); - setLoading(-1); - }); - }, []); - - const completeIssueEmbedAction = () => { - props.issueEmbedConfig.clickAction(issueDetails.id, props.node.attrs.title); - }; - - return ( - - {loading == 0 ? ( -
-
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} -
-

{issueDetails.name}

-
-
- +export const IssueWidgetCard = (props) => ( + +
+
+ {props.node.attrs.project_identifier}-{props.node.attrs.sequence_id} +
+
+
+
+
+
-
- - {issueDetails.assignee_details.map((assignee) => ( - - ))} - +
+ Embed and access issues in pages seamlessly, upgrade to plane pro now.
- {issueDetails.target_date && ( -
- - {new Date(issueDetails.target_date).toLocaleDateString()} -
- )}
+ + +
- ) : loading == -1 ? ( -
- - {"This Issue embed is not found in any project. It can no longer be updated or accessed from here."} -
- ) : ( -
- - -
- - -
-
-
- )} - - ); -}; +
+
+ +); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx index c13637bd916..6c744927adc 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx @@ -34,9 +34,7 @@ export const IssueWidget = Node.create({ }, addNodeView() { - return ReactNodeViewRenderer((props: Object) => ( - - )); + return ReactNodeViewRenderer((props: Object) => ); }, parseHTML() { diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/types.ts b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/types.ts deleted file mode 100644 index 615b55dee57..00000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface IEmbedConfig { - issueEmbedConfig: IIssueEmbedConfig; -} - -export interface IIssueEmbedConfig { - fetchIssue: (issueId: string) => Promise; - clickAction: (issueId: string, issueTitle: string) => void; - issues: Array; -} diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index df35540246d..d1bdbc93545 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -10,13 +10,12 @@ import { DocumentDetails } from "src/types/editor-types"; import { PageRenderer } from "src/ui/components/page-renderer"; import { getMenuOptions } from "src/utils/menu-options"; import { useRouter } from "next/router"; -import { IEmbedConfig } from "src/ui/extensions/widgets/issue-embed-widget/types"; interface IDocumentEditor { // document info documentDetails: DocumentDetails; value: string; - rerenderOnPropsChange: { + rerenderOnPropsChange?: { id: string; description_html: string; }; @@ -39,7 +38,7 @@ interface IDocumentEditor { setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; setShouldShowAlert?: (showAlert: boolean) => void; forwardedRef?: any; - updatePageTitle: (title: string) => Promise; + updatePageTitle: (title: string) => void; debouncedUpdatesEnabled?: boolean; isSubmitting: "submitting" | "submitted" | "saved"; @@ -47,7 +46,6 @@ interface IDocumentEditor { duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; - embedConfig?: IEmbedConfig; } interface DocumentEditorProps extends IDocumentEditor { forwardedRef?: React.Ref; @@ -75,17 +73,23 @@ const DocumentEditor = ({ duplicationConfig, pageLockConfig, pageArchiveConfig, - embedConfig, updatePageTitle, cancelUploadImage, onActionCompleteHandler, rerenderOnPropsChange, }: IDocumentEditor) => { - // const [alert, setAlert] = useState("") const { markings, updateMarkings } = useEditorMarkings(); const [sidePeekVisible, setSidePeekVisible] = useState(true); const router = useRouter(); + const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); + + // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin + // loads such that we can invoke it from react when the cursor leaves the container + const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { + setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); + }; + const editor = useEditor({ onChange(json, html) { updateMarkings(json); @@ -104,7 +108,7 @@ const DocumentEditor = ({ cancelUploadImage, rerenderOnPropsChange, forwardedRef, - extensions: DocumentEditorExtensions(uploadFile, embedConfig?.issueEmbedConfig, setIsSubmitting), + extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting), }); if (!editor) { @@ -145,12 +149,14 @@ const DocumentEditor = ({ documentDetails={documentDetails} isSubmitting={isSubmitting} /> -
+
-
+
void; - embedConfig?: IEmbedConfig; } interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { @@ -51,7 +49,6 @@ const DocumentReadOnlyEditor = ({ pageDuplicationConfig, pageLockConfig, pageArchiveConfig, - embedConfig, rerenderOnPropsChange, onActionCompleteHandler, }: DocumentReadOnlyEditorProps) => { @@ -63,7 +60,7 @@ const DocumentReadOnlyEditor = ({ value, forwardedRef, rerenderOnPropsChange, - extensions: [IssueWidgetExtension({ issueEmbedConfig: embedConfig?.issueEmbedConfig })], + extensions: [IssueWidgetPlaceholder()], }); useEffect(() => { @@ -105,12 +102,13 @@ const DocumentReadOnlyEditor = ({ documentDetails={documentDetails} archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} /> -
+
-
+
Promise.resolve()} readonly={true} editor={editor} diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 94801928c9a..3392493201f 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.14.0", + "version": "0.15.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 269caad93f4..af99fec61f6 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -3,6 +3,7 @@ import { Extension } from "@tiptap/core"; import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state"; // @ts-ignore import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; +import React from "react"; function createDragHandleElement(): HTMLElement { const dragHandleElement = document.createElement("div"); @@ -30,6 +31,7 @@ function createDragHandleElement(): HTMLElement { export interface DragHandleOptions { dragHandleWidth: number; + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; } function absoluteRect(node: Element) { @@ -43,22 +45,23 @@ function absoluteRect(node: Element) { } function nodeDOMAtCoords(coords: { x: number; y: number }) { - return document.elementsFromPoint(coords.x, coords.y).find((elem: Element) => { - return ( - elem.parentElement?.matches?.(".ProseMirror") || - elem.matches( - [ - "li", - "p:not(:first-child)", - "pre", - "blockquote", - "h1, h2, h3", - "[data-type=horizontalRule]", - ".tableWrapper", - ].join(", ") - ) + return document + .elementsFromPoint(coords.x, coords.y) + .find( + (elem: Element) => + elem.parentElement?.matches?.(".ProseMirror") || + elem.matches( + [ + "li", + "p:not(:first-child)", + "pre", + "blockquote", + "h1, h2, h3", + "[data-type=horizontalRule]", + ".tableWrapper", + ].join(", ") + ) ); - }); } function nodePosAtDOM(node: Element, view: EditorView) { @@ -150,6 +153,8 @@ function DragHandle(options: DragHandleOptions) { } } + options.setHideDragHandle?.(hideDragHandle); + return new Plugin({ key: new PluginKey("dragHandle"), view: (view) => { @@ -237,14 +242,16 @@ function DragHandle(options: DragHandleOptions) { }); } -export const DragAndDrop = Extension.create({ - name: "dragAndDrop", - - addProseMirrorPlugins() { - return [ - DragHandle({ - dragHandleWidth: 24, - }), - ]; - }, -}); +export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) => + Extension.create({ + name: "dragAndDrop", + + addProseMirrorPlugins() { + return [ + DragHandle({ + dragHandleWidth: 24, + setHideDragHandle, + }), + ]; + }, + }); diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 7882d60ff2e..2272b903061 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.14.0", + "version": "0.15.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/extensions/enter-key-extension.tsx b/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx index 129efa4ee61..7d93bf36f42 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx +++ b/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx @@ -4,13 +4,16 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ name: "enterKey", - addKeyboardShortcuts() { + addKeyboardShortcuts(this) { return { Enter: () => { - if (onEnterKeyPress) { - onEnterKeyPress(); + if (!this.editor.storage.mentionsOpen) { + if (onEnterKeyPress) { + onEnterKeyPress(); + } + return true; } - return true; + return false; }, "Shift-Enter": ({ editor }) => editor.commands.first(({ commands }) => [ diff --git a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx index 527fd567430..c4b24d166a5 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx @@ -1,5 +1,3 @@ import { EnterKeyExtension } from "src/ui/extensions/enter-key-extension"; -export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [ - // EnterKeyExtension(onEnterKeyPress), -]; +export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [EnterKeyExtension(onEnterKeyPress)]; diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 245248d458d..7bd0920da7b 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.14.0", + "version": "0.15.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index 1e81c8173c0..3d1da6cdab7 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,14 +1,15 @@ -import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; -import Placeholder from "@tiptap/extension-placeholder"; import { UploadImage } from "@plane/editor-core"; +import { DragAndDrop, SlashCommand } from "@plane/editor-extensions"; +import Placeholder from "@tiptap/extension-placeholder"; export const RichTextEditorExtensions = ( uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, - dragDropEnabled?: boolean + dragDropEnabled?: boolean, + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void ) => [ SlashCommand(uploadFile, setIsSubmitting), - dragDropEnabled === true && DragAndDrop, + dragDropEnabled === true && DragAndDrop(setHideDragHandle), Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 17d701600f7..43c3f8f3432 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -1,5 +1,4 @@ "use client"; -import * as React from "react"; import { DeleteImage, EditorContainer, @@ -10,8 +9,9 @@ import { UploadImage, useEditor, } from "@plane/editor-core"; -import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; +import * as React from "react"; import { RichTextEditorExtensions } from "src/ui/extensions"; +import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { value: string; @@ -66,6 +66,14 @@ const RichTextEditor = ({ rerenderOnPropsChange, mentionSuggestions, }: RichTextEditorProps) => { + const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); + + // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin + // loads such that we can invoke it from react when the cursor leaves the container + const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { + setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); + }; + const editor = useEditor({ onChange, debouncedUpdatesEnabled, @@ -78,7 +86,7 @@ const RichTextEditor = ({ restoreFile, forwardedRef, rerenderOnPropsChange, - extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled), + extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction), mentionHighlights, mentionSuggestions, }); @@ -92,7 +100,7 @@ const RichTextEditor = ({ if (!editor) return null; return ( - + {editor && }
diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 5237bf033f8..93ad475f883 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.14.0", + "version": "0.15.0", "main": "index.js", "license": "MIT", "dependencies": { diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 213367b4f8e..b04497011ca 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.14.0", + "version": "0.15.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 97f7cab8435..3465b819671 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -27,6 +27,7 @@ module.exports = { "custom-shadow-xl": "var(--color-shadow-xl)", "custom-shadow-2xl": "var(--color-shadow-2xl)", "custom-shadow-3xl": "var(--color-shadow-3xl)", + "custom-shadow-4xl": "var(--color-shadow-4xl)", "custom-sidebar-shadow-2xs": "var(--color-sidebar-shadow-2xs)", "custom-sidebar-shadow-xs": "var(--color-sidebar-shadow-xs)", "custom-sidebar-shadow-sm": "var(--color-sidebar-shadow-sm)", @@ -36,8 +37,8 @@ module.exports = { "custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)", "custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)", "custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)", - "onbording-shadow-sm": "var(--color-onboarding-shadow-sm)", - + "custom-sidebar-shadow-4xl": "var(--color-sidebar-shadow-4xl)", + "onboarding-shadow-sm": "var(--color-onboarding-shadow-sm)", }, colors: { custom: { @@ -212,7 +213,7 @@ module.exports = { to: { left: "100%" }, }, }, - typography: ({ theme }) => ({ + typography: () => ({ brand: { css: { "--tw-prose-body": convertToRGB("--color-text-100"), @@ -225,12 +226,12 @@ module.exports = { "--tw-prose-bullets": convertToRGB("--color-text-100"), "--tw-prose-hr": convertToRGB("--color-text-100"), "--tw-prose-quotes": convertToRGB("--color-text-100"), - "--tw-prose-quote-borders": convertToRGB("--color-border"), + "--tw-prose-quote-borders": convertToRGB("--color-border-200"), "--tw-prose-code": convertToRGB("--color-text-100"), "--tw-prose-pre-code": convertToRGB("--color-text-100"), "--tw-prose-pre-bg": convertToRGB("--color-background-100"), - "--tw-prose-th-borders": convertToRGB("--color-border"), - "--tw-prose-td-borders": convertToRGB("--color-border"), + "--tw-prose-th-borders": convertToRGB("--color-border-200"), + "--tw-prose-td-borders": convertToRGB("--color-border-200"), }, }, }), diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index a23b1b3c28f..0ad6f2ba924 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.14.0", + "version": "0.15.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 00000000000..8fa98db3059 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,6 @@ +{ + "name": "@plane/types", + "version": "0.15.0", + "private": true, + "main": "./src/index.d.ts" +} diff --git a/web/types/ai.d.ts b/packages/types/src/ai.d.ts similarity index 73% rename from web/types/ai.d.ts rename to packages/types/src/ai.d.ts index 6c933a03397..ce8bcbadb2f 100644 --- a/web/types/ai.d.ts +++ b/packages/types/src/ai.d.ts @@ -1,4 +1,4 @@ -import { IProjectLite, IWorkspaceLite } from "types"; +import { IProjectLite, IWorkspaceLite } from "@plane/types"; export interface IGptResponse { response: string; diff --git a/web/types/analytics.d.ts b/packages/types/src/analytics.d.ts similarity index 100% rename from web/types/analytics.d.ts rename to packages/types/src/analytics.d.ts diff --git a/web/types/api_token.d.ts b/packages/types/src/api_token.d.ts similarity index 100% rename from web/types/api_token.d.ts rename to packages/types/src/api_token.d.ts diff --git a/web/types/app.d.ts b/packages/types/src/app.d.ts similarity index 72% rename from web/types/app.d.ts rename to packages/types/src/app.d.ts index 0122cf73a7e..06a433ddda5 100644 --- a/web/types/app.d.ts +++ b/packages/types/src/app.d.ts @@ -1,18 +1,14 @@ -export type NextPageWithLayout

= NextPage & { - getLayout?: (page: ReactElement) => ReactNode; -}; - export interface IAppConfig { email_password_login: boolean; file_size_limit: number; - google_client_id: string | null; github_app_name: string | null; github_client_id: string | null; + google_client_id: string | null; + has_openai_configured: boolean; + has_unsplash_configured: boolean; + is_smtp_configured: boolean; magic_login: boolean; - slack_client_id: string | null; posthog_api_key: string | null; posthog_host: string | null; - has_openai_configured: boolean; - has_unsplash_configured: boolean; - is_self_managed: boolean; + slack_client_id: string | null; } diff --git a/web/types/auth.d.ts b/packages/types/src/auth.d.ts similarity index 100% rename from web/types/auth.d.ts rename to packages/types/src/auth.d.ts diff --git a/web/types/calendar.ts b/packages/types/src/calendar.d.ts similarity index 100% rename from web/types/calendar.ts rename to packages/types/src/calendar.d.ts diff --git a/web/types/cycles.d.ts b/packages/types/src/cycles.d.ts similarity index 82% rename from web/types/cycles.d.ts rename to packages/types/src/cycles.d.ts index 4f243deeb23..12cbab4c61a 100644 --- a/web/types/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -1,4 +1,11 @@ -import type { IUser, IIssue, IProjectLite, IWorkspaceLite, IIssueFilterOptions, IUserLite } from "types"; +import type { + IUser, + TIssue, + IProjectLite, + IWorkspaceLite, + IIssueFilterOptions, + IUserLite, +} from "@plane/types"; export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft"; @@ -23,7 +30,7 @@ export interface ICycle { is_favorite: boolean; issue: string; name: string; - owned_by: IUser; + owned_by: string; project: string; project_detail: IProjectLite; status: TCycleGroups; @@ -54,7 +61,7 @@ export type TAssigneesDistribution = { }; export type TCompletionChartDistribution = { - [key: string]: number; + [key: string]: number | null; }; export type TLabelsDistribution = { @@ -68,7 +75,7 @@ export type TLabelsDistribution = { export interface CycleIssueResponse { id: string; - issue_detail: IIssue; + issue_detail: TIssue; created_at: Date; updated_at: Date; created_by: string; @@ -80,9 +87,13 @@ export interface CycleIssueResponse { sub_issues_count: number; } -export type SelectCycleType = (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined; +export type SelectCycleType = + | (ICycle & { actionType: "edit" | "delete" | "create-issue" }) + | undefined; -export type SelectIssue = (IIssue & { actionType: "edit" | "delete" | "create" }) | null; +export type SelectIssue = + | (TIssue & { actionType: "edit" | "delete" | "create" }) + | null; export type CycleDateCheckData = { start_date: string; diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts new file mode 100644 index 00000000000..31751c0d06c --- /dev/null +++ b/packages/types/src/dashboard.d.ts @@ -0,0 +1,175 @@ +import { IIssueActivity, TIssuePriorities } from "./issues"; +import { TIssue } from "./issues/issue"; +import { TIssueRelationTypes } from "./issues/issue_relation"; +import { TStateGroups } from "./state"; + +export type TWidgetKeys = + | "overview_stats" + | "assigned_issues" + | "created_issues" + | "issues_by_state_groups" + | "issues_by_priority" + | "recent_activity" + | "recent_projects" + | "recent_collaborators"; + +export type TIssuesListTypes = "upcoming" | "overdue" | "completed"; + +export type TDurationFilterOptions = + | "today" + | "this_week" + | "this_month" + | "this_year"; + +// widget filters +export type TAssignedIssuesWidgetFilters = { + target_date?: TDurationFilterOptions; + tab?: TIssuesListTypes; +}; + +export type TCreatedIssuesWidgetFilters = { + target_date?: TDurationFilterOptions; + tab?: TIssuesListTypes; +}; + +export type TIssuesByStateGroupsWidgetFilters = { + target_date?: TDurationFilterOptions; +}; + +export type TIssuesByPriorityWidgetFilters = { + target_date?: TDurationFilterOptions; +}; + +export type TWidgetFiltersFormData = + | { + widgetKey: "assigned_issues"; + filters: Partial; + } + | { + widgetKey: "created_issues"; + filters: Partial; + } + | { + widgetKey: "issues_by_state_groups"; + filters: Partial; + } + | { + widgetKey: "issues_by_priority"; + filters: Partial; + }; + +export type TWidget = { + id: string; + is_visible: boolean; + key: TWidgetKeys; + readonly widget_filters: // only for read + TAssignedIssuesWidgetFilters & + TCreatedIssuesWidgetFilters & + TIssuesByStateGroupsWidgetFilters & + TIssuesByPriorityWidgetFilters; + filters: // only for write + TAssignedIssuesWidgetFilters & + TCreatedIssuesWidgetFilters & + TIssuesByStateGroupsWidgetFilters & + TIssuesByPriorityWidgetFilters; +}; + +export type TWidgetStatsRequestParams = + | { + widget_key: TWidgetKeys; + } + | { + target_date: string; + issue_type: TIssuesListTypes; + widget_key: "assigned_issues"; + expand?: "issue_relation"; + } + | { + target_date: string; + issue_type: TIssuesListTypes; + widget_key: "created_issues"; + } + | { + target_date: string; + widget_key: "issues_by_state_groups"; + } + | { + target_date: string; + widget_key: "issues_by_priority"; + }; + +export type TWidgetIssue = TIssue & { + issue_relation: { + id: string; + project_id: string; + relation_type: TIssueRelationTypes; + sequence_id: number; + }[]; +}; + +// widget stats responses +export type TOverviewStatsWidgetResponse = { + assigned_issues_count: number; + completed_issues_count: number; + created_issues_count: number; + pending_issues_count: number; +}; + +export type TAssignedIssuesWidgetResponse = { + issues: TWidgetIssue[]; + count: number; +}; + +export type TCreatedIssuesWidgetResponse = { + issues: TWidgetIssue[]; + count: number; +}; + +export type TIssuesByStateGroupsWidgetResponse = { + count: number; + state: TStateGroups; +}; + +export type TIssuesByPriorityWidgetResponse = { + count: number; + priority: TIssuePriorities; +}; + +export type TRecentActivityWidgetResponse = IIssueActivity; + +export type TRecentProjectsWidgetResponse = string[]; + +export type TRecentCollaboratorsWidgetResponse = { + active_issue_count: number; + user_id: string; +}; + +export type TWidgetStatsResponse = + | TOverviewStatsWidgetResponse + | TIssuesByStateGroupsWidgetResponse[] + | TIssuesByPriorityWidgetResponse[] + | TAssignedIssuesWidgetResponse + | TCreatedIssuesWidgetResponse + | TRecentActivityWidgetResponse[] + | TRecentProjectsWidgetResponse + | TRecentCollaboratorsWidgetResponse[]; + +// dashboard +export type TDashboard = { + created_at: string; + created_by: string | null; + description_html: string; + id: string; + identifier: string | null; + is_default: boolean; + name: string; + owned_by: string; + type: string; + updated_at: string; + updated_by: string | null; +}; + +export type THomeDashboardResponse = { + dashboard: TDashboard; + widgets: TWidget[]; +}; diff --git a/web/types/estimate.d.ts b/packages/types/src/estimate.d.ts similarity index 100% rename from web/types/estimate.d.ts rename to packages/types/src/estimate.d.ts index 32925c79328..96b584ce1fc 100644 --- a/web/types/estimate.d.ts +++ b/packages/types/src/estimate.d.ts @@ -1,24 +1,24 @@ export interface IEstimate { - id: string; created_at: Date; - updated_at: Date; - name: string; - description: string; created_by: string; - updated_by: string; - points: IEstimatePoint[]; + description: string; + id: string; + name: string; project: string; project_detail: IProject; + updated_at: Date; + updated_by: string; + points: IEstimatePoint[]; workspace: string; workspace_detail: IWorkspace; } export interface IEstimatePoint { - id: string; created_at: string; created_by: string; description: string; estimate: string; + id: string; key: number; project: string; updated_at: string; diff --git a/web/types/importer/github-importer.d.ts b/packages/types/src/importer/github-importer.d.ts similarity index 100% rename from web/types/importer/github-importer.d.ts rename to packages/types/src/importer/github-importer.d.ts diff --git a/web/types/importer/index.ts b/packages/types/src/importer/index.d.ts similarity index 92% rename from web/types/importer/index.ts rename to packages/types/src/importer/index.d.ts index 81e1bb22fab..877c0719690 100644 --- a/web/types/importer/index.ts +++ b/packages/types/src/importer/index.d.ts @@ -1,9 +1,9 @@ export * from "./github-importer"; export * from "./jira-importer"; -import { IProjectLite } from "types/projects"; +import { IProjectLite } from "../projects"; // types -import { IUserLite } from "types/users"; +import { IUserLite } from "../users"; export interface IImporterService { created_at: string; diff --git a/web/types/importer/jira-importer.d.ts b/packages/types/src/importer/jira-importer.d.ts similarity index 100% rename from web/types/importer/jira-importer.d.ts rename to packages/types/src/importer/jira-importer.d.ts diff --git a/web/types/inbox.d.ts b/packages/types/src/inbox.d.ts similarity index 74% rename from web/types/inbox.d.ts rename to packages/types/src/inbox.d.ts index 10fc37b31f1..4d666ae8356 100644 --- a/web/types/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -1,7 +1,13 @@ -import { IIssue } from "./issues"; +import { TIssue } from "./issues/base"; import type { IProjectLite } from "./projects"; -export interface IInboxIssue extends IIssue { +export type TInboxIssueExtended = { + completed_at: string | null; + start_date: string | null; + target_date: string | null; +}; + +export interface IInboxIssue extends TIssue, TInboxIssueExtended { issue_inbox: { duplicate_to: string | null; id: string; @@ -48,7 +54,12 @@ interface StatusDuplicate { duplicate_to: string; } -export type TInboxStatus = StatusReject | StatusSnoozed | StatusAccepted | StatusDuplicate | StatePending; +export type TInboxStatus = + | StatusReject + | StatusSnoozed + | StatusAccepted + | StatusDuplicate + | StatePending; export interface IInboxFilterOptions { priority?: string[] | null; diff --git a/packages/types/src/inbox/inbox-issue.d.ts b/packages/types/src/inbox/inbox-issue.d.ts new file mode 100644 index 00000000000..c7d33f75b6a --- /dev/null +++ b/packages/types/src/inbox/inbox-issue.d.ts @@ -0,0 +1,65 @@ +import { TIssue } from "../issues/base"; + +export enum EInboxStatus { + PENDING = -2, + REJECT = -1, + SNOOZED = 0, + ACCEPTED = 1, + DUPLICATE = 2, +} + +export type TInboxStatus = + | EInboxStatus.PENDING + | EInboxStatus.REJECT + | EInboxStatus.SNOOZED + | EInboxStatus.ACCEPTED + | EInboxStatus.DUPLICATE; + +export type TInboxIssueDetail = { + id?: string; + source: "in-app"; + status: TInboxStatus; + duplicate_to: string | undefined; + snoozed_till: Date | undefined; +}; + +export type TInboxIssueDetailMap = Record< + string, + Record +>; // inbox_id -> issue_id -> TInboxIssueDetail + +export type TInboxIssueDetailIdMap = Record; // inbox_id -> issue_id[] + +export type TInboxIssueExtendedDetail = TIssue & { + issue_inbox: TInboxIssueDetail[]; +}; + +// property type checks +export type TInboxPendingStatus = { + status: EInboxStatus.PENDING; +}; + +export type TInboxRejectStatus = { + status: EInboxStatus.REJECT; +}; + +export type TInboxSnoozedStatus = { + status: EInboxStatus.SNOOZED; + snoozed_till: Date; +}; + +export type TInboxAcceptedStatus = { + status: EInboxStatus.ACCEPTED; +}; + +export type TInboxDuplicateStatus = { + status: EInboxStatus.DUPLICATE; + duplicate_to: string; // issue_id +}; + +export type TInboxDetailedStatus = + | TInboxPendingStatus + | TInboxRejectStatus + | TInboxSnoozedStatus + | TInboxAcceptedStatus + | TInboxDuplicateStatus; diff --git a/packages/types/src/inbox/inbox.d.ts b/packages/types/src/inbox/inbox.d.ts new file mode 100644 index 00000000000..1b4e23e0fc4 --- /dev/null +++ b/packages/types/src/inbox/inbox.d.ts @@ -0,0 +1,27 @@ +export type TInboxIssueFilterOptions = { + priority: string[]; + inbox_status: number[]; +}; + +export type TInboxIssueQueryParams = "priority" | "inbox_status"; + +export type TInboxIssueFilters = { filters: TInboxIssueFilterOptions }; + +export type TInbox = { + id: string; + name: string; + description: string; + workspace: string; + project: string; + is_default: boolean; + view_props: TInboxIssueFilters; + created_by: string; + updated_by: string; + created_at: Date; + updated_at: Date; + pending_issue_count: number; +}; + +export type TInboxDetailMap = Record; // inbox_id -> TInbox + +export type TInboxDetailIdMap = Record; // project_id -> inbox_id[] diff --git a/packages/types/src/inbox/root.d.ts b/packages/types/src/inbox/root.d.ts new file mode 100644 index 00000000000..2f10c088def --- /dev/null +++ b/packages/types/src/inbox/root.d.ts @@ -0,0 +1,2 @@ +export * from "./inbox"; +export * from "./inbox-issue"; diff --git a/web/types/index.d.ts b/packages/types/src/index.d.ts similarity index 71% rename from web/types/index.d.ts rename to packages/types/src/index.d.ts index 9f27e818ca1..6e8ded94296 100644 --- a/web/types/index.d.ts +++ b/packages/types/src/index.d.ts @@ -1,6 +1,7 @@ export * from "./users"; export * from "./workspace"; export * from "./cycles"; +export * from "./dashboard"; export * from "./projects"; export * from "./state"; export * from "./invitation"; @@ -12,7 +13,11 @@ export * from "./pages"; export * from "./ai"; export * from "./estimate"; export * from "./importer"; + +// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable export * from "./inbox"; +export * from "./inbox/root"; + export * from "./analytics"; export * from "./calendar"; export * from "./notifications"; @@ -21,6 +26,11 @@ export * from "./reaction"; export * from "./view-props"; export * from "./workspace-views"; export * from "./webhook"; +export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable +export * from "./auth"; +export * from "./api_token"; +export * from "./instance"; +export * from "./app"; export type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object diff --git a/web/types/instance.d.ts b/packages/types/src/instance.d.ts similarity index 100% rename from web/types/instance.d.ts rename to packages/types/src/instance.d.ts diff --git a/web/types/integration.d.ts b/packages/types/src/integration.d.ts similarity index 100% rename from web/types/integration.d.ts rename to packages/types/src/integration.d.ts diff --git a/web/types/issues.d.ts b/packages/types/src/issues.d.ts similarity index 68% rename from web/types/issues.d.ts rename to packages/types/src/issues.d.ts index 09f21eb3a5d..c54943f901d 100644 --- a/web/types/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -1,7 +1,6 @@ +import { ReactElement } from "react"; import { KeyedMutator } from "swr"; import type { - IState, - IUser, ICycle, IModule, IUserLite, @@ -10,7 +9,8 @@ import type { IStateLite, Properties, IIssueDisplayFilterOptions, -} from "types"; + TIssue, +} from "@plane/types"; export interface IIssueCycle { id: string; @@ -76,58 +76,6 @@ export interface IssueRelation { relation: "blocking" | null; } -export interface IIssue { - archived_at: string; - assignees: string[]; - assignee_details: IUser[]; - attachment_count: number; - attachments: any[]; - issue_relations: IssueRelation[]; - related_issues: IssueRelation[]; - bridge_id?: string | null; - completed_at: Date; - created_at: string; - created_by: string; - cycle: string | null; - cycle_id: string | null; - cycle_detail: ICycle | null; - description: any; - description_html: any; - description_stripped: any; - estimate_point: number | null; - id: string; - // tempId is used for optimistic updates. It is not a part of the API response. - tempId?: string; - issue_cycle: IIssueCycle | null; - issue_link: ILinkDetails[]; - issue_module: IIssueModule | null; - labels: string[]; - label_details: any[]; - is_draft: boolean; - links_list: IIssueLink[]; - link_count: number; - module: string | null; - module_id: string | null; - name: string; - parent: string | null; - parent_detail: IIssueParent | null; - priority: TIssuePriorities; - project: string; - project_detail: IProjectLite; - sequence_id: number; - sort_order: number; - sprints: string | null; - start_date: string | null; - state: string; - state_detail: IState; - sub_issues_count: number; - target_date: string | null; - updated_at: string; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} - export interface ISubIssuesState { backlog: number; unstarted: number; @@ -138,7 +86,7 @@ export interface ISubIssuesState { export interface ISubIssueResponse { state_distribution: ISubIssuesState; - sub_issues: IIssue[]; + sub_issues: TIssue[]; } export interface BlockeIssueDetail { @@ -161,17 +109,10 @@ export type IssuePriorities = { export interface IIssueLabel { id: string; - created_at: Date; - updated_at: Date; name: string; - description: string; color: string; - created_by: string; - updated_by: string; - project: string; - project_detail: IProjectLite; - workspace: string; - workspace_detail: IWorkspaceLite; + project_id: string; + workspace_id: string; parent: string | null; sort_order: number; } @@ -240,13 +181,13 @@ export interface IIssueAttachment { } export interface IIssueViewProps { - groupedIssues: { [key: string]: IIssue[] } | undefined; + groupedIssues: { [key: string]: TIssue[] } | undefined; displayFilters: IIssueDisplayFilterOptions | undefined; isEmpty: boolean; mutateIssues: KeyedMutator< - | IIssue[] + | TIssue[] | { - [key: string]: IIssue[]; + [key: string]: TIssue[]; } >; params: any; @@ -254,3 +195,29 @@ export interface IIssueViewProps { } export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; + +export interface ViewFlags { + enableQuickAdd: boolean; + enableIssueCreation: boolean; + enableInlineEditing: boolean; +} + +export type GroupByColumnTypes = + | "project" + | "state" + | "state_detail.group" + | "priority" + | "labels" + | "assignees" + | "created_by"; + +export interface IGroupByColumn { + id: string; + name: string; + icon: ReactElement | undefined; + payload: Partial; +} + +export interface IIssueMap { + [key: string]: TIssue; +} diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts new file mode 100644 index 00000000000..9f17d78c7de --- /dev/null +++ b/packages/types/src/issues/activity/base.d.ts @@ -0,0 +1,58 @@ +export * from "./issue_activity"; +export * from "./issue_comment"; +export * from "./issue_comment_reaction"; + +import { TIssuePriorities } from "../issues"; + +// root types +export type TIssueActivityWorkspaceDetail = { + name: string; + slug: string; + id: string; +}; + +export type TIssueActivityProjectDetail = { + id: string; + identifier: string; + name: string; + cover_image: string; + description: string | null; + emoji: string | null; + icon_prop: { + name: string; + color: string; + } | null; +}; + +export type TIssueActivityIssueDetail = { + id: string; + sequence_id: boolean; + sort_order: boolean; + name: string; + description_html: string; + priority: TIssuePriorities; + start_date: string; + target_date: string; + is_draft: boolean; +}; + +export type TIssueActivityUserDetail = { + id: string; + first_name: string; + last_name: string; + avatar: string; + is_bot: boolean; + display_name: string; +}; + +export type TIssueActivityComment = + | { + id: string; + activity_type: "COMMENT"; + created_at?: string; + } + | { + id: string; + activity_type: "ACTIVITY"; + created_at?: string; + }; diff --git a/packages/types/src/issues/activity/issue_activity.d.ts b/packages/types/src/issues/activity/issue_activity.d.ts new file mode 100644 index 00000000000..391d06c1276 --- /dev/null +++ b/packages/types/src/issues/activity/issue_activity.d.ts @@ -0,0 +1,41 @@ +import { + TIssueActivityWorkspaceDetail, + TIssueActivityProjectDetail, + TIssueActivityIssueDetail, + TIssueActivityUserDetail, +} from "./base"; + +export type TIssueActivity = { + id: string; + workspace: string; + workspace_detail: TIssueActivityWorkspaceDetail; + project: string; + project_detail: TIssueActivityProjectDetail; + issue: string; + issue_detail: TIssueActivityIssueDetail; + actor: string; + actor_detail: TIssueActivityUserDetail; + created_at: string; + updated_at: string; + created_by: string | undefined; + updated_by: string | undefined; + attachments: any[]; + + verb: string; + field: string | undefined; + old_value: string | undefined; + new_value: string | undefined; + comment: string | undefined; + old_identifier: string | undefined; + new_identifier: string | undefined; + epoch: number; + issue_comment: string | null; +}; + +export type TIssueActivityMap = { + [issue_id: string]: TIssueActivity; +}; + +export type TIssueActivityIdMap = { + [issue_id: string]: string[]; +}; diff --git a/packages/types/src/issues/activity/issue_comment.d.ts b/packages/types/src/issues/activity/issue_comment.d.ts new file mode 100644 index 00000000000..45d34be0829 --- /dev/null +++ b/packages/types/src/issues/activity/issue_comment.d.ts @@ -0,0 +1,39 @@ +import { + TIssueActivityWorkspaceDetail, + TIssueActivityProjectDetail, + TIssueActivityIssueDetail, + TIssueActivityUserDetail, +} from "./base"; + +export type TIssueComment = { + id: string; + workspace: string; + workspace_detail: TIssueActivityWorkspaceDetail; + project: string; + project_detail: TIssueActivityProjectDetail; + issue: string; + issue_detail: TIssueActivityIssueDetail; + actor: string; + actor_detail: TIssueActivityUserDetail; + created_at: string; + updated_at: string; + created_by: string | undefined; + updated_by: string | undefined; + attachments: any[]; + + comment_reactions: any[]; + comment_stripped: string; + comment_html: string; + comment_json: object; + external_id: string | undefined; + external_source: string | undefined; + access: "EXTERNAL" | "INTERNAL"; +}; + +export type TIssueCommentMap = { + [issue_id: string]: TIssueComment; +}; + +export type TIssueCommentIdMap = { + [issue_id: string]: string[]; +}; diff --git a/packages/types/src/issues/activity/issue_comment_reaction.d.ts b/packages/types/src/issues/activity/issue_comment_reaction.d.ts new file mode 100644 index 00000000000..892a3e9065c --- /dev/null +++ b/packages/types/src/issues/activity/issue_comment_reaction.d.ts @@ -0,0 +1,20 @@ +export type TIssueCommentReaction = { + id: string; + comment: string; + actor: string; + reaction: string; + workspace: string; + project: string; + created_at: Date; + updated_at: Date; + created_by: string; + updated_by: string; +}; + +export type TIssueCommentReactionMap = { + [reaction_id: string]: TIssueCommentReaction; +}; + +export type TIssueCommentReactionIdMap = { + [comment_id: string]: { [reaction: string]: string[] }; +}; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts new file mode 100644 index 00000000000..ae210d3b12d --- /dev/null +++ b/packages/types/src/issues/base.d.ts @@ -0,0 +1,22 @@ +// issues +export * from "./issue"; +export * from "./issue_reaction"; +export * from "./issue_link"; +export * from "./issue_attachment"; +export * from "./issue_relation"; +export * from "./issue_sub_issues"; +export * from "./activity/base"; + +export type TLoader = "init-loader" | "mutation" | undefined; + +export type TGroupedIssues = { + [group_id: string]: string[]; +}; + +export type TSubGroupedIssues = { + [sub_grouped_id: string]: { + [group_id: string]: string[]; + }; +}; + +export type TUnGroupedIssues = string[]; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts new file mode 100644 index 00000000000..527abe63038 --- /dev/null +++ b/packages/types/src/issues/issue.d.ts @@ -0,0 +1,45 @@ +import { TIssuePriorities } from "../issues"; + +// new issue structure types +export type TIssue = { + id: string; + sequence_id: number; + name: string; + description_html: string; + sort_order: number; + + state_id: string; + priority: TIssuePriorities; + label_ids: string[]; + assignee_ids: string[]; + estimate_point: number | null; + + sub_issues_count: number; + attachment_count: number; + link_count: number; + + project_id: string; + parent_id: string | null; + cycle_id: string | null; + module_ids: string[] | null; + + created_at: string; + updated_at: string; + start_date: string | null; + target_date: string | null; + completed_at: string | null; + archived_at: string | null; + + created_by: string; + updated_by: string; + + is_draft: boolean; + is_subscribed: boolean; + + // tempId is used for optimistic updates. It is not a part of the API response. + tempId?: string; +}; + +export type TIssueMap = { + [issue_id: string]: TIssue; +}; diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts new file mode 100644 index 00000000000..90daa08faeb --- /dev/null +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -0,0 +1,23 @@ +export type TIssueAttachment = { + id: string; + created_at: string; + updated_at: string; + attributes: { + name: string; + size: number; + }; + asset: string; + created_by: string; + updated_by: string; + project: string; + workspace: string; + issue: string; +}; + +export type TIssueAttachmentMap = { + [issue_id: string]: TIssueAttachment; +}; + +export type TIssueAttachmentIdMap = { + [issue_id: string]: string[]; +}; diff --git a/packages/types/src/issues/issue_link.d.ts b/packages/types/src/issues/issue_link.d.ts new file mode 100644 index 00000000000..2c469e6829b --- /dev/null +++ b/packages/types/src/issues/issue_link.d.ts @@ -0,0 +1,20 @@ +export type TIssueLinkEditableFields = { + title: string; + url: string; +}; + +export type TIssueLink = TIssueLinkEditableFields & { + created_at: Date; + created_by: string; + created_by_detail: IUserLite; + id: string; + metadata: any; +}; + +export type TIssueLinkMap = { + [issue_id: string]: TIssueLink; +}; + +export type TIssueLinkIdMap = { + [issue_id: string]: string[]; +}; diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts new file mode 100644 index 00000000000..88ef274261a --- /dev/null +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -0,0 +1,21 @@ +export type TIssueReaction = { + actor: string; + actor_detail: IUserLite; + created_at: Date; + created_by: string; + id: string; + issue: string; + project: string; + reaction: string; + updated_at: Date; + updated_by: string; + workspace: string; +}; + +export type TIssueReactionMap = { + [reaction_id: string]: TIssueReaction; +}; + +export type TIssueReactionIdMap = { + [issue_id: string]: { [reaction: string]: string[] }; +}; diff --git a/packages/types/src/issues/issue_relation.d.ts b/packages/types/src/issues/issue_relation.d.ts new file mode 100644 index 00000000000..0b1c5f7cde5 --- /dev/null +++ b/packages/types/src/issues/issue_relation.d.ts @@ -0,0 +1,15 @@ +import { TIssue } from "./issues"; + +export type TIssueRelationTypes = + | "blocking" + | "blocked_by" + | "duplicate" + | "relates_to"; + +export type TIssueRelation = Record; + +export type TIssueRelationMap = { + [issue_id: string]: Record; +}; + +export type TIssueRelationIdMap = Record; diff --git a/packages/types/src/issues/issue_sub_issues.d.ts b/packages/types/src/issues/issue_sub_issues.d.ts new file mode 100644 index 00000000000..e604761ed02 --- /dev/null +++ b/packages/types/src/issues/issue_sub_issues.d.ts @@ -0,0 +1,22 @@ +import { TIssue } from "./issue"; + +export type TSubIssuesStateDistribution = { + backlog: string[]; + unstarted: string[]; + started: string[]; + completed: string[]; + cancelled: string[]; +}; + +export type TIssueSubIssues = { + state_distribution: TSubIssuesStateDistribution; + sub_issues: TIssue[]; +}; + +export type TIssueSubIssuesStateDistributionMap = { + [issue_id: string]: TSubIssuesStateDistribution; +}; + +export type TIssueSubIssuesIdMap = { + [issue_id: string]: string[]; +}; diff --git a/packages/types/src/issues/issue_subscription.d.ts b/packages/types/src/issues/issue_subscription.d.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web/types/modules.d.ts b/packages/types/src/modules.d.ts similarity index 93% rename from web/types/modules.d.ts rename to packages/types/src/modules.d.ts index 733b8f7ded9..0e49da7fe07 100644 --- a/web/types/modules.d.ts +++ b/packages/types/src/modules.d.ts @@ -1,14 +1,14 @@ import type { IUser, IUserLite, - IIssue, + TIssue, IProject, IWorkspace, IWorkspaceLite, IProjectLite, IIssueFilterOptions, ILinkDetails, -} from "types"; +} from "@plane/types"; export type TModuleStatus = "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled"; @@ -58,7 +58,7 @@ export interface ModuleIssueResponse { created_by: string; id: string; issue: string; - issue_detail: IIssue; + issue_detail: TIssue; module: string; module_detail: IModule; project: string; @@ -75,4 +75,4 @@ export type ModuleLink = { export type SelectModuleType = (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined; -export type SelectIssue = (IIssue & { actionType: "edit" | "delete" | "create" }) | undefined; +export type SelectIssue = (TIssue & { actionType: "edit" | "delete" | "create" }) | undefined; diff --git a/web/types/notifications.d.ts b/packages/types/src/notifications.d.ts similarity index 100% rename from web/types/notifications.d.ts rename to packages/types/src/notifications.d.ts diff --git a/web/types/pages.d.ts b/packages/types/src/pages.d.ts similarity index 79% rename from web/types/pages.d.ts rename to packages/types/src/pages.d.ts index a1c241f6a1a..29552b94cf6 100644 --- a/web/types/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,5 +1,5 @@ // types -import { IIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "types"; +import { TIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "@plane/types"; export interface IPage { access: number; @@ -27,15 +27,11 @@ export interface IPage { } export interface IRecentPages { - today: IPage[]; - yesterday: IPage[]; - this_week: IPage[]; - older: IPage[]; - [key: string]: IPage[]; -} - -export interface RecentPagesResponse { - [key: string]: IPage[]; + today: string[]; + yesterday: string[]; + this_week: string[]; + older: string[]; + [key: string]: string[]; } export interface IPageBlock { @@ -47,7 +43,7 @@ export interface IPageBlock { description_stripped: any; id: string; issue: string | null; - issue_detail: IIssue | null; + issue_detail: TIssue | null; name: string; page: string; project: string; diff --git a/web/types/projects.d.ts b/packages/types/src/projects.d.ts similarity index 77% rename from web/types/projects.d.ts rename to packages/types/src/projects.d.ts index 129b0bb3b82..b54e3f0f931 100644 --- a/web/types/projects.d.ts +++ b/packages/types/src/projects.d.ts @@ -1,6 +1,5 @@ -import type { IUserLite, IWorkspace, IWorkspaceLite, IUserMemberLite, TStateGroups, IProjectViewProps } from "."; - -export type TUserProjectRole = 5 | 10 | 15 | 20; +import { EUserProjectRoles } from "constants/project"; +import type { IUser, IUserLite, IWorkspace, IWorkspaceLite, TStateGroups } from "."; export interface IProject { archive_in: number; @@ -34,13 +33,10 @@ export interface IProject { is_deployed: boolean; is_favorite: boolean; is_member: boolean; - member_role: TUserProjectRole | null; + member_role: EUserProjectRoles | null; members: IProjectMemberLite[]; - issue_views_view: boolean; - module_view: boolean; name: string; network: number; - page_view: boolean; project_lead: IUserLite | string | null; sort_order: number | null; total_cycles: number; @@ -64,6 +60,10 @@ type ProjectPreferences = { }; }; +export interface IProjectMap { + [id: string]: IProject; +} + export interface IProjectMemberLite { id: string; member__avatar: string; @@ -77,7 +77,7 @@ export interface IProjectMember { project: IProjectLite; workspace: IWorkspaceLite; comment: string; - role: TUserProjectRole; + role: EUserProjectRoles; preferences: ProjectPreferences; @@ -90,27 +90,14 @@ export interface IProjectMember { updated_by: string; } -export interface IProjectMemberInvitation { +export interface IProjectMembership { id: string; - - project: IProject; - workspace: IWorkspace; - - email: string; - accepted: boolean; - token: string; - message: string; - responded_at: Date; - role: TUserProjectRole; - - created_at: Date; - updated_at: Date; - created_by: string; - updated_by: string; + member: string; + role: EUserProjectRoles; } export interface IProjectBulkAddFormData { - members: { role: TUserProjectRole; member_id: string }[]; + members: { role: EUserProjectRoles; member_id: string }[]; } export interface IGithubRepository { @@ -130,7 +117,7 @@ export type TProjectIssuesSearchParams = { parent?: boolean; issue_relation?: boolean; cycle?: boolean; - module?: boolean; + module?: string[]; sub_issue?: boolean; issue_id?: string; workspace_search: boolean; diff --git a/web/types/reaction.d.ts b/packages/types/src/reaction.d.ts similarity index 100% rename from web/types/reaction.d.ts rename to packages/types/src/reaction.d.ts diff --git a/web/types/state.d.ts b/packages/types/src/state.d.ts similarity index 56% rename from web/types/state.d.ts rename to packages/types/src/state.d.ts index 3fdbaa2d323..120b216da25 100644 --- a/web/types/state.d.ts +++ b/packages/types/src/state.d.ts @@ -1,24 +1,17 @@ -import { IProject, IProjectLite, IWorkspaceLite } from "types"; +import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types"; export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; export interface IState { readonly id: string; color: string; - readonly created_at: Date; - readonly created_by: string; default: boolean; description: string; group: TStateGroups; name: string; - project: string; - readonly project_detail: IProjectLite; + project_id: string; sequence: number; - readonly slug: string; - readonly updated_at: Date; - readonly updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export interface IStateLite { diff --git a/web/types/users.d.ts b/packages/types/src/users.d.ts similarity index 93% rename from web/types/users.d.ts rename to packages/types/src/users.d.ts index 301c1d7c0ab..81c8abcd5f0 100644 --- a/web/types/users.d.ts +++ b/packages/types/src/users.d.ts @@ -1,3 +1,4 @@ +import { EUserProjectRoles } from "constants/project"; import { IIssueActivity, IIssueLite, TStateGroups } from "."; export interface IUser { @@ -61,11 +62,10 @@ export interface IUserTheme { export interface IUserLite { avatar: string; - created_at: Date; display_name: string; email?: string; first_name: string; - readonly id: string; + id: string; is_bot: boolean; last_name: string; } @@ -163,7 +163,15 @@ export interface IUserProfileProjectSegregation { } export interface IUserProjectsRole { - [project_id: string]: number; + [projectId: string]: EUserProjectRoles; +} + +export interface IUserEmailNotificationSettings { + property_change: boolean; + state_change: boolean; + comment: boolean; + mention: boolean; + issue_completed: boolean; } // export interface ICurrentUser { diff --git a/web/types/view-props.d.ts b/packages/types/src/view-props.d.ts similarity index 85% rename from web/types/view-props.d.ts rename to packages/types/src/view-props.d.ts index c8c47576b9a..61cc7081b29 100644 --- a/web/types/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -1,4 +1,9 @@ -export type TIssueLayouts = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart"; +export type TIssueLayouts = + | "list" + | "kanban" + | "calendar" + | "spreadsheet" + | "gantt_chart"; export type TIssueGroupByOptions = | "state" @@ -59,8 +64,7 @@ export type TIssueParams = | "order_by" | "type" | "sub_issue" - | "show_empty_groups" - | "start_target_date"; + | "show_empty_groups"; export type TCalendarLayouts = "month" | "week"; @@ -88,7 +92,6 @@ export interface IIssueDisplayFilterOptions { layout?: TIssueLayouts; order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; - start_target_date?: boolean; sub_issue?: boolean; type?: TIssueTypeFilters; } @@ -108,6 +111,24 @@ export interface IIssueDisplayProperties { updated_on?: boolean; } +export type TIssueKanbanFilters = { + group_by: string[]; + sub_group_by: string[]; +}; + +export interface IIssueFilters { + filters: IIssueFilterOptions | undefined; + displayFilters: IIssueDisplayFilterOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + kanbanFilters: TIssueKanbanFilters | undefined; +} + +export interface IIssueFiltersResponse { + filters: IIssueFilterOptions; + display_filters: IIssueDisplayFilterOptions; + display_properties: IIssueDisplayProperties; +} + export interface IWorkspaceIssueFilterOptions { assignees?: string[] | null; created_by?: string[] | null; diff --git a/web/types/views.d.ts b/packages/types/src/views.d.ts similarity index 58% rename from web/types/views.d.ts rename to packages/types/src/views.d.ts index 4f55e8c7459..db30554a847 100644 --- a/web/types/views.d.ts +++ b/packages/types/src/views.d.ts @@ -1,4 +1,4 @@ -import { IIssueFilterOptions } from "./view-props"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "./view-props"; export interface IProjectView { id: string; @@ -10,6 +10,9 @@ export interface IProjectView { updated_by: string; name: string; description: string; + filters: IIssueFilterOptions; + display_filters: IIssueDisplayFilterOptions; + display_properties: IIssueDisplayProperties; query: IIssueFilterOptions; query_data: IIssueFilterOptions; project: string; diff --git a/web/types/waitlist.d.ts b/packages/types/src/waitlist.d.ts similarity index 100% rename from web/types/waitlist.d.ts rename to packages/types/src/waitlist.d.ts diff --git a/web/types/webhook.d.ts b/packages/types/src/webhook.d.ts similarity index 100% rename from web/types/webhook.d.ts rename to packages/types/src/webhook.d.ts diff --git a/web/types/workspace-views.d.ts b/packages/types/src/workspace-views.d.ts similarity index 51% rename from web/types/workspace-views.d.ts rename to packages/types/src/workspace-views.d.ts index 754e637118e..e270f4f6928 100644 --- a/web/types/workspace-views.d.ts +++ b/packages/types/src/workspace-views.d.ts @@ -1,4 +1,9 @@ -import { IWorkspaceViewProps } from "./view-props"; +import { + IWorkspaceViewProps, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, +} from "./view-props"; export interface IWorkspaceView { id: string; @@ -10,6 +15,9 @@ export interface IWorkspaceView { updated_by: string; name: string; description: string; + filters: IIssueIIFilterOptions; + display_filters: IIssueDisplayFilterOptions; + display_properties: IIssueDisplayProperties; query: any; query_data: IWorkspaceViewProps; project: string; @@ -21,4 +29,8 @@ export interface IWorkspaceView { }; } -export type TStaticViewTypes = "all-issues" | "assigned" | "created" | "subscribed"; +export type TStaticViewTypes = + | "all-issues" + | "assigned" + | "created" + | "subscribed"; diff --git a/web/types/workspace.d.ts b/packages/types/src/workspace.d.ts similarity index 87% rename from web/types/workspace.d.ts rename to packages/types/src/workspace.d.ts index fb2aca72284..2d7e94d959b 100644 --- a/web/types/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,6 +1,10 @@ -import type { IProjectMember, IUser, IUserLite, IWorkspaceViewProps } from "types"; - -export type TUserWorkspaceRole = 5 | 10 | 15 | 20; +import { EUserWorkspaceRoles } from "constants/workspace"; +import type { + IProjectMember, + IUser, + IUserLite, + IWorkspaceViewProps, +} from "@plane/types"; export interface IWorkspace { readonly id: string; @@ -27,18 +31,22 @@ export interface IWorkspaceLite { export interface IWorkspaceMemberInvitation { accepted: boolean; - readonly id: string; email: string; - token: string; + id: string; message: string; responded_at: Date; - role: TUserWorkspaceRole; - created_by_detail: IUser; - workspace: IWorkspace; + role: EUserWorkspaceRoles; + token: string; + workspace: { + id: string; + logo: string; + name: string; + slug: string; + }; } export interface IWorkspaceBulkInviteFormData { - emails: { email: string; role: TUserWorkspaceRole }[]; + emails: { email: string; role: EUserWorkspaceRoles }[]; } export type Properties = { @@ -58,15 +66,9 @@ export type Properties = { }; export interface IWorkspaceMember { - company_role: string | null; - created_at: Date; - created_by: string; id: string; member: IUserLite; - role: TUserWorkspaceRole; - updated_at: Date; - updated_by: string; - workspace: IWorkspaceLite; + role: EUserWorkspaceRoles; } export interface IWorkspaceMemberMe { @@ -76,7 +78,7 @@ export interface IWorkspaceMemberMe { default_props: IWorkspaceViewProps; id: string; member: string; - role: TUserWorkspaceRole; + role: EUserWorkspaceRoles; updated_at: Date; updated_by: string; view_props: IWorkspaceViewProps; diff --git a/packages/ui/helpers.ts b/packages/ui/helpers.ts new file mode 100644 index 00000000000..a500a738583 --- /dev/null +++ b/packages/ui/helpers.ts @@ -0,0 +1,4 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); diff --git a/packages/ui/package.json b/packages/ui/package.json index b643d47d491..9228cf9bb4b 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.14.0", + "version": "0.15.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -17,6 +17,17 @@ "lint": "eslint src/", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, + "dependencies": { + "@blueprintjs/core": "^4.16.3", + "@blueprintjs/popover2": "^1.13.3", + "@headlessui/react": "^1.7.17", + "@popperjs/core": "^2.11.8", + "clsx": "^2.0.0", + "react-color": "^2.19.3", + "react-dom": "^18.2.0", + "react-popper": "^2.3.0", + "tailwind-merge": "^2.0.0" + }, "devDependencies": { "@types/node": "^20.5.2", "@types/react": "^18.2.42", @@ -29,13 +40,5 @@ "tsconfig": "*", "tsup": "^5.10.1", "typescript": "4.7.4" - }, - "dependencies": { - "@blueprintjs/core": "^4.16.3", - "@blueprintjs/popover2": "^1.13.3", - "@headlessui/react": "^1.7.17", - "@popperjs/core": "^2.11.8", - "react-color": "^2.19.3", - "react-popper": "^2.3.0" } } diff --git a/packages/ui/src/avatar/avatar.tsx b/packages/ui/src/avatar/avatar.tsx index 4be345961ad..6344dce834c 100644 --- a/packages/ui/src/avatar/avatar.tsx +++ b/packages/ui/src/avatar/avatar.tsx @@ -141,6 +141,7 @@ export const Avatar: React.FC = (props) => { } : {} } + tabIndex={-1} > {src ? ( {name} diff --git a/packages/ui/src/button/button.tsx b/packages/ui/src/button/button.tsx index d63d89eb2f8..10ee815f692 100644 --- a/packages/ui/src/button/button.tsx +++ b/packages/ui/src/button/button.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { getIconStyling, getButtonStyling, TButtonVariant, TButtonSizes } from "./helper"; +import { cn } from "../../helpers"; export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: TButtonVariant; @@ -31,7 +32,7 @@ const Button = React.forwardRef((props, ref) => const buttonIconStyle = getIconStyling(size); return ( - @@ -83,86 +107,79 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} + onClick={openDropdown} > {label} {!noChevron && !disabled &&

-
- - setQuery(e.target.value)} - placeholder="Type to search..." - displayValue={(assigned: any) => assigned?.name} - /> -
+ {isOpen && ( +
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ - active || selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ active, selected }) => ( - <> - {option.content} - {multiple ? ( -
- -
- ) : ( - - )} - - )} -
- )) +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + cn( + "w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none", + { + "bg-custom-background-80": active, + } + ) + } + onClick={() => { + if (!multiple) closeDropdown(); + }} + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matches found

+ ) ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} +

Loading...

+ )} +
+ {footerOption}
- {footerOption} -
- + + )} ); }} diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 8fbe3fbdc7e..0fa183cb2ce 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -1,11 +1,12 @@ -import React, { useState } from "react"; - -// react-popper +import React, { useRef, useState } from "react"; import { usePopper } from "react-popper"; -// headless ui import { Listbox } from "@headlessui/react"; -// icons import { Check, ChevronDown } from "lucide-react"; +// hooks +import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helpers +import { cn } from "../../helpers"; // types import { ICustomSelectItemProps, ICustomSelectProps } from "./helper"; @@ -25,21 +26,36 @@ const CustomSelect = (props: ICustomSelectProps) => { onChange, optionsClassName = "", value, - width = "auto", + tabIndex, } = props; + // states const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", }); + const openDropdown = () => { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + return ( <> @@ -51,6 +67,7 @@ const CustomSelect = (props: ICustomSelectProps) => { className={`flex items-center justify-between gap-1 text-xs ${ disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" } ${customButtonClassName}`} + onClick={openDropdown} > {customButton} @@ -65,6 +82,7 @@ const CustomSelect = (props: ICustomSelectProps) => { } ${ disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} + onClick={openDropdown} > {label} {!noChevron && !disabled && ); }; @@ -101,16 +120,20 @@ const Option = (props: ICustomSelectItemProps) => { return ( - `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"} ${className}` + className={({ active }) => + cn( + "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200", + { + "bg-custom-background-80": active, + }, + className + ) } > {({ selected }) => (
{children}
- {selected && } + {selected && }
)}
diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index eac53b6e65b..06f1c44c0a3 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -13,8 +13,8 @@ export interface IDropdownProps { noChevron?: boolean; onOpen?: () => void; optionsClassName?: string; - width?: "auto" | string; placement?: Placement; + tabIndex?: number; } export interface ICustomMenuDropdownProps extends IDropdownProps { @@ -23,6 +23,8 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + closeOnSelect?: boolean; + portalElement?: Element | null; } export interface ICustomSelectProps extends IDropdownProps { @@ -34,6 +36,7 @@ export interface ICustomSelectProps extends IDropdownProps { interface CustomSearchSelectProps { footerOption?: JSX.Element; onChange: any; + onClose?: () => void; options: | { value: any; diff --git a/packages/ui/src/hooks/use-dropdown-key-down.tsx b/packages/ui/src/hooks/use-dropdown-key-down.tsx new file mode 100644 index 00000000000..1bb861477f5 --- /dev/null +++ b/packages/ui/src/hooks/use-dropdown-key-down.tsx @@ -0,0 +1,24 @@ +import { useCallback } from "react"; + +type TUseDropdownKeyDown = { + (onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent) => void; +}; + +export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => { + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.stopPropagation(); + if (!isOpen) { + onOpen(); + } + } else if (event.key === "Escape" && isOpen) { + event.stopPropagation(); + onClose(); + } + }, + [isOpen, onOpen, onClose] + ); + + return handleKeyDown; +}; diff --git a/packages/ui/src/hooks/use-outside-click-detector.tsx b/packages/ui/src/hooks/use-outside-click-detector.tsx new file mode 100644 index 00000000000..5331d11c880 --- /dev/null +++ b/packages/ui/src/hooks/use-outside-click-detector.tsx @@ -0,0 +1,19 @@ +import React, { useEffect } from "react"; + +const useOutsideClickDetector = (ref: React.RefObject, callback: () => void) => { + const handleClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + callback(); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", handleClick); + + return () => { + document.removeEventListener("mousedown", handleClick); + }; + }); +}; + +export default useOutsideClickDetector; diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index 198391adbd8..0b98b3e6b66 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -1,45 +1,78 @@ import * as React from "react"; - -// icons import { AlertCircle, Ban, SignalHigh, SignalLow, SignalMedium } from "lucide-react"; +import { cn } from "../../helpers"; + +type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; -// types -import { IPriorityIcon } from "./type"; +interface IPriorityIcon { + className?: string; + containerClassName?: string; + priority: TIssuePriorities; + size?: number; + withContainer?: boolean; +} -export const PriorityIcon: React.FC = ({ priority, className = "", transparentBg = false }) => { - if (!className || className === "") className = "h-4 w-4"; +export const PriorityIcon: React.FC = (props) => { + const { priority, className = "", containerClassName = "", size = 14, withContainer = false } = props; - // Convert to lowercase for string comparison - const lowercasePriority = priority?.toLowerCase(); + const priorityClasses = { + urgent: "bg-red-500 text-red-500 border-red-500", + high: "bg-orange-500/20 text-orange-500 border-orange-500", + medium: "bg-yellow-500/20 text-yellow-500 border-yellow-500", + low: "bg-custom-primary-100/20 text-custom-primary-100 border-custom-primary-100", + none: "bg-custom-background-80 text-custom-text-200 border-custom-border-300", + }; - //get priority icon - const getPriorityIcon = (): React.ReactNode => { - switch (lowercasePriority) { - case "urgent": - return ; - case "high": - return ; - case "medium": - return ; - case "low": - return ; - default: - return ; - } + // get priority icon + const icons = { + urgent: AlertCircle, + high: SignalHigh, + medium: SignalMedium, + low: SignalLow, + none: Ban, }; + const Icon = icons[priority]; + + if (!Icon) return null; return ( <> - {transparentBg ? ( - getPriorityIcon() - ) : ( + {withContainer ? (
- {getPriorityIcon()} +
+ ) : ( + )} ); diff --git a/packages/ui/src/icons/type.d.ts b/packages/ui/src/icons/type.d.ts index 65b188e4c8c..4a04c948b62 100644 --- a/packages/ui/src/icons/type.d.ts +++ b/packages/ui/src/icons/type.d.ts @@ -1,11 +1,3 @@ export interface ISvgIcons extends React.SVGAttributes { className?: string | undefined; } - -export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; - -export interface IPriorityIcon { - priority: TIssuePriorities | null; - className?: string; - transparentBg?: boolean | false; -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4b1bb2fcfe4..b90b6993a77 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,3 +9,4 @@ export * from "./progress"; export * from "./spinners"; export * from "./tooltip"; export * from "./loader"; +export * from "./control-link"; diff --git a/packages/ui/src/progress/circular-progress-indicator.tsx b/packages/ui/src/progress/circular-progress-indicator.tsx index d445480c737..66c70475f49 100644 --- a/packages/ui/src/progress/circular-progress-indicator.tsx +++ b/packages/ui/src/progress/circular-progress-indicator.tsx @@ -35,9 +35,9 @@ export const CircularProgressIndicator: React.FC = ( width="45.2227" height="45.2227" filterUnits="userSpaceOnUse" - color-interpolation-filters="sRGB" + colorInterpolationFilters="sRGB" > - + diff --git a/packages/ui/src/progress/linear-progress-indicator.tsx b/packages/ui/src/progress/linear-progress-indicator.tsx index 471015406f7..7cf9717a098 100644 --- a/packages/ui/src/progress/linear-progress-indicator.tsx +++ b/packages/ui/src/progress/linear-progress-indicator.tsx @@ -1,18 +1,27 @@ import React from "react"; import { Tooltip } from "../tooltip"; +import { cn } from "../../helpers"; type Props = { data: any; noTooltip?: boolean; + inPercentage?: boolean; + size?: "sm" | "md" | "lg"; }; -export const LinearProgressIndicator: React.FC = ({ data, noTooltip = false }) => { +export const LinearProgressIndicator: React.FC = ({ + data, + noTooltip = false, + inPercentage = false, + size = "sm", +}) => { const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0); // eslint-disable-next-line @typescript-eslint/no-unused-vars let progress = 0; const bars = data.map((item: any) => { const width = `${(item.value / total) * 100}%`; + if (width === "0%") return <>; const style = { width, backgroundColor: item.color, @@ -21,18 +30,24 @@ export const LinearProgressIndicator: React.FC = ({ data, noTooltip = fal if (noTooltip) return
; else return ( - -
+ +
); }); return ( -
+
{total === 0 ? ( -
{bars}
+
{bars}
) : ( -
{bars}
+
{bars}
)}
); diff --git a/space/components/accounts/sign-in-forms/create-password.tsx b/space/components/accounts/sign-in-forms/create-password.tsx index cb7326b7579..55205e70757 100644 --- a/space/components/accounts/sign-in-forms/create-password.tsx +++ b/space/components/accounts/sign-in-forms/create-password.tsx @@ -101,7 +101,7 @@ export const CreatePasswordForm: React.FC = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/components/accounts/sign-in-forms/email-form.tsx b/space/components/accounts/sign-in-forms/email-form.tsx index 43fd4df31ac..4f8ed429470 100644 --- a/space/components/accounts/sign-in-forms/email-form.tsx +++ b/space/components/accounts/sign-in-forms/email-form.tsx @@ -100,7 +100,7 @@ export const EmailForm: React.FC = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/accounts/sign-in-forms/optional-set-password.tsx b/space/components/accounts/sign-in-forms/optional-set-password.tsx index 6868485701b..2199717598a 100644 --- a/space/components/accounts/sign-in-forms/optional-set-password.tsx +++ b/space/components/accounts/sign-in-forms/optional-set-password.tsx @@ -61,7 +61,7 @@ export const OptionalSetPasswordForm: React.FC = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/components/accounts/sign-in-forms/password.tsx b/space/components/accounts/sign-in-forms/password.tsx index d080ff639e0..f909f16c5d5 100644 --- a/space/components/accounts/sign-in-forms/password.tsx +++ b/space/components/accounts/sign-in-forms/password.tsx @@ -155,7 +155,7 @@ export const PasswordForm: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx index 6ebc0549056..af1e5d68f6a 100644 --- a/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx +++ b/space/components/accounts/sign-in-forms/self-hosted-sign-in.tsx @@ -97,7 +97,7 @@ export const SelfHostedSignInForm: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/accounts/sign-in-forms/set-password-link.tsx b/space/components/accounts/sign-in-forms/set-password-link.tsx index b0e5f69d3b0..0b5ad21d9c6 100644 --- a/space/components/accounts/sign-in-forms/set-password-link.tsx +++ b/space/components/accounts/sign-in-forms/set-password-link.tsx @@ -87,7 +87,7 @@ export const SetPasswordLink: React.FC = (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/components/accounts/sign-in-forms/unique-code.tsx b/space/components/accounts/sign-in-forms/unique-code.tsx index 6b45bc429a0..4c61fa1513f 100644 --- a/space/components/accounts/sign-in-forms/unique-code.tsx +++ b/space/components/accounts/sign-in-forms/unique-code.tsx @@ -182,7 +182,7 @@ export const UniqueCodeForm: React.FC = (props) => { }} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( diff --git a/space/components/issues/peek-overview/comment/add-comment.tsx b/space/components/issues/peek-overview/comment/add-comment.tsx index d6c3ce4e6e4..ef1a115d282 100644 --- a/space/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/components/issues/peek-overview/comment/add-comment.tsx @@ -14,6 +14,7 @@ import { Comment } from "types/issue"; import { LiteTextEditorWithRef } from "@plane/lite-text-editor"; // service import fileService from "services/file.service"; +import { RootStore } from "store/root"; const defaultValues: Partial = { comment_html: "", @@ -35,6 +36,9 @@ export const AddComment: React.FC = observer((props) => { } = useForm({ defaultValues }); const router = useRouter(); + const { project }: RootStore = useMobxStore(); + const workspaceId = project.workspace?.id; + const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; const { user: userStore, issueDetails: issueDetailStore } = useMobxStore(); @@ -78,8 +82,8 @@ export const AddComment: React.FC = observer((props) => { }} cancelUploadImage={fileService.cancelUpload} uploadFile={fileService.getUploadFileFunction(workspace_slug as string)} - deleteFile={fileService.deleteImage} - restoreFile={fileService.restoreImage} + deleteFile={fileService.getDeleteImageFunction(workspaceId as string)} + restoreFile={fileService.getRestoreImageFunction(workspaceId as string)} ref={editorRef} value={ !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) diff --git a/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/components/issues/peek-overview/comment/comment-detail-card.tsx index a8216514076..7c6abe19956 100644 --- a/space/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -17,6 +17,7 @@ import { Comment } from "types/issue"; import fileService from "services/file.service"; import useEditorSuggestions from "hooks/use-editor-suggestions"; +import { RootStore } from "store/root"; type Props = { workspaceSlug: string; comment: Comment; @@ -24,6 +25,9 @@ type Props = { export const CommentCard: React.FC = observer((props) => { const { comment, workspaceSlug } = props; + const { project }: RootStore = useMobxStore(); + const workspaceId = project.workspace?.id; + // store const { user: userStore, issueDetails: issueDetailStore } = useMobxStore(); // states @@ -105,8 +109,8 @@ export const CommentCard: React.FC = observer((props) => { onEnterKeyPress={handleSubmit(handleCommentUpdate)} cancelUploadImage={fileService.cancelUpload} uploadFile={fileService.getUploadFileFunction(workspaceSlug)} - deleteFile={fileService.deleteImage} - restoreFile={fileService.restoreImage} + deleteFile={fileService.getDeleteImageFunction(workspaceId as string)} + restoreFile={fileService.getRestoreImageFunction(workspaceId as string)} ref={editorRef} value={value} debouncedUpdatesEnabled={false} diff --git a/space/package.json b/space/package.json index 73f67327bcc..7d180d5ff5a 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.14.0", + "version": "0.15.0", "private": true, "scripts": { "dev": "turbo run develop", diff --git a/space/pages/accounts/password.tsx b/space/pages/accounts/password.tsx index a3fabdda9ae..85da11290f2 100644 --- a/space/pages/accounts/password.tsx +++ b/space/pages/accounts/password.tsx @@ -104,7 +104,7 @@ const HomePage: NextPage = () => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> diff --git a/space/services/file.service.ts b/space/services/file.service.ts index b2d1f6ccd7e..ecebf92b7d3 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -74,6 +74,39 @@ class FileService extends APIService { }; } + getDeleteImageFunction(workspaceId: string) { + return async (src: string) => { + try { + const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; + const data = await this.deleteImage(assetUrlWithWorkspaceId); + return data; + } catch (e) { + console.error(e); + } + }; + } + + getRestoreImageFunction(workspaceId: string) { + return async (src: string) => { + try { + const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; + const data = await this.restoreImage(assetUrlWithWorkspaceId); + return data; + } catch (e) { + console.error(e); + } + }; + } + + extractAssetIdFromUrl(src: string, workspaceId: string): string { + const indexWhereAssetIdStarts = src.indexOf(workspaceId) + workspaceId.length + 1; + if (indexWhereAssetIdStarts === -1) { + throw new Error("Workspace ID not found in source string"); + } + const assetUrl = src.substring(indexWhereAssetIdStarts); + return assetUrl; + } + async deleteImage(assetUrlWithWorkspaceId: string): Promise { return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`) .then((response) => response?.status) diff --git a/space/styles/globals.css b/space/styles/globals.css index 6b2a53e3f1c..92980b0d7df 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -64,6 +64,7 @@ 0px 1px 32px 0px rgba(16, 24, 40, 0.12); --color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), 0px 1px 48px 0px rgba(16, 24, 40, 0.12); + --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ @@ -88,6 +89,7 @@ --color-sidebar-shadow-xl: var(--color-shadow-xl); --color-sidebar-shadow-2xl: var(--color-shadow-2xl); --color-sidebar-shadow-3xl: var(--color-shadow-3xl); + --color-sidebar-shadow-4xl: var(--color-shadow-4xl); } [data-theme="light"], diff --git a/turbo.json b/turbo.json index 48f0c042235..bd5ee34b59e 100644 --- a/turbo.json +++ b/turbo.json @@ -29,63 +29,18 @@ "dist/**" ] }, - "web#develop": { + "develop": { "cache": false, "persistent": true, "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build", - "@plane/document-editor#build", - "@plane/ui#build" + "^build" ] }, - "space#develop": { + "dev": { "cache": false, "persistent": true, "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build", - "@plane/document-editor#build", - "@plane/ui#build" - ] - }, - "web#build": { - "cache": true, - "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build", - "@plane/document-editor#build", - "@plane/ui#build" - ] - }, - "space#build": { - "cache": true, - "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build", - "@plane/document-editor#build", - "@plane/ui#build" - ] - }, - "@plane/lite-text-editor#build": { - "cache": true, - "dependsOn": [ - "@plane/editor-core#build", - "@plane/editor-extensions#build" - ] - }, - "@plane/rich-text-editor#build": { - "cache": true, - "dependsOn": [ - "@plane/editor-core#build", - "@plane/editor-extensions#build" - ] - }, - "@plane/document-editor#build": { - "cache": true, - "dependsOn": [ - "@plane/editor-core#build", - "@plane/editor-extensions#build" + "^build" ] }, "test": { @@ -97,12 +52,6 @@ "lint": { "outputs": [] }, - "dev": { - "cache": false - }, - "develop": { - "cache": false - }, "start": { "cache": false }, diff --git a/web/Dockerfile.web b/web/Dockerfile.web index d9260e61d8b..e0d525c2c27 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -1,3 +1,6 @@ +# ****************************************** +# STAGE 1: Build the project +# ****************************************** FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat # Set working directory @@ -8,6 +11,10 @@ COPY . . RUN turbo prune --scope=web --docker + +# ****************************************** +# STAGE 2: Install dependencies & build the project +# ****************************************** # Add lockfile and package.json's of isolated subworkspace FROM node:18-alpine AS installer @@ -31,6 +38,11 @@ ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL RUN yarn turbo run build --filter=web + +# ****************************************** +# STAGE 3: Copy the project and start it +# ****************************************** + FROM node:18-alpine AS runner WORKDIR /app @@ -46,6 +58,7 @@ COPY --from=installer /app/web/package.json . # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./ COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next +COPY --from=installer --chown=captain:plane /app/web/public ./web/public ARG NEXT_PUBLIC_API_BASE_URL="" ARG NEXT_PUBLIC_DEPLOY_URL="" diff --git a/web/components/account/deactivate-account-modal.tsx b/web/components/account/deactivate-account-modal.tsx index 53ac1df5092..307a65ad2e3 100644 --- a/web/components/account/deactivate-account-modal.tsx +++ b/web/components/account/deactivate-account-modal.tsx @@ -4,8 +4,8 @@ import { useTheme } from "next-themes"; import { Dialog, Transition } from "@headlessui/react"; import { Trash2 } from "lucide-react"; import { mutate } from "swr"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useUser } from "hooks/store"; // ui import { Button } from "@plane/ui"; // hooks @@ -22,9 +22,7 @@ export const DeactivateAccountModal: React.FC = (props) => { // states const [isDeactivating, setIsDeactivating] = useState(false); - const { - user: { deactivateAccount }, - } = useMobxStore(); + const { deactivateAccount } = useUser(); const router = useRouter(); diff --git a/web/components/account/email-signup-form.tsx b/web/components/account/email-signup-form.tsx deleted file mode 100644 index 8bbf859a408..00000000000 --- a/web/components/account/email-signup-form.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React from "react"; -import Link from "next/link"; -import { Controller, useForm } from "react-hook-form"; -// ui -import { Button, Input } from "@plane/ui"; -// types -type EmailPasswordFormValues = { - email: string; - password?: string; - confirm_password: string; - medium?: string; -}; - -type Props = { - onSubmit: (formData: EmailPasswordFormValues) => Promise; -}; - -export const EmailSignUpForm: React.FC = (props) => { - const { onSubmit } = props; - - const { - handleSubmit, - control, - watch, - formState: { errors, isSubmitting, isValid, isDirty }, - } = useForm({ - defaultValues: { - email: "", - password: "", - confirm_password: "", - medium: "email", - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - return ( - <> -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> -
-
- ( - - )} - /> -
-
- { - if (watch("password") != val) { - return "Your passwords do no match"; - } - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> -
-
- - - Already have an account? Sign in. - - -
-
- -
-
- - ); -}; diff --git a/web/components/account/index.ts b/web/components/account/index.ts index 275f7ff087b..0d1cffbc661 100644 --- a/web/components/account/index.ts +++ b/web/components/account/index.ts @@ -1,5 +1,4 @@ +export * from "./o-auth"; export * from "./sign-in-forms"; +export * from "./sign-up-forms"; export * from "./deactivate-account-modal"; -export * from "./github-sign-in"; -export * from "./google-sign-in"; -export * from "./email-signup-form"; diff --git a/web/components/account/github-sign-in.tsx b/web/components/account/o-auth/github-sign-in.tsx similarity index 90% rename from web/components/account/github-sign-in.tsx rename to web/components/account/o-auth/github-sign-in.tsx index 27a8bf01c82..74bfd6d940f 100644 --- a/web/components/account/github-sign-in.tsx +++ b/web/components/account/o-auth/github-sign-in.tsx @@ -12,10 +12,11 @@ import githubDarkModeImage from "/public/logos/github-dark.svg"; type Props = { handleSignIn: React.Dispatch; clientId: string; + type: "sign_in" | "sign_up"; }; export const GitHubSignInButton: FC = (props) => { - const { handleSignIn, clientId } = props; + const { handleSignIn, clientId, type } = props; // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); @@ -53,7 +54,7 @@ export const GitHubSignInButton: FC = (props) => { width={20} alt="GitHub Logo" /> - Sign-in with GitHub + {type === "sign_in" ? "Sign-in" : "Sign-up"} with GitHub
diff --git a/web/components/account/google-sign-in.tsx b/web/components/account/o-auth/google-sign-in.tsx similarity index 87% rename from web/components/account/google-sign-in.tsx rename to web/components/account/o-auth/google-sign-in.tsx index 48488e07e1d..c1c57baa0a4 100644 --- a/web/components/account/google-sign-in.tsx +++ b/web/components/account/o-auth/google-sign-in.tsx @@ -4,10 +4,11 @@ import Script from "next/script"; type Props = { handleSignIn: React.Dispatch; clientId: string; + type: "sign_in" | "sign_up"; }; export const GoogleSignInButton: FC = (props) => { - const { handleSignIn, clientId } = props; + const { handleSignIn, clientId, type } = props; // refs const googleSignInButton = useRef(null); // states @@ -29,8 +30,7 @@ export const GoogleSignInButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - text: "signin_with", - width: 384, + text: type === "sign_in" ? "signin_with" : "signup_with", } as GsiButtonConfiguration // customization attributes ); } catch (err) { @@ -40,7 +40,7 @@ export const GoogleSignInButton: FC = (props) => { window?.google?.accounts.id.prompt(); // also display the One Tap dialog setGsiScriptLoaded(true); - }, [handleSignIn, gsiScriptLoaded, clientId]); + }, [handleSignIn, gsiScriptLoaded, clientId, type]); useEffect(() => { if (window?.google?.accounts?.id) { diff --git a/web/components/account/o-auth/index.ts b/web/components/account/o-auth/index.ts new file mode 100644 index 00000000000..4cea6ce5b97 --- /dev/null +++ b/web/components/account/o-auth/index.ts @@ -0,0 +1,3 @@ +export * from "./github-sign-in"; +export * from "./google-sign-in"; +export * from "./o-auth-options"; diff --git a/web/components/account/sign-in-forms/o-auth-options.tsx b/web/components/account/o-auth/o-auth-options.tsx similarity index 78% rename from web/components/account/sign-in-forms/o-auth-options.tsx rename to web/components/account/o-auth/o-auth-options.tsx index aec82cfa52e..7c8468acb78 100644 --- a/web/components/account/sign-in-forms/o-auth-options.tsx +++ b/web/components/account/o-auth/o-auth-options.tsx @@ -1,28 +1,30 @@ import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // services import { AuthService } from "services/auth.service"; // hooks +import { useApplication } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { GitHubSignInButton, GoogleSignInButton } from "components/account"; type Props = { handleSignInRedirection: () => Promise; + type: "sign_in" | "sign_up"; }; // services const authService = new AuthService(); export const OAuthOptions: React.FC = observer((props) => { - const { handleSignInRedirection } = props; + const { handleSignInRedirection, type } = props; // toast alert const { setToastAlert } = useToast(); // mobx store const { - appConfig: { envConfig }, - } = useMobxStore(); + config: { envConfig }, + } = useApplication(); + // derived values + const areBothOAuthEnabled = envConfig?.google_client_id && envConfig?.github_client_id; const handleGoogleSignIn = async ({ clientId, credential }: any) => { try { @@ -73,12 +75,14 @@ export const OAuthOptions: React.FC = observer((props) => {

Or continue with


-
+
{envConfig?.google_client_id && ( - +
+ +
)} {envConfig?.github_client_id && ( - + )}
diff --git a/web/components/account/sign-in-forms/create-password.tsx b/web/components/account/sign-in-forms/create-password.tsx deleted file mode 100644 index cf53078bedf..00000000000 --- a/web/components/account/sign-in-forms/create-password.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useEffect } from "react"; -import Link from "next/link"; -import { Controller, useForm } from "react-hook-form"; -// services -import { AuthService } from "services/auth.service"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { Button, Input } from "@plane/ui"; -// helpers -import { checkEmailValidity } from "helpers/string.helper"; -// constants -import { ESignInSteps } from "components/account"; - -type Props = { - email: string; - handleStepChange: (step: ESignInSteps) => void; - handleSignInRedirection: () => Promise; - isOnboarded: boolean; -}; - -type TCreatePasswordFormValues = { - email: string; - password: string; -}; - -const defaultValues: TCreatePasswordFormValues = { - email: "", - password: "", -}; - -// services -const authService = new AuthService(); - -export const CreatePasswordForm: React.FC = (props) => { - const { email, handleSignInRedirection, isOnboarded } = props; - // toast alert - const { setToastAlert } = useToast(); - // form info - const { - control, - formState: { errors, isSubmitting, isValid }, - handleSubmit, - setFocus, - } = useForm({ - defaultValues: { - ...defaultValues, - email, - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const handleCreatePassword = async (formData: TCreatePasswordFormValues) => { - const payload = { - password: formData.password, - }; - - await authService - .setPassword(payload) - .then(async () => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Password created successfully.", - }); - await handleSignInRedirection(); - }) - .catch((err) => - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ); - }; - - useEffect(() => { - setFocus("password"); - }, [setFocus]); - - return ( - <> -

- Get on your flight deck -

-
- checkEmailValidity(value) || "Email is invalid", - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - ( - - )} - /> - -

- When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

- - - ); -}; diff --git a/web/components/account/sign-in-forms/email.tsx b/web/components/account/sign-in-forms/email.tsx new file mode 100644 index 00000000000..67ef720fe63 --- /dev/null +++ b/web/components/account/sign-in-forms/email.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +import { observer } from "mobx-react-lite"; +// services +import { AuthService } from "services/auth.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData } from "@plane/types"; + +type Props = { + onSubmit: (isPasswordAutoset: boolean) => void; + updateEmail: (email: string) => void; +}; + +type TEmailFormValues = { + email: string; +}; + +const authService = new AuthService(); + +export const SignInEmailForm: React.FC = observer((props) => { + const { onSubmit, updateEmail } = props; + // hooks + const { setToastAlert } = useToast(); + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + email: "", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleFormSubmit = async (data: TEmailFormValues) => { + const payload: IEmailCheckData = { + email: data.email, + }; + + // update the global email state + updateEmail(data.email); + + await authService + .emailCheck(payload) + .then((res) => onSubmit(res.is_password_autoset)) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + return ( + <> +

+ Welcome back, let{"'"}s get you on board +

+

+ Get back to your issues, projects and workspaces. +

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange } }) => ( +
+ + {value.length > 0 && ( + onChange("")} + /> + )} +
+ )} + /> +
+ +
+ + ); +}); diff --git a/web/components/account/sign-in-forms/forgot-password-popover.tsx b/web/components/account/sign-in-forms/forgot-password-popover.tsx new file mode 100644 index 00000000000..d652e51f1fe --- /dev/null +++ b/web/components/account/sign-in-forms/forgot-password-popover.tsx @@ -0,0 +1,54 @@ +import { Fragment, useState } from "react"; +import { usePopper } from "react-popper"; +import { Popover } from "@headlessui/react"; +import { X } from "lucide-react"; + +export const ForgotPasswordPopover = () => { + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + return ( + + + + + + {({ close }) => ( +
+ 🤥 +

+ We see that your god hasn{"'"}t enabled SMTP, we will not be able to send a password reset link +

+ +
+ )} +
+
+ ); +}; diff --git a/web/components/account/sign-in-forms/index.ts b/web/components/account/sign-in-forms/index.ts index 1150a071cfa..8e44f490bcc 100644 --- a/web/components/account/sign-in-forms/index.ts +++ b/web/components/account/sign-in-forms/index.ts @@ -1,9 +1,6 @@ -export * from "./create-password"; -export * from "./email-form"; -export * from "./o-auth-options"; +export * from "./email"; +export * from "./forgot-password-popover"; export * from "./optional-set-password"; export * from "./password"; export * from "./root"; -export * from "./self-hosted-sign-in"; -export * from "./set-password-link"; export * from "./unique-code"; diff --git a/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx index ead9b9c9a06..d7a5952984f 100644 --- a/web/components/account/sign-in-forms/optional-set-password.tsx +++ b/web/components/account/sign-in-forms/optional-set-password.tsx @@ -1,36 +1,79 @@ import React, { useState } from "react"; -import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/auth.service"; +// hooks +import useToast from "hooks/use-toast"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; -// constants -import { ESignInSteps } from "components/account"; +// icons +import { Eye, EyeOff } from "lucide-react"; type Props = { email: string; - handleStepChange: (step: ESignInSteps) => void; handleSignInRedirection: () => Promise; - isOnboarded: boolean; }; -export const OptionalSetPasswordForm: React.FC = (props) => { - const { email, handleStepChange, handleSignInRedirection, isOnboarded } = props; +type TCreatePasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TCreatePasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const SignInOptionalSetPasswordForm: React.FC = (props) => { + const { email, handleSignInRedirection } = props; // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + const [showPassword, setShowPassword] = useState(false); + // toast alert + const { setToastAlert } = useToast(); // form info const { control, - formState: { errors, isValid }, - } = useForm({ + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ defaultValues: { + ...defaultValues, email, }, mode: "onChange", reValidateMode: "onChange", }); + const handleCreatePassword = async (formData: TCreatePasswordFormValues) => { + const payload = { + password: formData.password, + }; + + await authService + .setPassword(payload) + .then(async () => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Password created successfully.", + }); + await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); @@ -39,12 +82,11 @@ export const OptionalSetPasswordForm: React.FC = (props) => { return ( <> -

Set a password

-

+

Set your password

+

If you{"'"}d like to do away with codes, set a password here.

- -
+ = (props) => { onChange={onChange} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" - className="h-[46px] w-full border border-onboarding-border-100 pr-12 text-onboarding-text-400" + placeholder="name@company.com" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400" disabled /> )} /> -
+
+ ( +
+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ )} + /> +

+ Whatever you choose now will be your account{"'"}s password until you change it. +

+
+
-

- When you click{" "} - {isOnboarded ? "Go to workspace" : "Set up workspace"} above, - you agree with our{" "} - - terms and conditions of service. - -

); diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index a75a450e25b..fe20d5b1076 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -1,25 +1,27 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import Link from "next/link"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; -import { XCircle } from "lucide-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useApplication } from "hooks/store"; +// components +import { ESignInSteps, ForgotPasswordPopover } from "components/account"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types -import { IPasswordSignInData } from "types/auth"; -// constants -import { ESignInSteps } from "components/account"; +import { IPasswordSignInData } from "@plane/types"; type Props = { email: string; - updateEmail: (email: string) => void; handleStepChange: (step: ESignInSteps) => void; - handleSignInRedirection: () => Promise; + handleEmailClear: () => void; + onSubmit: () => Promise; }; type TPasswordFormValues = { @@ -34,21 +36,25 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); -export const PasswordForm: React.FC = (props) => { - const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; +export const SignInPasswordForm: React.FC = observer((props) => { + const { email, handleStepChange, handleEmailClear, onSubmit } = props; // states const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); - const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); + const [showPassword, setShowPassword] = useState(false); // toast alert const { setToastAlert } = useToast(); + const { + config: { envConfig }, + } = useApplication(); + // derived values + const isSmtpConfigured = envConfig?.is_smtp_configured; // form info const { control, - formState: { dirtyFields, errors, isSubmitting, isValid }, + formState: { errors, isSubmitting, isValid }, getValues, handleSubmit, setError, - setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -59,8 +65,6 @@ export const PasswordForm: React.FC = (props) => { }); const handleFormSubmit = async (formData: TPasswordFormValues) => { - updateEmail(formData.email); - const payload: IPasswordSignInData = { email: formData.email, password: formData.password, @@ -68,7 +72,7 @@ export const PasswordForm: React.FC = (props) => { await authService .passwordSignIn(payload) - .then(async () => await handleSignInRedirection()) + .then(async () => await onSubmit()) .catch((err) => setToastAlert({ type: "error", @@ -78,31 +82,6 @@ export const PasswordForm: React.FC = (props) => { ); }; - const handleForgotPassword = async () => { - const emailFormValue = getValues("email"); - - const isEmailValid = checkEmailValidity(emailFormValue); - - if (!isEmailValid) { - setError("email", { message: "Email is invalid" }); - return; - } - - setIsSendingResetPasswordLink(true); - - authService - .sendResetPasswordLink({ email: emailFormValue }) - .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) - .catch((err) => - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsSendingResetPasswordLink(false)); - }; - const handleSendUniqueCode = async () => { const emailFormValue = getValues("email"); @@ -128,16 +107,15 @@ export const PasswordForm: React.FC = (props) => { .finally(() => setIsSendingUniqueCode(false)); }; - useEffect(() => { - setFocus("password"); - }, [setFocus]); - return ( <> -

- Get on your flight deck +

+ Welcome back, let{"'"}s get you on board

-
+

+ Get back to your issues, projects and workspaces. +

+
= (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + disabled={isSmtpConfigured} /> {value.length > 0 && ( onChange("")} + onClick={() => { + if (isSmtpConfigured) handleEmailClear(); + else onChange(""); + }} /> )}
@@ -173,61 +155,71 @@ export const PasswordForm: React.FC = (props) => { control={control} name="password" rules={{ - required: dirtyFields.email ? false : "Password is required", + required: "Password is required", }} render={({ field: { value, onChange } }) => ( - +
+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
)} /> -
- +
+ {isSmtpConfigured ? ( + + Forgot your password? + + ) : ( + + )}
-
- +
+ {envConfig && envConfig.is_smtp_configured && ( + + )}
-

- When you click Go to workspace above, you agree with our{" "} - - terms and conditions of service. - -

); -}; +}); diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index f7ec6b59341..c92cd4bd457 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -1,118 +1,121 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; +import Link from "next/link"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // hooks +import { useApplication } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { LatestFeatureBlock } from "components/common"; import { - EmailForm, - UniqueCodeForm, - PasswordForm, - SetPasswordLink, + SignInEmailForm, + SignInUniqueCodeForm, + SignInPasswordForm, OAuthOptions, - OptionalSetPasswordForm, - CreatePasswordForm, - SelfHostedSignInForm, + SignInOptionalSetPasswordForm, } from "components/account"; export enum ESignInSteps { EMAIL = "EMAIL", PASSWORD = "PASSWORD", - SET_PASSWORD_LINK = "SET_PASSWORD_LINK", UNIQUE_CODE = "UNIQUE_CODE", OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", - CREATE_PASSWORD = "CREATE_PASSWORD", USE_UNIQUE_CODE_FROM_PASSWORD = "USE_UNIQUE_CODE_FROM_PASSWORD", } -const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; - export const SignInRoot = observer(() => { // states - const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); + const [signInStep, setSignInStep] = useState(null); const [email, setEmail] = useState(""); - const [isOnboarded, setIsOnboarded] = useState(false); // sign in redirection hook const { handleRedirection } = useSignInRedirection(); // mobx store const { - appConfig: { envConfig }, - } = useMobxStore(); + config: { envConfig }, + } = useApplication(); + // derived values + const isSmtpConfigured = envConfig?.is_smtp_configured; + + // step 1 submit handler- email verification + const handleEmailVerification = (isPasswordAutoset: boolean) => { + if (isSmtpConfigured && isPasswordAutoset) setSignInStep(ESignInSteps.UNIQUE_CODE); + else setSignInStep(ESignInSteps.PASSWORD); + }; + + // step 2 submit handler- unique code sign in + const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => { + if (isPasswordAutoset) setSignInStep(ESignInSteps.OPTIONAL_SET_PASSWORD); + else await handleRedirection(); + }; + + // step 3 submit handler- password sign in + const handlePasswordSignIn = async () => { + await handleRedirection(); + }; const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); + useEffect(() => { + if (isSmtpConfigured) setSignInStep(ESignInSteps.EMAIL); + else setSignInStep(ESignInSteps.PASSWORD); + }, [isSmtpConfigured]); + return ( <>
- {envConfig?.is_self_managed ? ( - setEmail(newEmail)} - handleSignInRedirection={handleRedirection} - /> - ) : ( + <> + {signInStep === ESignInSteps.EMAIL && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignInSteps.UNIQUE_CODE && ( + { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + onSubmit={handleUniqueCodeSignIn} + submitButtonText="Continue" + /> + )} + {signInStep === ESignInSteps.PASSWORD && ( + { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + onSubmit={handlePasswordSignIn} + handleStepChange={(step) => setSignInStep(step)} + /> + )} + {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( + { + setEmail(""); + setSignInStep(ESignInSteps.EMAIL); + }} + onSubmit={handleUniqueCodeSignIn} + submitButtonText="Go to workspace" + /> + )} + {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( + + )} + +
+ {isOAuthEnabled && + (signInStep === ESignInSteps.EMAIL || (!isSmtpConfigured && signInStep === ESignInSteps.PASSWORD)) && ( <> - {signInStep === ESignInSteps.EMAIL && ( - setSignInStep(step)} - updateEmail={(newEmail) => setEmail(newEmail)} - /> - )} - {signInStep === ESignInSteps.PASSWORD && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - /> - )} - {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( - setEmail(newEmail)} /> - )} - {signInStep === ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - submitButtonLabel="Go to workspace" - showTermsAndConditions - updateUserOnboardingStatus={(value) => setIsOnboarded(value)} - /> - )} - {signInStep === ESignInSteps.UNIQUE_CODE && ( - setEmail(newEmail)} - handleStepChange={(step) => setSignInStep(step)} - handleSignInRedirection={handleRedirection} - updateUserOnboardingStatus={(value) => setIsOnboarded(value)} - /> - )} - {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - isOnboarded={isOnboarded} - /> - )} - {signInStep === ESignInSteps.CREATE_PASSWORD && ( - setSignInStep(step)} - handleSignInRedirection={handleRedirection} - isOnboarded={isOnboarded} - /> - )} + +

+ Don{"'"}t have an account?{" "} + + Sign up + +

)} -
- {isOAuthEnabled && !OAUTH_HIDDEN_STEPS.includes(signInStep) && ( - - )} ); diff --git a/web/components/account/sign-in-forms/set-password-link.tsx b/web/components/account/sign-in-forms/set-password-link.tsx deleted file mode 100644 index 17dbd2ad412..00000000000 --- a/web/components/account/sign-in-forms/set-password-link.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from "react"; -import { Controller, useForm } from "react-hook-form"; -// services -import { AuthService } from "services/auth.service"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { Button, Input } from "@plane/ui"; -// helpers -import { checkEmailValidity } from "helpers/string.helper"; -// types -import { IEmailCheckData } from "types/auth"; - -type Props = { - email: string; - updateEmail: (email: string) => void; -}; - -const authService = new AuthService(); - -export const SetPasswordLink: React.FC = (props) => { - const { email, updateEmail } = props; - - const { setToastAlert } = useToast(); - - const { - control, - formState: { errors, isSubmitting, isValid }, - handleSubmit, - } = useForm({ - defaultValues: { - email, - }, - mode: "onChange", - reValidateMode: "onChange", - }); - - const handleSendNewLink = async (formData: { email: string }) => { - updateEmail(formData.email); - - const payload: IEmailCheckData = { - email: formData.email, - }; - - await authService - .sendResetPasswordLink(payload) - .then(() => - setToastAlert({ - type: "success", - title: "Success!", - message: "We have sent a new link to your email.", - }) - ) - .catch((err) => - setToastAlert({ - type: "error", - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ); - }; - - return ( - <> -

- Get on your flight deck -

-

- We have sent a link to {email}, so you can set a - password -

- -
-
- checkEmailValidity(value) || "Email is invalid", - }} - render={({ field: { value, onChange } }) => ( - - )} - /> -
- -
- - ); -}; diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 1a4fa0e493a..6e0ae374526 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useState } from "react"; -import Link from "next/link"; +import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { CornerDownLeft, XCircle } from "lucide-react"; +import { XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; import { UserService } from "services/user.service"; @@ -13,18 +12,13 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types -import { IEmailCheckData, IMagicSignInData } from "types/auth"; -// constants -import { ESignInSteps } from "components/account"; +import { IEmailCheckData, IMagicSignInData } from "@plane/types"; type Props = { email: string; - updateEmail: (email: string) => void; - handleStepChange: (step: ESignInSteps) => void; - handleSignInRedirection: () => Promise; - submitButtonLabel?: string; - showTermsAndConditions?: boolean; - updateUserOnboardingStatus: (value: boolean) => void; + onSubmit: (isPasswordAutoset: boolean) => Promise; + handleEmailClear: () => void; + submitButtonText: string; }; type TUniqueCodeFormValues = { @@ -41,16 +35,8 @@ const defaultValues: TUniqueCodeFormValues = { const authService = new AuthService(); const userService = new UserService(); -export const UniqueCodeForm: React.FC = (props) => { - const { - email, - updateEmail, - handleStepChange, - handleSignInRedirection, - submitButtonLabel = "Continue", - showTermsAndConditions = false, - updateUserOnboardingStatus, - } = props; +export const SignInUniqueCodeForm: React.FC = (props) => { + const { email, onSubmit, handleEmailClear, submitButtonText } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // toast alert @@ -60,11 +46,10 @@ export const UniqueCodeForm: React.FC = (props) => { // form info const { control, - formState: { dirtyFields, errors, isSubmitting, isValid }, + formState: { errors, isSubmitting, isValid }, getValues, handleSubmit, reset, - setFocus, } = useForm({ defaultValues: { ...defaultValues, @@ -86,10 +71,7 @@ export const UniqueCodeForm: React.FC = (props) => { .then(async () => { const currentUser = await userService.currentUser(); - updateUserOnboardingStatus(currentUser.is_onboarded); - - if (currentUser.is_password_autoset) handleStepChange(ESignInSteps.OPTIONAL_SET_PASSWORD); - else await handleSignInRedirection(); + await onSubmit(currentUser.is_password_autoset); }) .catch((err) => setToastAlert({ @@ -129,13 +111,6 @@ export const UniqueCodeForm: React.FC = (props) => { ); }; - const handleFormSubmit = async (formData: TUniqueCodeFormValues) => { - updateEmail(formData.email); - - if (dirtyFields.email) await handleSendNewCode(formData); - else await handleUniqueCodeSignIn(formData); - }; - const handleRequestNewCode = async () => { setIsRequestingNewCode(true); @@ -145,21 +120,16 @@ export const UniqueCodeForm: React.FC = (props) => { }; const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; - const hasEmailChanged = dirtyFields.email; - useEffect(() => { - setFocus("token"); - }, [setFocus]); return ( <> -

- Get on your flight deck -

+

Moving to the runway

- Paste the code you got at {email} below. + Paste the code you got at +
+ {email} below.

- -
+
= (props) => { type="email" value={value} onChange={onChange} - onBlur={() => { - if (hasEmailChanged) handleSendNewCode(getValues()); - }} ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + disabled /> {value.length > 0 && ( onChange("")} + onClick={handleEmailClear} /> )}
)} /> - {hasEmailChanged && ( - - )}
( = (props) => { hasError={Boolean(errors.token)} placeholder="gets-sets-flys" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus /> )} /> @@ -233,29 +194,14 @@ export const UniqueCodeForm: React.FC = (props) => { {resendTimerCode > 0 ? `Request new code in ${resendTimerCode}s` : isRequestingNewCode - ? "Requesting new code" - : "Request new code"} + ? "Requesting new code" + : "Request new code"}
- - {showTermsAndConditions && ( -

- When you click the button above, you agree with our{" "} - - terms and conditions of service. - -

- )} ); diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-up-forms/email.tsx similarity index 76% rename from web/components/account/sign-in-forms/email-form.tsx rename to web/components/account/sign-up-forms/email.tsx index 6b607147568..0d5861b4ee2 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-up-forms/email.tsx @@ -1,6 +1,7 @@ -import React, { useEffect } from "react"; +import React from "react"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; +import { observer } from "mobx-react-lite"; // services import { AuthService } from "services/auth.service"; // hooks @@ -10,12 +11,10 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types -import { IEmailCheckData } from "types/auth"; -// constants -import { ESignInSteps } from "components/account"; +import { IEmailCheckData } from "@plane/types"; type Props = { - handleStepChange: (step: ESignInSteps) => void; + onSubmit: () => void; updateEmail: (email: string) => void; }; @@ -25,16 +24,14 @@ type TEmailFormValues = { const authService = new AuthService(); -export const EmailForm: React.FC = (props) => { - const { handleStepChange, updateEmail } = props; - +export const SignUpEmailForm: React.FC = observer((props) => { + const { onSubmit, updateEmail } = props; + // hooks const { setToastAlert } = useToast(); - const { control, formState: { errors, isSubmitting, isValid }, handleSubmit, - setFocus, } = useForm({ defaultValues: { email: "", @@ -53,12 +50,7 @@ export const EmailForm: React.FC = (props) => { await authService .emailCheck(payload) - .then((res) => { - // if the password has been autoset, send the user to magic sign-in - if (res.is_password_autoset) handleStepChange(ESignInSteps.UNIQUE_CODE); - // if the password has not been autoset, send them to password sign-in - else handleStepChange(ESignInSteps.PASSWORD); - }) + .then(() => onSubmit()) .catch((err) => setToastAlert({ type: "error", @@ -68,10 +60,6 @@ export const EmailForm: React.FC = (props) => { ); }; - useEffect(() => { - setFocus("email"); - }, [setFocus]); - return ( <>

@@ -90,7 +78,7 @@ export const EmailForm: React.FC = (props) => { required: "Email is required", validate: (value) => checkEmailValidity(value) || "Email is invalid", }} - render={({ field: { value, onChange, ref } }) => ( + render={({ field: { value, onChange } }) => (
= (props) => { type="email" value={value} onChange={onChange} - ref={ref} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" + autoFocus /> {value.length > 0 && ( = (props) => { />
); -}; +}); diff --git a/web/components/account/sign-up-forms/index.ts b/web/components/account/sign-up-forms/index.ts new file mode 100644 index 00000000000..f84d41abc2d --- /dev/null +++ b/web/components/account/sign-up-forms/index.ts @@ -0,0 +1,5 @@ +export * from "./email"; +export * from "./optional-set-password"; +export * from "./password"; +export * from "./root"; +export * from "./unique-code"; diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx new file mode 100644 index 00000000000..db14f0ccb50 --- /dev/null +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -0,0 +1,179 @@ +import React, { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +// services +import { AuthService } from "services/auth.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// constants +import { ESignUpSteps } from "components/account"; +// icons +import { Eye, EyeOff } from "lucide-react"; + +type Props = { + email: string; + handleStepChange: (step: ESignUpSteps) => void; + handleSignInRedirection: () => Promise; +}; + +type TCreatePasswordFormValues = { + email: string; + password: string; +}; + +const defaultValues: TCreatePasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export const SignUpOptionalSetPasswordForm: React.FC = (props) => { + const { email, handleSignInRedirection } = props; + // states + const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + const [showPassword, setShowPassword] = useState(false); + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleCreatePassword = async (formData: TCreatePasswordFormValues) => { + const payload = { + password: formData.password, + }; + + await authService + .setPassword(payload) + .then(async () => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Password created successfully.", + }); + await handleSignInRedirection(); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleGoToWorkspace = async () => { + setIsGoingToWorkspace(true); + + await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + }; + + return ( + <> +

Moving to the runway

+

+ Let{"'"}s set a password so +
+ you can do away with codes. +

+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> +
+ ( +
+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
+ )} + /> +

+ This password will continue to be your account{"'"}s password. +

+
+
+ + +
+ + + ); +}; diff --git a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx b/web/components/account/sign-up-forms/password.tsx similarity index 59% rename from web/components/account/sign-in-forms/self-hosted-sign-in.tsx rename to web/components/account/sign-up-forms/password.tsx index 2335226ce3e..293e03ef874 100644 --- a/web/components/account/sign-in-forms/self-hosted-sign-in.tsx +++ b/web/components/account/sign-up-forms/password.tsx @@ -1,7 +1,8 @@ -import React, { useEffect } from "react"; +import React, { useState } from "react"; import Link from "next/link"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; -import { XCircle } from "lucide-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; // hooks @@ -11,12 +12,10 @@ import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types -import { IPasswordSignInData } from "types/auth"; +import { IPasswordSignInData } from "@plane/types"; type Props = { - email: string; - updateEmail: (email: string) => void; - handleSignInRedirection: () => Promise; + onSubmit: () => Promise; }; type TPasswordFormValues = { @@ -31,20 +30,20 @@ const defaultValues: TPasswordFormValues = { const authService = new AuthService(); -export const SelfHostedSignInForm: React.FC = (props) => { - const { email, updateEmail, handleSignInRedirection } = props; +export const SignUpPasswordForm: React.FC = observer((props) => { + const { onSubmit } = props; + // states + const [showPassword, setShowPassword] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info const { control, - formState: { dirtyFields, errors, isSubmitting }, + formState: { errors, isSubmitting, isValid }, handleSubmit, - setFocus, } = useForm({ defaultValues: { ...defaultValues, - email, }, mode: "onChange", reValidateMode: "onChange", @@ -56,11 +55,9 @@ export const SelfHostedSignInForm: React.FC = (props) => { password: formData.password, }; - updateEmail(formData.email); - await authService .passwordSignIn(payload) - .then(async () => await handleSignInRedirection()) + .then(async () => await onSubmit()) .catch((err) => setToastAlert({ type: "error", @@ -70,16 +67,15 @@ export const SelfHostedSignInForm: React.FC = (props) => { ); }; - useEffect(() => { - setFocus("email"); - }, [setFocus]); - return ( <> -

+

Get on your flight deck

-
+

+ Create or join a workspace. Start with your e-mail. +

+
= (props) => { value={value} onChange={onChange} hasError={Boolean(errors.email)} - placeholder="orville.wright@frstflt.com" + placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 pr-12 placeholder:text-onboarding-text-400" /> {value.length > 0 && ( @@ -115,22 +111,39 @@ export const SelfHostedSignInForm: React.FC = (props) => { control={control} name="password" rules={{ - required: dirtyFields.email ? false : "Password is required", + required: "Password is required", }} render={({ field: { value, onChange } }) => ( - +
+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
)} /> +

+ This password will continue to be your account{"'"}s password. +

-

When you click the button above, you agree with our{" "} @@ -141,4 +154,4 @@ export const SelfHostedSignInForm: React.FC = (props) => { ); -}; +}); diff --git a/web/components/account/sign-up-forms/root.tsx b/web/components/account/sign-up-forms/root.tsx new file mode 100644 index 00000000000..da9d7d79a96 --- /dev/null +++ b/web/components/account/sign-up-forms/root.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useApplication } from "hooks/store"; +import useSignInRedirection from "hooks/use-sign-in-redirection"; +// components +import { + OAuthOptions, + SignUpEmailForm, + SignUpOptionalSetPasswordForm, + SignUpPasswordForm, + SignUpUniqueCodeForm, +} from "components/account"; +import Link from "next/link"; + +export enum ESignUpSteps { + EMAIL = "EMAIL", + UNIQUE_CODE = "UNIQUE_CODE", + PASSWORD = "PASSWORD", + OPTIONAL_SET_PASSWORD = "OPTIONAL_SET_PASSWORD", +} + +const OAUTH_ENABLED_STEPS = [ESignUpSteps.EMAIL]; + +export const SignUpRoot = observer(() => { + // states + const [signInStep, setSignInStep] = useState(null); + const [email, setEmail] = useState(""); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); + // mobx store + const { + config: { envConfig }, + } = useApplication(); + + // step 1 submit handler- email verification + const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE); + + // step 2 submit handler- unique code sign in + const handleUniqueCodeSignIn = async (isPasswordAutoset: boolean) => { + if (isPasswordAutoset) setSignInStep(ESignUpSteps.OPTIONAL_SET_PASSWORD); + else await handleRedirection(); + }; + + // step 3 submit handler- password sign in + const handlePasswordSignIn = async () => { + await handleRedirection(); + }; + + const isOAuthEnabled = envConfig && (envConfig.google_client_id || envConfig.github_client_id); + + useEffect(() => { + if (envConfig?.is_smtp_configured) setSignInStep(ESignUpSteps.EMAIL); + else setSignInStep(ESignUpSteps.PASSWORD); + }, [envConfig?.is_smtp_configured]); + + return ( + <> +

+ <> + {signInStep === ESignUpSteps.EMAIL && ( + setEmail(newEmail)} /> + )} + {signInStep === ESignUpSteps.UNIQUE_CODE && ( + { + setEmail(""); + setSignInStep(ESignUpSteps.EMAIL); + }} + onSubmit={handleUniqueCodeSignIn} + /> + )} + {signInStep === ESignUpSteps.PASSWORD && } + {signInStep === ESignUpSteps.OPTIONAL_SET_PASSWORD && ( + setSignInStep(step)} + /> + )} + +
+ {isOAuthEnabled && signInStep && OAUTH_ENABLED_STEPS.includes(signInStep) && ( + <> + +

+ Already using Plane?{" "} + + Sign in + +

+ + )} + + ); +}); diff --git a/web/components/account/sign-up-forms/unique-code.tsx b/web/components/account/sign-up-forms/unique-code.tsx new file mode 100644 index 00000000000..7764b627edf --- /dev/null +++ b/web/components/account/sign-up-forms/unique-code.tsx @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import Link from "next/link"; +import { Controller, useForm } from "react-hook-form"; +import { XCircle } from "lucide-react"; +// services +import { AuthService } from "services/auth.service"; +import { UserService } from "services/user.service"; +// hooks +import useToast from "hooks/use-toast"; +import useTimer from "hooks/use-timer"; +// ui +import { Button, Input } from "@plane/ui"; +// helpers +import { checkEmailValidity } from "helpers/string.helper"; +// types +import { IEmailCheckData, IMagicSignInData } from "@plane/types"; + +type Props = { + email: string; + handleEmailClear: () => void; + onSubmit: (isPasswordAutoset: boolean) => Promise; +}; + +type TUniqueCodeFormValues = { + email: string; + token: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + token: "", +}; + +// services +const authService = new AuthService(); +const userService = new UserService(); + +export const SignUpUniqueCodeForm: React.FC = (props) => { + const { email, handleEmailClear, onSubmit } = props; + // states + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // toast alert + const { setToastAlert } = useToast(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + getValues, + handleSubmit, + reset, + } = useForm({ + defaultValues: { + ...defaultValues, + email, + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + const handleUniqueCodeSignIn = async (formData: TUniqueCodeFormValues) => { + const payload: IMagicSignInData = { + email: formData.email, + key: `magic_${formData.email}`, + token: formData.token, + }; + + await authService + .magicSignIn(payload) + .then(async () => { + const currentUser = await userService.currentUser(); + + await onSubmit(currentUser.is_password_autoset); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { + const payload: IEmailCheckData = { + email: formData.email, + }; + + await authService + .generateUniqueCode(payload) + .then(() => { + setResendCodeTimer(30); + setToastAlert({ + type: "success", + title: "Success!", + message: "A new unique code has been sent to your email.", + }); + + reset({ + email: formData.email, + token: "", + }); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }) + ); + }; + + const handleRequestNewCode = async () => { + setIsRequestingNewCode(true); + + await handleSendNewCode(getValues()) + .then(() => setResendCodeTimer(30)) + .finally(() => setIsRequestingNewCode(false)); + }; + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + + return ( + <> +

Moving to the runway

+

+ Paste the code you got at +
+ {email} below. +

+ +
+
+ checkEmailValidity(value) || "Email is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( +
+ + {value.length > 0 && ( + + )} +
+ )} + /> +
+
+ ( + + )} + /> +
+ +
+
+ +

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + +

+
+ + ); +}; diff --git a/web/components/analytics/custom-analytics/custom-analytics.tsx b/web/components/analytics/custom-analytics/custom-analytics.tsx index 635fbee7f92..a3c083b027c 100644 --- a/web/components/analytics/custom-analytics/custom-analytics.tsx +++ b/web/components/analytics/custom-analytics/custom-analytics.tsx @@ -7,7 +7,7 @@ import { AnalyticsService } from "services/analytics.service"; // components import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSidebar } from "components/analytics"; // types -import { IAnalyticsParams } from "types"; +import { IAnalyticsParams } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; diff --git a/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx b/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx index 9917d0f58e1..ec7c4019507 100644 --- a/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx +++ b/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx @@ -3,7 +3,7 @@ import { BarTooltipProps } from "@nivo/bar"; import { DATE_KEYS } from "constants/analytics"; import { renderMonthAndYear } from "helpers/analytics.helper"; // types -import { IAnalyticsParams, IAnalyticsResponse } from "types"; +import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types"; type Props = { datum: BarTooltipProps; @@ -60,8 +60,8 @@ export const CustomTooltip: React.FC = ({ datum, analytics, params }) => ? "capitalize" : "" : params.x_axis === "priority" || params.x_axis === "state__group" - ? "capitalize" - : "" + ? "capitalize" + : "" }`} > {params.segment === "assignees__id" ? renderAssigneeName(tooltipValue.toString()) : tooltipValue}: diff --git a/web/components/analytics/custom-analytics/graph/index.tsx b/web/components/analytics/custom-analytics/graph/index.tsx index 06431ab02dd..51b4089c4f2 100644 --- a/web/components/analytics/custom-analytics/graph/index.tsx +++ b/web/components/analytics/custom-analytics/graph/index.tsx @@ -9,7 +9,7 @@ import { BarGraph } from "components/ui"; import { findStringWithMostCharacters } from "helpers/array.helper"; import { generateBarColor, generateDisplayName } from "helpers/analytics.helper"; // types -import { IAnalyticsParams, IAnalyticsResponse } from "types"; +import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types"; type Props = { analytics: IAnalyticsResponse; @@ -101,8 +101,8 @@ export const AnalyticsGraph: React.FC = ({ analytics, barGraphData, param ? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase() : "?" : datum.value && datum.value !== "None" - ? `${datum.value}`.toUpperCase()[0] - : "?"} + ? `${datum.value}`.toUpperCase()[0] + : "?"} diff --git a/web/components/analytics/custom-analytics/main-content.tsx b/web/components/analytics/custom-analytics/main-content.tsx index 5cfd1548298..3c199f8078c 100644 --- a/web/components/analytics/custom-analytics/main-content.tsx +++ b/web/components/analytics/custom-analytics/main-content.tsx @@ -8,7 +8,7 @@ import { Button, Loader } from "@plane/ui"; // helpers import { convertResponseToBarGraphData } from "helpers/analytics.helper"; // types -import { IAnalyticsParams, IAnalyticsResponse } from "types"; +import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; diff --git a/web/components/analytics/custom-analytics/select-bar.tsx b/web/components/analytics/custom-analytics/select-bar.tsx index f3d7a99937c..19f83e40b99 100644 --- a/web/components/analytics/custom-analytics/select-bar.tsx +++ b/web/components/analytics/custom-analytics/select-bar.tsx @@ -1,13 +1,11 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Control, Controller, UseFormSetValue } from "react-hook-form"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useProject } from "hooks/store"; // components import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics"; // types -import { IAnalyticsParams } from "types"; +import { IAnalyticsParams } from "@plane/types"; type Props = { control: Control; @@ -20,12 +18,7 @@ type Props = { export const CustomAnalyticsSelectBar: React.FC = observer((props) => { const { control, setValue, params, fullScreen, isProjectLevel } = props; - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { project: projectStore } = useMobxStore(); - - const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : null; + const { workspaceProjectIds: workspaceProjectIds } = useProject(); return (
= observer((props) => { name="project" control={control} render={({ field: { value, onChange } }) => ( - + )} />
diff --git a/web/components/analytics/custom-analytics/select/project.tsx b/web/components/analytics/custom-analytics/select/project.tsx index 7251c507394..3c08e157473 100644 --- a/web/components/analytics/custom-analytics/select/project.tsx +++ b/web/components/analytics/custom-analytics/select/project.tsx @@ -1,25 +1,33 @@ +import { observer } from "mobx-react-lite"; +// hooks +import { useProject } from "hooks/store"; // ui import { CustomSearchSelect } from "@plane/ui"; -// types -import { IProject } from "types"; type Props = { value: string[] | undefined; onChange: (val: string[] | null) => void; - projects: IProject[] | undefined; + projectIds: string[] | undefined; }; -export const SelectProject: React.FC = ({ value, onChange, projects }) => { - const options = projects?.map((project) => ({ - value: project.id, - query: project.name + project.identifier, - content: ( -
- {project.identifier} - {project.name} -
- ), - })); +export const SelectProject: React.FC = observer((props) => { + const { value, onChange, projectIds } = props; + const { getProjectById } = useProject(); + + const options = projectIds?.map((projectId) => { + const projectDetails = getProjectById(projectId); + + return { + value: projectDetails?.id, + query: `${projectDetails?.name} ${projectDetails?.identifier}`, + content: ( +
+ {projectDetails?.identifier} + {projectDetails?.name} +
+ ), + }; + }); return ( = ({ value, onChange, projects }) => options={options} label={ value && value.length > 0 - ? projects - ?.filter((p) => value.includes(p.id)) - .map((p) => p.identifier) + ? projectIds + ?.filter((p) => value.includes(p)) + .map((p) => getProjectById(p)?.name) .join(", ") : "All projects" } - optionsClassName="min-w-full max-w-[20rem]" multiple /> ); -}; +}); diff --git a/web/components/analytics/custom-analytics/select/segment.tsx b/web/components/analytics/custom-analytics/select/segment.tsx index 4efc6a21195..055665d9ee2 100644 --- a/web/components/analytics/custom-analytics/select/segment.tsx +++ b/web/components/analytics/custom-analytics/select/segment.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; // ui import { CustomSelect } from "@plane/ui"; // types -import { IAnalyticsParams, TXAxisValues } from "types"; +import { IAnalyticsParams, TXAxisValues } from "@plane/types"; // constants import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; @@ -28,7 +28,6 @@ export const SelectSegment: React.FC = ({ value, onChange, params }) => { } onChange={onChange} - width="w-full" maxHeight="lg" > No value diff --git a/web/components/analytics/custom-analytics/select/x-axis.tsx b/web/components/analytics/custom-analytics/select/x-axis.tsx index 66582a1e97b..74ee99a7708 100644 --- a/web/components/analytics/custom-analytics/select/x-axis.tsx +++ b/web/components/analytics/custom-analytics/select/x-axis.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; // ui import { CustomSelect } from "@plane/ui"; // types -import { IAnalyticsParams, TXAxisValues } from "types"; +import { IAnalyticsParams, TXAxisValues } from "@plane/types"; // constants import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; @@ -24,7 +24,6 @@ export const SelectXAxis: React.FC = (props) => { value={value} label={{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}} onChange={onChange} - width="w-full" maxHeight="lg" > {ANALYTICS_X_AXIS_VALUES.map((item) => { diff --git a/web/components/analytics/custom-analytics/select/y-axis.tsx b/web/components/analytics/custom-analytics/select/y-axis.tsx index 3f7348cce79..9f66c6b5450 100644 --- a/web/components/analytics/custom-analytics/select/y-axis.tsx +++ b/web/components/analytics/custom-analytics/select/y-axis.tsx @@ -1,7 +1,7 @@ // ui import { CustomSelect } from "@plane/ui"; // types -import { TYAxisValues } from "types"; +import { TYAxisValues } from "@plane/types"; // constants import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics"; @@ -15,7 +15,7 @@ export const SelectYAxis: React.FC = ({ value, onChange }) => ( value={value} label={{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}} onChange={onChange} - width="w-full" + maxHeight="lg" > {ANALYTICS_Y_AXIS_VALUES.map((item) => ( diff --git a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx index 41770eec8db..d09e8def49a 100644 --- a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx +++ b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx @@ -1,65 +1,74 @@ +import { observer } from "mobx-react-lite"; +// hooks +import { useProject } from "hooks/store"; // icons import { Contrast, LayoutGrid, Users } from "lucide-react"; // helpers import { renderEmoji } from "helpers/emoji.helper"; import { truncateText } from "helpers/string.helper"; -// types -import { IProject } from "types"; type Props = { - projects: IProject[]; + projectIds: string[]; }; -export const CustomAnalyticsSidebarProjectsList: React.FC = (props) => { - const { projects } = props; +export const CustomAnalyticsSidebarProjectsList: React.FC = observer((props) => { + const { projectIds } = props; + + const { getProjectById } = useProject(); return (

Selected Projects

- {projects.map((project) => ( -
-
- {project.emoji ? ( - {renderEmoji(project.emoji)} - ) : project.icon_prop ? ( -
{renderEmoji(project.icon_prop)}
- ) : ( - - {project?.name.charAt(0)} - - )} -
-

{truncateText(project.name, 20)}

- ({project.identifier}) -
-
-
-
-
- -
Total members
-
- {project.total_members} + {projectIds.map((projectId) => { + const project = getProjectById(projectId); + + if (!project) return; + + return ( +
+
+ {project.emoji ? ( + {renderEmoji(project.emoji)} + ) : project.icon_prop ? ( +
{renderEmoji(project.icon_prop)}
+ ) : ( + + {project?.name.charAt(0)} + + )} +
+

{truncateText(project.name, 20)}

+ ({project.identifier}) +
-
-
- -
Total cycles
+
+
+
+ +
Total members
+
+ {project.total_members}
- {project.total_cycles} -
-
-
- -
Total modules
+
+
+ +
Total cycles
+
+ {project.total_cycles} +
+
+
+ +
Total modules
+
+ {project.total_modules}
- {project.total_modules}
-
- ))} + ); + })}
); -}; +}); diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index 2eaaac7fbab..4a18011d154 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -1,26 +1,26 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useCycle, useMember, useModule, useProject } from "hooks/store"; // helpers import { renderEmoji } from "helpers/emoji.helper"; -import { renderShortDate } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { NETWORK_CHOICES } from "constants/project"; export const CustomAnalyticsSidebarHeader = observer(() => { const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { projectId, cycleId, moduleId } = router.query; - const { cycle: cycleStore, module: moduleStore, project: projectStore } = useMobxStore(); + const { getProjectById } = useProject(); + const { getCycleById } = useCycle(); + const { getModuleById } = useModule(); + const { getUserDetails } = useMember(); - const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined; - const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined; - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) - : undefined; + 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; return ( <> @@ -31,13 +31,13 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Lead
- {cycleDetails.owned_by?.display_name} + {cycleOwnerDetails?.display_name}
Start Date
{cycleDetails.start_date && cycleDetails.start_date !== "" - ? renderShortDate(cycleDetails.start_date) + ? renderFormattedDate(cycleDetails.start_date) : "No start date"}
@@ -45,7 +45,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Target Date
{cycleDetails.end_date && cycleDetails.end_date !== "" - ? renderShortDate(cycleDetails.end_date) + ? renderFormattedDate(cycleDetails.end_date) : "No end date"}
@@ -63,7 +63,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Start Date
{moduleDetails.start_date && moduleDetails.start_date !== "" - ? renderShortDate(moduleDetails.start_date) + ? renderFormattedDate(moduleDetails.start_date) : "No start date"}
@@ -71,7 +71,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Target Date
{moduleDetails.target_date && moduleDetails.target_date !== "" - ? renderShortDate(moduleDetails.target_date) + ? renderFormattedDate(moduleDetails.target_date) : "No end date"}
diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx index 7d1a6a3eba5..59013a3e3e2 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -5,8 +5,8 @@ import { mutate } from "swr"; // services import { AnalyticsService } from "services/analytics.service"; // hooks +import { useCycle, useModule, useProject, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; -import { useMobxStore } from "lib/mobx/store-provider"; // components import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; // ui @@ -14,9 +14,9 @@ import { Button, LayersIcon } from "@plane/ui"; // icons import { CalendarDays, Download, RefreshCw } from "lucide-react"; // helpers -import { renderShortDate } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "types"; +import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; @@ -29,172 +29,167 @@ type Props = { const analyticsService = new AnalyticsService(); -export const CustomAnalyticsSidebar: React.FC = observer( - ({ analytics, params, fullScreen, isProjectLevel = false }) => { - const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - - const { setToastAlert } = useToast(); - - const { user: userStore, project: projectStore, cycle: cycleStore, module: moduleStore } = useMobxStore(); - - const user = userStore.currentUser; - - const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined; - const projectDetails = - workspaceSlug && projectId - ? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString()) ?? undefined - : undefined; - - const trackExportAnalytics = () => { - if (!user) return; - - const eventPayload: any = { - workspaceSlug: workspaceSlug?.toString(), - params: { - x_axis: params.x_axis, - y_axis: params.y_axis, - group: params.segment, - project: params.project, - }, - }; - - if (projectDetails) { - const workspaceDetails = projectDetails.workspace as IWorkspace; - - eventPayload.workspaceId = workspaceDetails.id; - eventPayload.workspaceName = workspaceDetails.name; - eventPayload.projectId = projectDetails.id; - eventPayload.projectIdentifier = projectDetails.identifier; - eventPayload.projectName = projectDetails.name; - } - - 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; - } - - if (cycleDetails) { - eventPayload.cycleId = cycleDetails.id; - eventPayload.cycleName = cycleDetails.name; - } - - if (moduleDetails) { - eventPayload.moduleId = moduleDetails.id; - eventPayload.moduleName = moduleDetails.name; - } - }; - - const exportAnalytics = () => { - if (!workspaceSlug) return; - - const data: IExportAnalyticsFormData = { +export const CustomAnalyticsSidebar: React.FC = observer((props) => { + const { analytics, params, fullScreen, isProjectLevel = false } = props; + // router + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + // toast alert + const { setToastAlert } = useToast(); + // store hooks + const { currentUser } = useUser(); + const { workspaceProjectIds, getProjectById } = useProject(); + const { fetchCycleDetails, getCycleById } = useCycle(); + const { fetchModuleDetails, getModuleById } = useModule(); + + const projectDetails = projectId ? getProjectById(projectId.toString()) ?? undefined : undefined; + + const trackExportAnalytics = () => { + if (!currentUser) return; + + const eventPayload: any = { + workspaceSlug: workspaceSlug?.toString(), + params: { x_axis: params.x_axis, y_axis: params.y_axis, - }; - - if (params.segment) data.segment = params.segment; - if (params.project) data.project = params.project; - - analyticsService - .exportAnalytics(workspaceSlug.toString(), data) - .then((res) => { - setToastAlert({ - type: "success", - title: "Success!", - message: res.message, - }); - - trackExportAnalytics(); - }) - .catch(() => - setToastAlert({ - type: "error", - title: "Error!", - message: "There was some error in exporting the analytics. Please try again.", - }) - ); + group: params.segment, + project: params.project, + }, }; - const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined; - const moduleDetails = moduleId ? moduleStore.getModuleById(moduleId.toString()) : undefined; - - // fetch cycle details - useEffect(() => { - if (!workspaceSlug || !projectId || !cycleId || cycleDetails) return; - - cycleStore.fetchCycleWithId(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); - }, [cycleId, cycleDetails, cycleStore, projectId, workspaceSlug]); - - // fetch module details - useEffect(() => { - if (!workspaceSlug || !projectId || !moduleId || moduleDetails) return; - - moduleStore.fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); - }, [moduleId, moduleDetails, moduleStore, projectId, workspaceSlug]); - - const selectedProjects = params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id); + if (projectDetails) { + const workspaceDetails = projectDetails.workspace as IWorkspace; + + eventPayload.workspaceId = workspaceDetails.id; + eventPayload.workspaceName = workspaceDetails.name; + eventPayload.projectId = projectDetails.id; + eventPayload.projectIdentifier = projectDetails.identifier; + eventPayload.projectName = projectDetails.name; + } + + 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; + } + + if (cycleDetails) { + eventPayload.cycleId = cycleDetails.id; + eventPayload.cycleName = cycleDetails.name; + } + + if (moduleDetails) { + eventPayload.moduleId = moduleDetails.id; + eventPayload.moduleName = moduleDetails.name; + } + }; + + const exportAnalytics = () => { + if (!workspaceSlug) return; + + const data: IExportAnalyticsFormData = { + x_axis: params.x_axis, + y_axis: params.y_axis, + }; - return ( -
-
+ if (params.segment) data.segment = params.segment; + if (params.project) data.project = params.project; + + analyticsService + .exportAnalytics(workspaceSlug.toString(), data) + .then((res) => { + setToastAlert({ + type: "success", + title: "Success!", + message: res.message, + }); + + trackExportAnalytics(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "There was some error in exporting the analytics. Please try again.", + }) + ); + }; + + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + // fetch cycle details + useEffect(() => { + if (!workspaceSlug || !projectId || !cycleId || cycleDetails) return; + + fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); + }, [cycleId, cycleDetails, fetchCycleDetails, projectId, workspaceSlug]); + + // fetch module details + useEffect(() => { + if (!workspaceSlug || !projectId || !moduleId || moduleDetails) return; + + fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); + }, [moduleId, moduleDetails, fetchModuleDetails, projectId, workspaceSlug]); + + const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; + + return ( +
+
+
+ + {analytics ? analytics.total : "..."} Issues +
+ {isProjectLevel && (
- - {analytics ? analytics.total : "..."} Issues + + {renderFormattedDate( + (cycleId + ? cycleDetails?.created_at + : moduleId + ? moduleDetails?.created_at + : projectDetails?.created_at) ?? "" + )}
- {isProjectLevel && ( -
- - {renderShortDate( - (cycleId - ? cycleDetails?.created_at - : moduleId - ? moduleDetails?.created_at - : projectDetails?.created_at) ?? "" - )} -
- )} -
-
- {fullScreen ? ( - <> - {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( - selectedProjects.includes(p.id)) ?? []} - /> - )} - - - ) : null} -
-
- - -
+ )} +
+
+ {fullScreen ? ( + <> + {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( + + )} + + + ) : null} +
+
+ +
- ); - } -); +
+ ); +}); diff --git a/web/components/analytics/custom-analytics/table.tsx b/web/components/analytics/custom-analytics/table.tsx index 2066292c8e3..c09f26d7657 100644 --- a/web/components/analytics/custom-analytics/table.tsx +++ b/web/components/analytics/custom-analytics/table.tsx @@ -5,7 +5,7 @@ import { PriorityIcon } from "@plane/ui"; // helpers import { generateBarColor, generateDisplayName } from "helpers/analytics.helper"; // types -import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "types"; +import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "@plane/types"; // constants import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "constants/analytics"; diff --git a/web/components/analytics/project-modal/main-content.tsx b/web/components/analytics/project-modal/main-content.tsx index 55ed1d40392..09423e6dd71 100644 --- a/web/components/analytics/project-modal/main-content.tsx +++ b/web/components/analytics/project-modal/main-content.tsx @@ -4,7 +4,7 @@ import { Tab } from "@headlessui/react"; // components import { CustomAnalytics, ScopeAndDemand } from "components/analytics"; // types -import { ICycle, IModule, IProject } from "types"; +import { ICycle, IModule, IProject } from "@plane/types"; // constants import { ANALYTICS_TABS } from "constants/analytics"; diff --git a/web/components/analytics/project-modal/modal.tsx b/web/components/analytics/project-modal/modal.tsx index 6dfbfdd6b51..a4b82c4b6d0 100644 --- a/web/components/analytics/project-modal/modal.tsx +++ b/web/components/analytics/project-modal/modal.tsx @@ -5,7 +5,7 @@ import { Dialog, Transition } from "@headlessui/react"; // components import { ProjectAnalyticsModalHeader, ProjectAnalyticsModalMainContent } from "components/analytics"; // types -import { ICycle, IModule, IProject } from "types"; +import { ICycle, IModule, IProject } from "@plane/types"; type Props = { isOpen: boolean; diff --git a/web/components/analytics/scope-and-demand/demand.tsx b/web/components/analytics/scope-and-demand/demand.tsx index df679fbc5c4..e66ffeabfee 100644 --- a/web/components/analytics/scope-and-demand/demand.tsx +++ b/web/components/analytics/scope-and-demand/demand.tsx @@ -1,9 +1,9 @@ // icons import { Triangle } from "lucide-react"; // types -import { IDefaultAnalyticsResponse, TStateGroups } from "types"; +import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types"; // constants -import { STATE_GROUP_COLORS } from "constants/state"; +import { STATE_GROUPS } from "constants/state"; type Props = { defaultAnalytics: IDefaultAnalyticsResponse; @@ -27,7 +27,7 @@ export const AnalyticsDemand: React.FC = ({ defaultAnalytics }) => (
{group.state_group}
@@ -42,7 +42,7 @@ export const AnalyticsDemand: React.FC = ({ defaultAnalytics }) => ( className="absolute left-0 top-0 h-1 rounded duration-300" style={{ width: `${percentage}%`, - backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups], + backgroundColor: STATE_GROUPS[group.state_group as TStateGroups].color, }} />
diff --git a/web/components/analytics/scope-and-demand/scope.tsx b/web/components/analytics/scope-and-demand/scope.tsx index 4c69a23c5f8..ea1a51937d4 100644 --- a/web/components/analytics/scope-and-demand/scope.tsx +++ b/web/components/analytics/scope-and-demand/scope.tsx @@ -3,7 +3,7 @@ import { BarGraph, ProfileEmptyState } from "components/ui"; // image import emptyBarGraph from "public/empty-state/empty_bar_graph.svg"; // types -import { IDefaultAnalyticsResponse } from "types"; +import { IDefaultAnalyticsResponse } from "@plane/types"; type Props = { defaultAnalytics: IDefaultAnalyticsResponse; diff --git a/web/components/analytics/scope-and-demand/year-wise-issues.tsx b/web/components/analytics/scope-and-demand/year-wise-issues.tsx index aec15d9acd3..2a62c99d4bd 100644 --- a/web/components/analytics/scope-and-demand/year-wise-issues.tsx +++ b/web/components/analytics/scope-and-demand/year-wise-issues.tsx @@ -3,7 +3,7 @@ import { LineGraph, ProfileEmptyState } from "components/ui"; // image import emptyGraph from "public/empty-state/empty_graph.svg"; // types -import { IDefaultAnalyticsResponse } from "types"; +import { IDefaultAnalyticsResponse } from "@plane/types"; // constants import { MONTHS_LIST } from "constants/calendar"; diff --git a/web/components/api-token/delete-token-modal.tsx b/web/components/api-token/delete-token-modal.tsx index ed61d3546dd..993289c10c7 100644 --- a/web/components/api-token/delete-token-modal.tsx +++ b/web/components/api-token/delete-token-modal.tsx @@ -9,7 +9,7 @@ import useToast from "hooks/use-toast"; // ui import { Button } from "@plane/ui"; // types -import { IApiToken } from "types/api_token"; +import { IApiToken } from "@plane/types"; // fetch-keys import { API_TOKENS_LIST } from "constants/fetch-keys"; diff --git a/web/components/api-token/modal/create-token-modal.tsx b/web/components/api-token/modal/create-token-modal.tsx index 65c5bf362c4..b3fc3df78ec 100644 --- a/web/components/api-token/modal/create-token-modal.tsx +++ b/web/components/api-token/modal/create-token-modal.tsx @@ -12,7 +12,7 @@ import { CreateApiTokenForm, GeneratedTokenDetails } from "components/api-token" import { csvDownload } from "helpers/download.helper"; import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { IApiToken } from "types/api_token"; +import { IApiToken } from "@plane/types"; // fetch-keys import { API_TOKENS_LIST } from "constants/fetch-keys"; @@ -48,7 +48,7 @@ export const CreateApiTokenModal: React.FC = (props) => { const csvData = { Title: data.label, Description: data.description, - Expiry: data.expired_at ? renderFormattedDate(data.expired_at) : "Never expires", + Expiry: data.expired_at ? renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "" : "Never expires", "Secret key": data.token ?? "", }; diff --git a/web/components/api-token/modal/form.tsx b/web/components/api-token/modal/form.tsx index a04968dac07..ae7717b3933 100644 --- a/web/components/api-token/modal/form.tsx +++ b/web/components/api-token/modal/form.tsx @@ -11,7 +11,7 @@ import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; // helpers import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper"; // types -import { IApiToken } from "types/api_token"; +import { IApiToken } from "@plane/types"; type Props = { handleClose: () => void; @@ -175,8 +175,8 @@ export const CreateApiTokenForm: React.FC = (props) => { {value === "custom" ? "Custom date" : selectedOption - ? selectedOption.label - : "Set expiration date"} + ? selectedOption.label + : "Set expiration date"}
} value={value} @@ -219,8 +219,8 @@ export const CreateApiTokenForm: React.FC = (props) => { ? `Expires ${renderFormattedDate(customDate)}` : null : watch("expired_at") - ? `Expires ${getExpiryDate(watch("expired_at") ?? "")}` - : null} + ? `Expires ${getExpiryDate(watch("expired_at") ?? "")}` + : null} )}
diff --git a/web/components/api-token/modal/generated-token-details.tsx b/web/components/api-token/modal/generated-token-details.tsx index 1ffa69a78a8..f28ea348126 100644 --- a/web/components/api-token/modal/generated-token-details.tsx +++ b/web/components/api-token/modal/generated-token-details.tsx @@ -7,7 +7,7 @@ import { Button, Tooltip } from "@plane/ui"; import { renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; // types -import { IApiToken } from "types/api_token"; +import { IApiToken } from "@plane/types"; type Props = { handleClose: () => void; diff --git a/web/components/api-token/token-list-item.tsx b/web/components/api-token/token-list-item.tsx index 148924d6f77..2de73122280 100644 --- a/web/components/api-token/token-list-item.tsx +++ b/web/components/api-token/token-list-item.tsx @@ -5,9 +5,9 @@ import { DeleteApiTokenModal } from "components/api-token"; // ui import { Tooltip } from "@plane/ui"; // helpers -import { renderFormattedDate, timeAgo } from "helpers/date-time.helper"; +import { renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; // types -import { IApiToken } from "types/api_token"; +import { IApiToken } from "@plane/types"; type Props = { token: IApiToken; @@ -49,7 +49,7 @@ export const ApiTokenListItem: React.FC = (props) => { ? token.expired_at ? `Expires ${renderFormattedDate(token.expired_at!)}` : "Never expires" - : `Expired ${timeAgo(token.expired_at)}`} + : `Expired ${calculateTimeAgo(token.expired_at)}`}

diff --git a/web/components/auth-screens/not-authorized-view.tsx b/web/components/auth-screens/not-authorized-view.tsx index f0a3e3d904c..8d9d6ecd4d9 100644 --- a/web/components/auth-screens/not-authorized-view.tsx +++ b/web/components/auth-screens/not-authorized-view.tsx @@ -1,12 +1,12 @@ import React from "react"; -// next import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// hooks +import { useUser } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; -// hooks -import useUser from "hooks/use-user"; // images import ProjectNotAuthorizedImg from "public/auth/project-not-authorized.svg"; import WorkspaceNotAuthorizedImg from "public/auth/workspace-not-authorized.svg"; @@ -16,8 +16,9 @@ type Props = { type: "project" | "workspace"; }; -export const NotAuthorizedView: React.FC = ({ actionButton, type }) => { - const { user } = useUser(); +export const NotAuthorizedView: React.FC = observer((props) => { + const { actionButton, type } = props; + const { currentUser } = useUser(); const { query } = useRouter(); const { next_path } = query; @@ -35,9 +36,9 @@ export const NotAuthorizedView: React.FC = ({ actionButton, type }) => {

Oops! You are not authorized to view this page

- {user ? ( + {currentUser ? (

- You have signed in as {user.email}.
+ You have signed in as {currentUser.email}.
Sign in {" "} @@ -58,4 +59,4 @@ export const NotAuthorizedView: React.FC = ({ actionButton, type }) => {

); -}; +}); diff --git a/web/components/auth-screens/project/join-project.tsx b/web/components/auth-screens/project/join-project.tsx index 7ee4feacd9d..35b0b9b498b 100644 --- a/web/components/auth-screens/project/join-project.tsx +++ b/web/components/auth-screens/project/join-project.tsx @@ -1,9 +1,8 @@ import { useState } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; +// hooks +import { useProject, useUser } from "hooks/store"; // ui import { Button } from "@plane/ui"; // icons @@ -12,12 +11,13 @@ import { ClipboardList } from "lucide-react"; import JoinProjectImg from "public/auth/project-not-authorized.svg"; export const JoinProject: React.FC = () => { + // states const [isJoiningProject, setIsJoiningProject] = useState(false); - + // store hooks const { - project: projectStore, - user: { joinProject }, - }: RootStore = useMobxStore(); + membership: { joinProject }, + } = useUser(); + const { fetchProjects } = useProject(); const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -28,12 +28,8 @@ export const JoinProject: React.FC = () => { setIsJoiningProject(true); joinProject(workspaceSlug.toString(), [projectId.toString()]) - .then(() => { - projectStore.fetchProjects(workspaceSlug.toString()); - }) - .finally(() => { - setIsJoiningProject(false); - }); + .then(() => fetchProjects(workspaceSlug.toString())) + .finally(() => setIsJoiningProject(false)); }; return ( diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 6471bc9cfab..974efff3a1a 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -1,17 +1,16 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useProject, useUser } from "hooks/store"; // component import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui"; import { SelectMonthModal } from "components/automation"; // icon import { ArchiveRestore } from "lucide-react"; // constants -import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; +import { EUserProjectRoles, PROJECT_AUTOMATION_MONTHS } from "constants/project"; // types -import { IProject } from "types"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { IProject } from "@plane/types"; type Props = { handleChange: (formData: Partial) => Promise; @@ -23,13 +22,13 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { const { handleChange } = props; // states const [monthModal, setmonthModal] = useState(false); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); - const { user: userStore, project: projectStore } = useMobxStore(); - - const projectDetails = projectStore.currentProjectDetails; - const userRole = userStore.currentProjectRole; - - const isAdmin = userRole === EUserWorkspaceRoles.ADMIN; + const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; return ( <> @@ -54,29 +53,32 @@ export const AutoArchiveAutomation: React.FC = observer((props) => {
- projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 }) + currentProjectDetails?.archive_in === 0 + ? handleChange({ archive_in: 1 }) + : handleChange({ archive_in: 0 }) } size="sm" disabled={!isAdmin} />
- {projectDetails ? ( - projectDetails.archive_in !== 0 && ( + {currentProjectDetails ? ( + currentProjectDetails.archive_in !== 0 && (
Auto-archive issues that are closed for
{ handleChange({ archive_in: val }); }} input - width="w-full" disabled={!isAdmin} > <> diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index d21eb8b8097..8d6662c112d 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -1,17 +1,16 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useProject, useProjectState, useUser } from "hooks/store"; // component import { SelectMonthModal } from "components/automation"; import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui"; // icons import { ArchiveX } from "lucide-react"; // types -import { IProject } from "types"; -// fetch keys -import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { IProject } from "@plane/types"; +// constants +import { EUserProjectRoles, PROJECT_AUTOMATION_MONTHS } from "constants/project"; type Props = { handleChange: (formData: Partial) => Promise; @@ -21,15 +20,16 @@ export const AutoCloseAutomation: React.FC = observer((props) => { const { handleChange } = props; // states const [monthModal, setmonthModal] = useState(false); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); - const { user: userStore, project: projectStore, projectState: projectStateStore } = useMobxStore(); - - const userRole = userStore.currentProjectRole; - const projectDetails = projectStore.currentProjectDetails; // const stateGroups = projectStateStore.groupedProjectStates ?? undefined; - const states = projectStateStore.projectStates; - const options = states + const options = projectStates ?.filter((state) => state.group === "cancelled") .map((state) => ({ value: state.id, @@ -44,17 +44,17 @@ export const AutoCloseAutomation: React.FC = observer((props) => { const multipleOptions = (options ?? []).length > 1; - const defaultState = states?.find((s) => s.group === "cancelled")?.id || null; + const defaultState = projectStates?.find((s) => s.group === "cancelled")?.id || null; - const selectedOption = states?.find((s) => s.id === projectDetails?.default_state ?? defaultState); - const currentDefaultState = states?.find((s) => s.id === defaultState); + const selectedOption = projectStates?.find((s) => s.id === currentProjectDetails?.default_state ?? defaultState); + const currentDefaultState = projectStates?.find((s) => s.id === defaultState); const initialValues: Partial = { close_in: 1, default_state: defaultState, }; - const isAdmin = userRole === EUserWorkspaceRoles.ADMIN; + const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; return ( <> @@ -79,9 +79,9 @@ export const AutoCloseAutomation: React.FC = observer((props) => {
- projectDetails?.close_in === 0 + currentProjectDetails?.close_in === 0 ? handleChange({ close_in: 1, default_state: defaultState }) : handleChange({ close_in: 0, default_state: null }) } @@ -90,21 +90,22 @@ export const AutoCloseAutomation: React.FC = observer((props) => { />
- {projectDetails ? ( - projectDetails.close_in !== 0 && ( + {currentProjectDetails ? ( + currentProjectDetails.close_in !== 0 && (
Auto-close issues that are inactive for
{ handleChange({ close_in: val }); }} input - width="w-full" disabled={!isAdmin} > <> @@ -118,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)} > - Customise Time Range + Customize Time Range @@ -129,7 +130,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => {
Auto-close Status
{selectedOption ? ( @@ -159,7 +160,6 @@ export const AutoCloseAutomation: React.FC = observer((props) => { }} options={options} disabled={!multipleOptions} - width="w-full" input />
diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index eff42bb2d78..1d306bb0401 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -7,7 +7,7 @@ import { Dialog, Transition } from "@headlessui/react"; // ui import { Button, Input } from "@plane/ui"; // types -import type { IProject } from "types"; +import type { IProject } from "@plane/types"; // types type Props = { diff --git a/web/components/command-palette/actions/help-actions.tsx b/web/components/command-palette/actions/help-actions.tsx index 859a6d23a19..4aaaab33a95 100644 --- a/web/components/command-palette/actions/help-actions.tsx +++ b/web/components/command-palette/actions/help-actions.tsx @@ -1,7 +1,7 @@ import { Command } from "cmdk"; import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useApplication } from "hooks/store"; // ui import { DiscordIcon } from "@plane/ui"; @@ -14,7 +14,7 @@ export const CommandPaletteHelpActions: React.FC = (props) => { const { commandPalette: { toggleShortcutModal }, - } = useMobxStore(); + } = useApplication(); return ( diff --git a/web/components/command-palette/actions/issue-actions/actions-list.tsx b/web/components/command-palette/actions/issue-actions/actions-list.tsx index 8e188df7b3c..55f72c85d11 100644 --- a/web/components/command-palette/actions/issue-actions/actions-list.tsx +++ b/web/components/command-palette/actions/issue-actions/actions-list.tsx @@ -2,8 +2,8 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2 } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useApplication, useUser, useIssues } from "hooks/store"; // hooks import useToast from "hooks/use-toast"; // ui @@ -11,11 +11,12 @@ import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; +import { EIssuesStoreType } from "constants/issue"; type Props = { closePalette: () => void; - issueDetails: IIssue | undefined; + issueDetails: TIssue | undefined; pages: string[]; setPages: (pages: string[]) => void; setPlaceholder: (placeholder: string) => void; @@ -28,15 +29,17 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; + const { + issues: { updateIssue }, + } = useIssues(EIssuesStoreType.PROJECT); const { commandPalette: { toggleCommandPaletteModal, toggleDeleteIssueModal }, - projectIssues: { updateIssue }, - user: { currentUser }, - } = useMobxStore(); + } = useApplication(); + const { currentUser } = useUser(); const { setToastAlert } = useToast(); - const handleUpdateIssue = async (formData: Partial) => { + const handleUpdateIssue = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issueDetails) return; const payload = { ...formData }; @@ -49,12 +52,12 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { if (!issueDetails || !assignee) return; closePalette(); - const updatedAssignees = issueDetails.assignees ?? []; + const updatedAssignees = issueDetails.assignee_ids ?? []; if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); else updatedAssignees.push(assignee); - handleUpdateIssue({ assignees: updatedAssignees }); + handleUpdateIssue({ assignee_ids: updatedAssignees }); }; const deleteIssue = () => { @@ -130,7 +133,7 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { className="focus:outline-none" >
- {issueDetails?.assignees.includes(currentUser?.id ?? "") ? ( + {issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? ( <> Un-assign from me diff --git a/web/components/command-palette/actions/issue-actions/change-assignee.tsx b/web/components/command-palette/actions/issue-actions/change-assignee.tsx index 57af2b62a73..96fba41f6e2 100644 --- a/web/components/command-palette/actions/issue-actions/change-assignee.tsx +++ b/web/components/command-palette/actions/issue-actions/change-assignee.tsx @@ -3,15 +3,16 @@ import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; import { Check } from "lucide-react"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues, useMember } from "hooks/store"; // ui import { Avatar } from "@plane/ui"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; +import { EIssuesStoreType } from "constants/issue"; type Props = { closePalette: () => void; - issue: IIssue; + issue: TIssue; }; export const ChangeIssueAssignee: React.FC = observer((props) => { @@ -21,30 +22,40 @@ export const ChangeIssueAssignee: React.FC = observer((props) => { const { workspaceSlug, projectId } = router.query; // store const { - projectIssues: { updateIssue }, - projectMember: { projectMembers }, - } = useMobxStore(); + issues: { updateIssue }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + project: { projectMemberIds, getProjectMemberDetails }, + } = useMember(); const options = - projectMembers?.map(({ member }) => ({ - value: member.id, - query: member.display_name, - content: ( - <> -
- - {member.display_name} -
- {issue.assignees.includes(member.id) && ( -
- + projectMemberIds?.map((userId) => { + const memberDetails = getProjectMemberDetails(userId); + + return { + value: `${memberDetails?.member?.id}`, + query: `${memberDetails?.member?.display_name}`, + content: ( + <> +
+ + {memberDetails?.member?.display_name}
- )} - - ), - })) ?? []; + {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && ( +
+ +
+ )} + + ), + }; + }) ?? []; - const handleUpdateIssue = async (formData: Partial) => { + const handleUpdateIssue = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; const payload = { ...formData }; @@ -54,18 +65,18 @@ export const ChangeIssueAssignee: React.FC = observer((props) => { }; const handleIssueAssignees = (assignee: string) => { - const updatedAssignees = issue.assignees ?? []; + const updatedAssignees = issue.assignee_ids ?? []; if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); else updatedAssignees.push(assignee); - handleUpdateIssue({ assignees: updatedAssignees }); + handleUpdateIssue({ assignee_ids: updatedAssignees }); closePalette(); }; return ( <> - {options.map((option: any) => ( + {options.map((option) => ( handleIssueAssignees(option.value)} diff --git a/web/components/command-palette/actions/issue-actions/change-priority.tsx b/web/components/command-palette/actions/issue-actions/change-priority.tsx index 81b9f7ae90c..8d1c482610a 100644 --- a/web/components/command-palette/actions/issue-actions/change-priority.tsx +++ b/web/components/command-palette/actions/issue-actions/change-priority.tsx @@ -3,17 +3,17 @@ import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; import { Check } from "lucide-react"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // ui import { PriorityIcon } from "@plane/ui"; // types -import { IIssue, TIssuePriorities } from "types"; +import { TIssue, TIssuePriorities } from "@plane/types"; // constants -import { PRIORITIES } from "constants/project"; +import { EIssuesStoreType, ISSUE_PRIORITIES } from "constants/issue"; type Props = { closePalette: () => void; - issue: IIssue; + issue: TIssue; }; export const ChangeIssuePriority: React.FC = observer((props) => { @@ -23,10 +23,10 @@ export const ChangeIssuePriority: React.FC = observer((props) => { const { workspaceSlug, projectId } = router.query; const { - projectIssues: { updateIssue }, - } = useMobxStore(); + issues: { updateIssue }, + } = useIssues(EIssuesStoreType.PROJECT); - const submitChanges = async (formData: Partial) => { + const submitChanges = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; const payload = { ...formData }; @@ -42,13 +42,13 @@ export const ChangeIssuePriority: React.FC = observer((props) => { return ( <> - {PRIORITIES.map((priority) => ( - handleIssueState(priority)} className="focus:outline-none"> + {ISSUE_PRIORITIES.map((priority) => ( + handleIssueState(priority.key)} className="focus:outline-none">
- - {priority ?? "None"} + + {priority.title ?? "None"}
-
{priority === issue.priority && }
+
{priority.key === issue.priority && }
))} diff --git a/web/components/command-palette/actions/issue-actions/change-state.tsx b/web/components/command-palette/actions/issue-actions/change-state.tsx index 0ce05bd7b0f..7841a4a1e65 100644 --- a/web/components/command-palette/actions/issue-actions/change-state.tsx +++ b/web/components/command-palette/actions/issue-actions/change-state.tsx @@ -1,33 +1,33 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// cmdk import { Command } from "cmdk"; +// hooks +import { useProjectState, useIssues } from "hooks/store"; // ui import { Spinner, StateGroupIcon } from "@plane/ui"; // icons import { Check } from "lucide-react"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; +import { EIssuesStoreType } from "constants/issue"; type Props = { closePalette: () => void; - issue: IIssue; + issue: TIssue; }; export const ChangeIssueState: React.FC = observer((props) => { const { closePalette, issue } = props; - + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; - + // store hooks const { - projectState: { projectStates }, - projectIssues: { updateIssue }, - } = useMobxStore(); + issues: { updateIssue }, + } = useIssues(EIssuesStoreType.PROJECT); + const { projectStates } = useProjectState(); - const submitChanges = async (formData: Partial) => { + const submitChanges = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issue) return; const payload = { ...formData }; @@ -37,7 +37,7 @@ export const ChangeIssueState: React.FC = observer((props) => { }; const handleIssueState = (stateId: string) => { - submitChanges({ state: stateId }); + submitChanges({ state_id: stateId }); closePalette(); }; @@ -51,7 +51,7 @@ export const ChangeIssueState: React.FC = observer((props) => {

{state.name}

-
{state.id === issue.state && }
+
{state.id === issue.state_id && }
)) ) : ( diff --git a/web/components/command-palette/actions/project-actions.tsx b/web/components/command-palette/actions/project-actions.tsx index 1e10b3a4645..44b5e6111fd 100644 --- a/web/components/command-palette/actions/project-actions.tsx +++ b/web/components/command-palette/actions/project-actions.tsx @@ -1,7 +1,7 @@ import { Command } from "cmdk"; import { ContrastIcon, FileText } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useApplication } from "hooks/store"; // ui import { DiceIcon, PhotoFilterIcon } from "@plane/ui"; @@ -14,8 +14,8 @@ export const CommandPaletteProjectActions: React.FC = (props) => { const { commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal }, - trackEvent: { setTrackElement }, - } = useMobxStore(); + eventTracker: { setTrackElement }, + } = useApplication(); return ( <> diff --git a/web/components/command-palette/actions/search-results.tsx b/web/components/command-palette/actions/search-results.tsx index 791c6265610..769a26be7f4 100644 --- a/web/components/command-palette/actions/search-results.tsx +++ b/web/components/command-palette/actions/search-results.tsx @@ -3,7 +3,7 @@ import { Command } from "cmdk"; // helpers import { commandGroups } from "components/command-palette"; // types -import { IWorkspaceSearchResults } from "types"; +import { IWorkspaceSearchResults } from "@plane/types"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/actions/theme-actions.tsx b/web/components/command-palette/actions/theme-actions.tsx index f7266a48a69..976a63c871d 100644 --- a/web/components/command-palette/actions/theme-actions.tsx +++ b/web/components/command-palette/actions/theme-actions.tsx @@ -4,8 +4,8 @@ import { useTheme } from "next-themes"; import { Settings } from "lucide-react"; import { observer } from "mobx-react-lite"; // hooks +import { useUser } from "hooks/store"; import useToast from "hooks/use-toast"; -import { useMobxStore } from "lib/mobx/store-provider"; // constants import { THEME_OPTIONS } from "constants/themes"; @@ -18,9 +18,7 @@ export const CommandPaletteThemeActions: FC = observer((props) => { // states const [mounted, setMounted] = useState(false); // store - const { - user: { updateCurrentUserTheme }, - } = useMobxStore(); + const { updateCurrentUserTheme } = useUser(); // hooks const { setTheme } = useTheme(); const { setToastAlert } = useToast(); diff --git a/web/components/command-palette/actions/workspace-settings-actions.tsx b/web/components/command-palette/actions/workspace-settings-actions.tsx index 84e62593a4f..1f05234f41d 100644 --- a/web/components/command-palette/actions/workspace-settings-actions.tsx +++ b/web/components/command-palette/actions/workspace-settings-actions.tsx @@ -1,7 +1,10 @@ import { useRouter } from "next/router"; import { Command } from "cmdk"; -// icons -import { SettingIcon } from "components/icons"; +// hooks +import { useUser } from "hooks/store"; +import Link from "next/link"; +// constants +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace"; type Props = { closePalette: () => void; @@ -9,9 +12,15 @@ type Props = { export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) => { const { closePalette } = props; - + // router const router = useRouter(); const { workspaceSlug } = router.query; + // mobx store + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // derived values + const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; const redirect = (path: string) => { closePalette(); @@ -20,42 +29,23 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC = (props) = return ( <> - redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none"> -
- - General -
-
- redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none"> -
- - Members -
-
- redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none"> -
- - Billing and Plans -
-
- redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none"> -
- - Integrations -
-
- redirect(`/${workspaceSlug}/settings/imports`)} className="focus:outline-none"> -
- - Import -
-
- redirect(`/${workspaceSlug}/settings/exports`)} className="focus:outline-none"> -
- - Export -
-
+ {WORKSPACE_SETTINGS_LINKS.map( + (setting) => + workspaceMemberInfo >= setting.access && ( + redirect(`/${workspaceSlug}${setting.href}`)} + className="focus:outline-none" + > + +
+ + {setting.label} +
+ +
+ ) + )} ); }; diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index 005e570e70e..34282782536 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -5,8 +5,8 @@ import { Command } from "cmdk"; import { Dialog, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; import { FolderPlus, Search, Settings } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useApplication, useProject } from "hooks/store"; // services import { WorkspaceService } from "services/workspace.service"; import { IssueService } from "services/issue"; @@ -26,7 +26,7 @@ import { } from "components/command-palette"; import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; // types -import { IWorkspaceSearchResults } from "types"; +import { IWorkspaceSearchResults } from "@plane/types"; // fetch-keys import { ISSUE_DETAILS } from "constants/fetch-keys"; @@ -35,6 +35,8 @@ const workspaceService = new WorkspaceService(); const issueService = new IssueService(); export const CommandModal: React.FC = observer(() => { + // hooks + const { getProjectById } = useProject(); // states const [placeholder, setPlaceholder] = useState("Type a command or search..."); const [resultsCount, setResultsCount] = useState(0); @@ -62,8 +64,8 @@ export const CommandModal: React.FC = observer(() => { toggleCreateIssueModal, toggleCreateProjectModal, }, - trackEvent: { setTrackElement }, - } = useMobxStore(); + eventTracker: { setTrackElement }, + } = useApplication(); // router const router = useRouter(); @@ -135,6 +137,8 @@ export const CommandModal: React.FC = observer(() => { [debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes ); + const projectDetails = getProjectById(issueDetails?.project_id ?? ""); + return ( setSearchTerm("")} as={React.Fragment}> closePalette()}> @@ -188,7 +192,7 @@ export const CommandModal: React.FC = observer(() => { > {issueDetails && (
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} {issueDetails.name} + {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
)} {projectId && ( diff --git a/web/components/command-palette/command-pallette.tsx b/web/components/command-palette/command-palette.tsx similarity index 93% rename from web/components/command-palette/command-pallette.tsx rename to web/components/command-palette/command-palette.tsx index 0488455fb9d..213c35f8ee9 100644 --- a/web/components/command-palette/command-pallette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import useSWR from "swr"; import { observer } from "mobx-react-lite"; // hooks +import { useApplication, useIssues, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CommandModal, ShortcutsModal } from "components/command-palette"; @@ -19,8 +20,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; import { IssueService } from "services/issue"; // fetch keys import { ISSUE_DETAILS } from "constants/fetch-keys"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { EIssuesStoreType } from "constants/issue"; // services const issueService = new IssueService(); @@ -28,14 +28,17 @@ const issueService = new IssueService(); export const CommandPalette: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId, issueId, cycleId, moduleId } = router.query; - // store + const { commandPalette, theme: { toggleSidebar }, - user: { currentUser }, - trackEvent: { setTrackElement }, - projectIssues: { removeIssue }, - } = useMobxStore(); + eventTracker: { setTrackElement }, + } = useApplication(); + const { currentUser } = useUser(); + const { + issues: { removeIssue }, + } = useIssues(EIssuesStoreType.PROJECT); + const { toggleCommandPaletteModal, isCreateIssueModalOpen, @@ -212,11 +215,9 @@ export const CommandPalette: FC = observer(() => { toggleCreateIssueModal(false)} - prePopulateData={ - cycleId ? { cycle: cycleId.toString() } : moduleId ? { module: moduleId.toString() } : undefined - } - currentStore={createIssueStoreType} + onClose={() => toggleCreateIssueModal(false)} + data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined} + storeType={createIssueStoreType} /> {workspaceSlug && projectId && issueId && issueDetails && ( diff --git a/web/components/command-palette/helpers.tsx b/web/components/command-palette/helpers.tsx index 8bf0c9938f7..44fc55bbeb5 100644 --- a/web/components/command-palette/helpers.tsx +++ b/web/components/command-palette/helpers.tsx @@ -6,7 +6,7 @@ import { IWorkspaceIssueSearchResult, IWorkspaceProjectSearchResult, IWorkspaceSearchResult, -} from "types"; +} from "@plane/types"; export const commandGroups: { [key: string]: { diff --git a/web/components/command-palette/index.ts b/web/components/command-palette/index.ts index 192ef8ef9fc..0d2e042a7fe 100644 --- a/web/components/command-palette/index.ts +++ b/web/components/command-palette/index.ts @@ -1,5 +1,5 @@ export * from "./actions"; export * from "./shortcuts-modal"; export * from "./command-modal"; -export * from "./command-pallette"; +export * from "./command-palette"; export * from "./helpers"; diff --git a/web/components/common/new-empty-state.tsx b/web/components/common/new-empty-state.tsx index 7bad18734ec..efbab82495f 100644 --- a/web/components/common/new-empty-state.tsx +++ b/web/components/common/new-empty-state.tsx @@ -19,7 +19,7 @@ type Props = { icon?: any; text: string; onClick: () => void; - } | null; + }; disabled?: boolean; }; @@ -43,7 +43,7 @@ export const NewEmptyState: React.FC = ({ return (
-
+

{title}

{description &&

{description}

}
diff --git a/web/components/common/product-updates-modal.tsx b/web/components/common/product-updates-modal.tsx index 46be10298de..cd0a5b9fffa 100644 --- a/web/components/common/product-updates-modal.tsx +++ b/web/components/common/product-updates-modal.tsx @@ -1,7 +1,5 @@ import React from "react"; - import useSWR from "swr"; - // headless ui import { Dialog, Transition } from "@headlessui/react"; // services @@ -12,7 +10,7 @@ import { Loader } from "@plane/ui"; // icons import { X } from "lucide-react"; // helpers -import { renderLongDateFormat } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; type Props = { isOpen: boolean; @@ -69,7 +67,7 @@ export const ProductUpdatesModal: React.FC = ({ isOpen, setIsOpen }) => { {item.tag_name} - {renderLongDateFormat(item.published_at)} + {renderFormattedDate(item.published_at)} {index === 0 && ( New diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 1ac34cf738c..72a67883ef1 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -1,9 +1,8 @@ import { useRouter } from "next/router"; +import { useEffect } from "react"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// hook -import useEstimateOption from "hooks/use-estimate-option"; +// store hooks +import { useEstimate, useLabel } from "hooks/store"; // icons import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui"; import { @@ -22,34 +21,35 @@ import { UsersIcon, } from "lucide-react"; // helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // types -import { IIssueActivity } from "types"; -import { useEffect } from "react"; +import { IIssueActivity } from "@plane/types"; -const IssueLink = ({ activity }: { activity: IIssueActivity }) => { +export const IssueLink = ({ activity }: { activity: IIssueActivity }) => { const router = useRouter(); const { workspaceSlug } = router.query; return ( - - - {activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}{" "} - {activity.issue_detail?.name} - + + {activity?.issue_detail ? ( + + {`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}{" "} + {activity.issue_detail?.name} + + ) : ( + + {" an Issue"}{" "} + + )} ); }; @@ -73,11 +73,8 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => { }; const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; workspaceSlug: string }) => { - const { - workspace: { labels, fetchWorkspaceLabels }, - } = useMobxStore(); - - const workspaceLabels = labels[workspaceSlug]; + // store hooks + const { workspaceLabels, fetchWorkspaceLabels } = useLabel(); useEffect(() => { if (!workspaceLabels) fetchWorkspaceLabels(workspaceSlug); @@ -94,16 +91,21 @@ const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; works ); }); -const EstimatePoint = ({ point }: { point: string }) => { - const { estimateValue, isEstimateActive } = useEstimateOption(Number(point)); +const EstimatePoint = observer((props: { point: string }) => { + const { point } = props; + const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate(); const currentPoint = Number(point) + 1; + const estimateValue = getEstimatePointValue(Number(point), null); + return ( - {isEstimateActive ? estimateValue : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`} + {areEstimatesEnabledForCurrentProject + ? estimateValue + : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`} ); -}; +}); const activityDetails: { [key: string]: { @@ -123,7 +125,6 @@ const activityDetails: { to )} - . ); else @@ -136,7 +137,6 @@ const activityDetails: { from )} - . ); }, @@ -144,8 +144,18 @@ const activityDetails: { }, archived_at: { message: (activity) => { - if (activity.new_value === "restore") return "restored the issue."; - else return "archived the issue."; + if (activity.new_value === "restore") + return ( + <> + restored + + ); + else + return ( + <> + archived + + ); }, icon: {showIssue && ( - + {" "} to @@ -400,7 +313,6 @@ const activityDetails: { to )} - . ); else if (activity.verb === "updated") @@ -421,7 +333,6 @@ const activityDetails: { from )} - . ); else @@ -442,18 +353,66 @@ const activityDetails: { from )} - . ); }, icon:
-
- {!isNotAllowed && ( + {!isNotAllowed && ( +
- )} - - - - {!isNotAllowed && ( + + + - )} -
+
+ )}

- Added {timeAgo(link.created_at)} + Added {calculateTimeAgo(link.created_at)}
by{" "} {link.created_by_detail.is_bot diff --git a/web/components/core/sidebar/progress-chart.tsx b/web/components/core/sidebar/progress-chart.tsx index 4433e4c098a..3d47d8eca25 100644 --- a/web/components/core/sidebar/progress-chart.tsx +++ b/web/components/core/sidebar/progress-chart.tsx @@ -1,11 +1,11 @@ import React from "react"; - +import { eachDayOfInterval, isValid } from "date-fns"; // ui import { LineGraph } from "components/ui"; // helpers -import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { renderFormattedDateWithoutYear } from "helpers/date-time.helper"; //types -import { TCompletionChartDistribution } from "types"; +import { TCompletionChartDistribution } from "@plane/types"; type Props = { distribution: TCompletionChartDistribution; @@ -41,26 +41,32 @@ const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) => )); const ProgressChart: React.FC = ({ distribution, startDate, endDate, totalIssues }) => { - const chartData = Object.keys(distribution).map((key) => ({ - currentDate: renderShortNumericDateFormat(key), + const chartData = Object.keys(distribution ?? []).map((key) => ({ + currentDate: renderFormattedDateWithoutYear(key), pending: distribution[key], })); const generateXAxisTickValues = () => { - const dates = getDatesInRange(startDate, endDate); + const start = new Date(startDate); + const end = new Date(endDate); + + let dates: Date[] = []; + if (isValid(start) && isValid(end)) { + dates = eachDayOfInterval({ start, end }); + } const maxDates = 4; const totalDates = dates.length; - if (totalDates <= maxDates) return dates.map((d) => renderShortNumericDateFormat(d)); + if (totalDates <= maxDates) return dates.map((d) => renderFormattedDateWithoutYear(d)); else { const interval = Math.ceil(totalDates / maxDates); const limitedDates = []; - for (let i = 0; i < totalDates; i += interval) limitedDates.push(renderShortNumericDateFormat(dates[i])); + for (let i = 0; i < totalDates; i += interval) limitedDates.push(renderFormattedDateWithoutYear(dates[i])); - if (!limitedDates.includes(renderShortNumericDateFormat(dates[totalDates - 1]))) - limitedDates.push(renderShortNumericDateFormat(dates[totalDates - 1])); + if (!limitedDates.includes(renderFormattedDateWithoutYear(dates[totalDates - 1]))) + limitedDates.push(renderFormattedDateWithoutYear(dates[totalDates - 1])); return limitedDates; } diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx new file mode 100644 index 00000000000..0e34eac2c41 --- /dev/null +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -0,0 +1,16 @@ +import { FC } from "react"; +import { Menu } from "lucide-react"; +import { useApplication } from "hooks/store"; +import { observer } from "mobx-react"; + +export const SidebarHamburgerToggle: FC = observer (() => { + const { theme: themStore } = useApplication(); + return ( +

themStore.toggleSidebar()} + > + +
+ ); +}); diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index 8cea3784fd3..c37cdf4b927 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -14,13 +14,12 @@ import { SingleProgressStats } from "components/core"; import { Avatar, StateGroupIcon } from "@plane/ui"; // types import { - IIssueFilterOptions, IModule, TAssigneesDistribution, TCompletionChartDistribution, TLabelsDistribution, TStateGroups, -} from "types"; +} from "@plane/types"; type Props = { distribution: { @@ -36,9 +35,6 @@ type Props = { roundedTab?: boolean; noBackground?: boolean; isPeekView?: boolean; - isCompleted?: boolean; - filters?: IIssueFilterOptions; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; }; export const SidebarProgressStats: React.FC = ({ @@ -48,10 +44,7 @@ export const SidebarProgressStats: React.FC = ({ module, roundedTab, noBackground, - isCompleted = false, isPeekView = false, - filters, - handleFiltersUpdate, }) => { const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); @@ -133,7 +126,7 @@ export const SidebarProgressStats: React.FC = ({ - {distribution.assignees.length > 0 ? ( + {distribution?.assignees.length > 0 ? ( distribution.assignees.map((assignee, index) => { if (assignee.assignee_id) return ( @@ -147,11 +140,20 @@ export const SidebarProgressStats: React.FC = ({ } completed={assignee.completed_issues} total={assignee.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""), - selected: filters?.assignees?.includes(assignee.assignee_id ?? ""), - })} + {...(!isPeekView && { + onClick: () => { + // TODO: set filters here + // if (filters?.assignees?.includes(assignee.assignee_id ?? "")) + // setFilters({ + // assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), + // }); + // else + // setFilters({ + // assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], + // }); + }, + // selected: filters?.assignees?.includes(assignee.assignee_id ?? ""), + })} /> ); else @@ -181,7 +183,7 @@ export const SidebarProgressStats: React.FC = ({ )} - {distribution.labels.length > 0 ? ( + {distribution?.labels.length > 0 ? ( distribution.labels.map((label, index) => ( = ({ } completed={label.completed_issues} total={label.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""), - selected: filters?.labels?.includes(label.label_id ?? `no-label-${index}`), - })} + {...(!isPeekView && { + // TODO: set filters here + onClick: () => { + // if (filters.labels?.includes(label.label_id ?? "")) + // setFilters({ + // labels: filters?.labels?.filter((l) => l !== label.label_id), + // }); + // else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); + }, + // selected: filters?.labels?.includes(label.label_id ?? ""), + })} /> )) ) : ( diff --git a/web/components/core/sidebar/single-progress-stats.tsx b/web/components/core/sidebar/single-progress-stats.tsx index f58bbc2c3dd..4d926285b65 100644 --- a/web/components/core/sidebar/single-progress-stats.tsx +++ b/web/components/core/sidebar/single-progress-stats.tsx @@ -30,7 +30,7 @@ export const SingleProgressStats: React.FC = ({ - {isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}% + {isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}%
of {total} diff --git a/web/components/core/theme/color-picker-input.tsx b/web/components/core/theme/color-picker-input.tsx index f47c1349f24..19cd519cbff 100644 --- a/web/components/core/theme/color-picker-input.tsx +++ b/web/components/core/theme/color-picker-input.tsx @@ -18,7 +18,7 @@ import { Input } from "@plane/ui"; // icons import { Palette } from "lucide-react"; // types -import { IUserTheme } from "types"; +import { IUserTheme } from "@plane/types"; type Props = { name: keyof IUserTheme; diff --git a/web/components/core/theme/custom-theme-selector.tsx b/web/components/core/theme/custom-theme-selector.tsx index c5517070284..bd6f4356921 100644 --- a/web/components/core/theme/custom-theme-selector.tsx +++ b/web/components/core/theme/custom-theme-selector.tsx @@ -1,12 +1,12 @@ import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { useTheme } from "next-themes"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useUser } from "hooks/store"; // ui import { Button, InputColorPicker } from "@plane/ui"; // types -import { IUserTheme } from "types"; +import { IUserTheme } from "@plane/types"; const inputRules = { required: "Background color is required", @@ -25,8 +25,8 @@ const inputRules = { }; export const CustomThemeSelector: React.FC = observer(() => { - const { user: userStore } = useMobxStore(); - const userTheme = userStore?.currentUser?.theme; + const { currentUser, updateCurrentUser } = useUser(); + const userTheme = currentUser?.theme; // hooks const { setTheme } = useTheme(); @@ -61,7 +61,7 @@ export const CustomThemeSelector: React.FC = observer(() => { setTheme("custom"); - return userStore.updateCurrentUser({ theme: payload }); + return updateCurrentUser({ theme: payload }); }; const handleValueChange = (val: string | undefined, onChange: any) => { diff --git a/web/components/core/theme/theme-switch.tsx b/web/components/core/theme/theme-switch.tsx index 78364562fcd..bcd847a280a 100644 --- a/web/components/core/theme/theme-switch.tsx +++ b/web/components/core/theme/theme-switch.tsx @@ -46,7 +46,6 @@ export const ThemeSwitch: FC = (props) => { } onChange={onChange} input - width="w-full" > {THEME_OPTIONS.map((themeOption) => ( diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 47beaa26252..a0101b1c1b3 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -1,11 +1,10 @@ import { MouseEvent } from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useTheme } from "next-themes"; // hooks +import { useCycle, useIssues, useMember, useProject, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { SingleProgressStats } from "components/core"; @@ -14,52 +13,28 @@ import { Loader, Tooltip, LinearProgressIndicator, - ContrastIcon, - RunningIcon, LayersIcon, StateGroupIcon, PriorityIcon, Avatar, + CycleGroupIcon, } from "@plane/ui"; // components import ProgressChart from "components/core/sidebar/progress-chart"; import { ActiveCycleProgressStats } from "components/cycles"; -import { ViewIssueLabel } from "components/issues"; +import { StateDropdown } from "components/dropdowns"; +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; // icons -import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } from "lucide-react"; +import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-react"; // helpers -import { renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; +import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper"; import { truncateText } from "helpers/string.helper"; // types -import { ICycle } from "types"; - -const stateGroups = [ - { - key: "backlog_issues", - title: "Backlog", - color: "#dee2e6", - }, - { - key: "unstarted_issues", - title: "Unstarted", - color: "#26b5ce", - }, - { - key: "started_issues", - title: "Started", - color: "#f7ae59", - }, - { - key: "cancelled_issues", - title: "Cancelled", - color: "#d687ff", - }, - { - key: "completed_issues", - title: "Completed", - color: "#09a953", - }, -]; +import { ICycle, TCycleGroups } from "@plane/types"; +// constants +import { EIssuesStoreType } from "constants/issue"; +import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; +import { CYCLE_EMPTY_STATE_DETAILS, CYCLE_STATE_GROUPS_DETAILS } from "constants/cycle"; interface IActiveCycleDetails { workspaceSlug: string; @@ -67,83 +42,83 @@ interface IActiveCycleDetails { } export const ActiveCycleDetails: React.FC = observer((props) => { - const router = useRouter(); - + // props const { workspaceSlug, projectId } = props; - - const { cycle: cycleStore, commandPalette: commandPaletteStore } = useMobxStore(); - + const { resolvedTheme } = useTheme(); + // store hooks + const { currentUser } = useUser(); + const { + issues: { fetchActiveCycleIssues }, + } = useIssues(EIssuesStoreType.CYCLE); + const { + fetchActiveCycle, + currentProjectActiveCycleId, + getActiveCycleById, + addCycleToFavorites, + removeCycleFromFavorites, + } = useCycle(); + const { currentProjectDetails } = useProject(); + const { getUserDetails } = useMember(); + // toast alert const { setToastAlert } = useToast(); const { isLoading } = useSWR( - workspaceSlug && projectId ? `ACTIVE_CYCLE_ISSUE_${projectId}_CURRENT` : null, - workspaceSlug && projectId ? () => cycleStore.fetchCycles(workspaceSlug, projectId, "current") : null + workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, + workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null ); - const activeCycle = cycleStore.cycles?.[projectId]?.current || null; - const cycle = activeCycle ? activeCycle[0] : null; - const issues = (cycleStore?.active_cycle_issues as any) || null; + const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; + const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined; - // const { data: issues } = useSWR( - // workspaceSlug && projectId && cycle?.id ? CYCLE_ISSUES_WITH_PARAMS(cycle?.id, { priority: "urgent,high" }) : null, - // workspaceSlug && projectId && cycle?.id - // ? () => - // cycleService.getCycleIssuesWithParams(workspaceSlug as string, projectId as string, cycle.id, { - // priority: "urgent,high", - // }) - // : null - // ) as { data: IIssue[] | undefined }; + const { data: activeCycleIssues } = useSWR( + workspaceSlug && projectId && currentProjectActiveCycleId + ? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" }) + : null, + workspaceSlug && projectId && currentProjectActiveCycleId + ? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId) + : null + ); + + const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["active"]; - if (!cycle && isLoading) + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("cycle", "active", isLightMode); + + if (!activeCycle && isLoading) return ( ); - if (!cycle) + if (!activeCycle) return ( -
-
-
- - - - -
-

No active cycle

- -
-
+ ); - const endDate = new Date(cycle.end_date ?? ""); - const startDate = new Date(cycle.start_date ?? ""); + const endDate = new Date(activeCycle.end_date ?? ""); + const startDate = new Date(activeCycle.start_date ?? ""); const groupedIssues: any = { - backlog: cycle.backlog_issues, - unstarted: cycle.unstarted_issues, - started: cycle.started_issues, - completed: cycle.completed_issues, - cancelled: cycle.cancelled_issues, + backlog: activeCycle.backlog_issues, + unstarted: activeCycle.unstarted_issues, + started: activeCycle.started_issues, + completed: activeCycle.completed_issues, + cancelled: activeCycle.cancelled_issues, }; - const cycleStatus = cycle.status.toLocaleLowerCase(); + const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -156,7 +131,7 @@ export const ActiveCycleDetails: React.FC = observer((props e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -165,13 +140,18 @@ export const ActiveCycleDetails: React.FC = observer((props }); }; - const progressIndicatorData = stateGroups.map((group, index) => ({ + const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, name: group.title, - value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0, + value: + activeCycle.total_issues > 0 + ? ((activeCycle[group.key as keyof ICycle] as number) / activeCycle.total_issues) * 100 + : 0, color: group.color, })); + const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date()); + return (
@@ -181,70 +161,17 @@ export const ActiveCycleDetails: React.FC = observer((props
- + - -

{truncateText(cycle.name, 70)}

+ +

{truncateText(activeCycle.name, 70)}

- - - {cycleStatus === "current" ? ( - - - {findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left - - ) : cycleStatus === "upcoming" ? ( - - - {findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left - - ) : cycleStatus === "completed" ? ( - - {cycle.total_issues - cycle.completed_issues > 0 && ( - - - - - - )}{" "} - Completed - - ) : ( - cycleStatus - )} + + + {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} - {cycle.is_favorite ? ( + {activeCycle.is_favorite ? (
- +
-
-
-
High Priority Issues
-
- {issues ? ( - issues.length > 0 ? ( - issues.map((issue: any) => ( -
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`)} - className="flex cursor-pointer flex-wrap items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-3 py-1.5" - > -
-
- - - {issue.project_detail?.identifier}-{issue.sequence_id} - - -
- - {truncateText(issue.name, 30)} +
+
High Priority Issues
+
+ {activeCycleIssues ? ( + activeCycleIssues.length > 0 ? ( + activeCycleIssues.map((issue: any) => ( + +
+ + + + + {currentProjectDetails?.identifier}-{issue.sequence_id} + + + + {truncateText(issue.name, 30)} + +
+
+ {}} + projectId={projectId?.toString() ?? ""} + disabled={true} + buttonVariant="background-with-text" + /> + {issue.target_date && ( + +
+ + {renderFormattedDateWithoutYear(issue.target_date)} +
-
-
-
- -
- -
- {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( -
- - {issue.assignee_details.map((assignee: any) => ( - - ))} - -
- ) : ( - "" - )} -
-
+ )}
- )) - ) : ( -
- No issues present in the cycle. -
- ) + + )) ) : ( - - - - - - )} -
+
+ There are no high priority issues present in this cycle. +
+ ) + ) : ( + + + + + + )}
- - {issues && issues.length > 0 && ( -
-
-
issue?.state_detail?.group === "completed")?.length / - issues.length) * - 100 ?? 0 - }%`, - }} - /> -
-
- {issues?.filter((issue: any) => issue?.state_detail?.group === "completed")?.length} of {issues?.length} -
-
- )}
-
+
@@ -466,15 +362,18 @@ export const ActiveCycleDetails: React.FC = observer((props - Pending Issues - {cycle.total_issues - (cycle.completed_issues + cycle.cancelled_issues)} + + Pending Issues -{" "} + {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)} +
-
+
diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle-stats.tsx index 2c933989215..1ffe19260da 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle-stats.tsx @@ -7,7 +7,7 @@ import { SingleProgressStats } from "components/core"; // ui import { Avatar } from "@plane/ui"; // types -import { ICycle } from "types"; +import { ICycle } from "@plane/types"; type Props = { cycle: ICycle; @@ -127,7 +127,7 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ) : (
- No issues present in the cycle. + There are no high priority issues present in this cycle.
)} diff --git a/web/components/cycles/cycle-peek-overview.tsx b/web/components/cycles/cycle-peek-overview.tsx index d6806eaf062..b7acff358ab 100644 --- a/web/components/cycles/cycle-peek-overview.tsx +++ b/web/components/cycles/cycle-peek-overview.tsx @@ -1,10 +1,8 @@ import React, { useEffect } from "react"; - import { useRouter } from "next/router"; - -// mobx import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useCycle } from "hooks/store"; // components import { CycleDetailsSidebar } from "./sidebar"; @@ -14,14 +12,13 @@ type Props = { }; export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug }) => { + // router const router = useRouter(); const { peekCycle } = router.query; - + // refs const ref = React.useRef(null); - - const { cycle: cycleStore } = useMobxStore(); - - const { fetchCycleWithId } = cycleStore; + // store hooks + const { fetchCycleDetails } = useCycle(); const handleClose = () => { delete router.query.peekCycle; @@ -33,8 +30,8 @@ export const CyclePeekOverview: React.FC = observer(({ projectId, workspa useEffect(() => { if (!peekCycle) return; - fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString()); - }, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]); + fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString()); + }, [fetchCycleDetails, peekCycle, projectId, workspaceSlug]); return ( <> diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index d43d5687287..d2279aeb4e3 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -2,6 +2,7 @@ import { FC, MouseEvent, useState } from "react"; import { useRouter } from "next/router"; import Link from "next/link"; // hooks +import { useApplication, useCycle, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; @@ -10,66 +11,67 @@ import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } // icons import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // helpers -import { findHowManyDaysLeft, renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; +import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; -// types -import { ICycle, TCycleGroups } from "types"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; // constants import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; +//.types +import { TCycleGroups } from "@plane/types"; export interface ICyclesBoardCard { workspaceSlug: string; projectId: string; - cycle: ICycle; + cycleId: string; } export const CyclesBoardCard: FC = (props) => { - const { cycle, workspaceSlug, projectId } = props; - // store - const { - cycle: cycleStore, - trackEvent: { setTrackElement }, - user: userStore, - } = useMobxStore(); - // toast - const { setToastAlert } = useToast(); + const { cycleId, workspaceSlug, projectId } = props; // states const [updateModal, setUpdateModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false); + // router + const router = useRouter(); + // store + const { + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); + // toast alert + const { setToastAlert } = useToast(); // computed - const cycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups; + const cycleDetails = getCycleById(cycleId); + + if (!cycleDetails) return null; + + const cycleStatus = cycleDetails.status.toLocaleLowerCase(); const isCompleted = cycleStatus === "completed"; - const endDate = new Date(cycle.end_date ?? ""); - const startDate = new Date(cycle.start_date ?? ""); - const isDateValid = cycle.start_date || cycle.end_date; + const endDate = new Date(cycleDetails.end_date ?? ""); + const startDate = new Date(cycleDetails.start_date ?? ""); + const isDateValid = cycleDetails.start_date || cycleDetails.end_date; - const { currentProjectRole } = userStore; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - const router = useRouter(); - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); - const cycleTotalIssues = - cycle.backlog_issues + - cycle.unstarted_issues + - cycle.started_issues + - cycle.completed_issues + - cycle.cancelled_issues; + cycleDetails.backlog_issues + + cycleDetails.unstarted_issues + + cycleDetails.started_issues + + cycleDetails.completed_issues + + cycleDetails.cancelled_issues; - const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100; + const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - const issueCount = cycle + const issueCount = cycleDetails ? cycleTotalIssues === 0 ? "0 Issue" - : cycleTotalIssues === cycle.completed_issues + : cycleTotalIssues === cycleDetails.completed_issues ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycle.completed_issues}/${cycleTotalIssues} Issues` + : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : "0 Issue"; const handleCopyText = (e: MouseEvent) => { @@ -77,7 +79,7 @@ export const CyclesBoardCard: FC = (props) => { e.stopPropagation(); const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { + copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { setToastAlert({ type: "success", title: "Link Copied!", @@ -90,7 +92,7 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -103,7 +105,7 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -132,14 +134,16 @@ export const CyclesBoardCard: FC = (props) => { router.push({ pathname: router.pathname, - query: { ...query, peekCycle: cycle.id }, + query: { ...query, peekCycle: cycleId }, }); }; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date()); + return (
setUpdateModal(false)} workspaceSlug={workspaceSlug} @@ -147,22 +151,22 @@ export const CyclesBoardCard: FC = (props) => { /> setDeleteModal(false)} workspaceSlug={workspaceSlug} projectId={projectId} /> - +
- + - - {cycle.name} + + {cycleDetails.name}
@@ -175,7 +179,7 @@ export const CyclesBoardCard: FC = (props) => { }} > {currentCycle.value === "current" - ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}` + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` : `${currentCycle.label}`} )} @@ -191,11 +195,11 @@ export const CyclesBoardCard: FC = (props) => { {issueCount}
- {cycle.assignees.length > 0 && ( - + {cycleDetails.assignees.length > 0 && ( +
- {cycle.assignees.map((assignee) => ( + {cycleDetails.assignees.map((assignee) => ( ))} @@ -228,15 +232,14 @@ export const CyclesBoardCard: FC = (props) => {
{isDateValid ? ( - {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} - {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} ) : ( No due date )}
{isEditingAllowed && - (cycle.is_favorite ? ( + (cycleDetails.is_favorite ? ( @@ -245,7 +248,7 @@ export const CyclesBoardCard: FC = (props) => { ))} - + {!isCompleted && isEditingAllowed && ( <> diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx index af234b9dc45..19e7f22252c 100644 --- a/web/components/cycles/cycles-board.tsx +++ b/web/components/cycles/cycles-board.tsx @@ -1,14 +1,16 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useTheme } from "next-themes"; +// hooks +import { useUser } from "hooks/store"; // components import { CyclePeekOverview, CyclesBoardCard } from "components/cycles"; -// types -import { ICycle } from "types"; +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; +// constants +import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle"; export interface ICyclesBoard { - cycles: ICycle[]; + cycleIds: string[]; filter: string; workspaceSlug: string; projectId: string; @@ -16,13 +18,20 @@ export interface ICyclesBoard { } export const CyclesBoard: FC = observer((props) => { - const { cycles, filter, workspaceSlug, projectId, peekCycle } = props; + const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props; + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { currentUser } = useUser(); - const { commandPalette: commandPaletteStore } = useMobxStore(); + const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS[filter as keyof typeof CYCLE_EMPTY_STATE_DETAILS]; + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("cycle", filter, isLightMode); return ( <> - {cycles.length > 0 ? ( + {cycleIds?.length > 0 ? (
= observer((props) => { : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" } auto-rows-max transition-all `} > - {cycles.map((cycle) => ( - + {cycleIds.map((cycleId) => ( + ))}
= observer((props) => {
) : ( -
-
-
- - - - -
-

{filter === "all" ? "No cycles" : `No ${filter} cycles`}

- -
-
+ )} ); diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 9ea26ab3976..e34f4b30b2d 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -1,10 +1,8 @@ import { FC, MouseEvent, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; - -// stores -import { useMobxStore } from "lib/mobx/store-provider"; // hooks +import { useApplication, useCycle, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; @@ -13,16 +11,16 @@ import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarG // icons import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; // helpers -import { findHowManyDaysLeft, renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; +import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; -// types -import { ICycle, TCycleGroups } from "types"; // constants import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; +// types +import { TCycleGroups } from "@plane/types"; type TCyclesListItem = { - cycle: ICycle; + cycleId: string; handleEditCycle?: () => void; handleDeleteCycle?: () => void; handleAddToFavorites?: () => void; @@ -32,52 +30,29 @@ type TCyclesListItem = { }; export const CyclesListItem: FC = (props) => { - const { cycle, workspaceSlug, projectId } = props; - // store - const { - cycle: cycleStore, - trackEvent: { setTrackElement }, - user: userStore, - } = useMobxStore(); - // toast - const { setToastAlert } = useToast(); + const { cycleId, workspaceSlug, projectId } = props; // states const [updateModal, setUpdateModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false); - // computed - const cycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups; - const isCompleted = cycleStatus === "completed"; - const endDate = new Date(cycle.end_date ?? ""); - const startDate = new Date(cycle.start_date ?? ""); - - const { currentProjectRole } = userStore; - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - + // router const router = useRouter(); - - const cycleTotalIssues = - cycle.backlog_issues + - cycle.unstarted_issues + - cycle.started_issues + - cycle.completed_issues + - cycle.cancelled_issues; - - const renderDate = cycle.start_date || cycle.end_date; - - const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); - - const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100; - - const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + // store hooks + const { + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); + // toast alert + const { setToastAlert } = useToast(); const handleCopyText = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { + copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { setToastAlert({ type: "success", title: "Link Copied!", @@ -90,7 +65,7 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -103,7 +78,7 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - cycleStore.removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycle).catch(() => { + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { setToastAlert({ type: "error", title: "Error!", @@ -132,27 +107,59 @@ export const CyclesListItem: FC = (props) => { router.push({ pathname: router.pathname, - query: { ...query, peekCycle: cycle.id }, + query: { ...query, peekCycle: cycleId }, }); }; + const cycleDetails = getCycleById(cycleId); + + if (!cycleDetails) return null; + + // computed + // TODO: change this logic once backend fix the response + const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + const isCompleted = cycleStatus === "completed"; + const endDate = new Date(cycleDetails.end_date ?? ""); + const startDate = new Date(cycleDetails.start_date ?? ""); + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + + const cycleTotalIssues = + cycleDetails.backlog_issues + + cycleDetails.unstarted_issues + + cycleDetails.started_issues + + cycleDetails.completed_issues + + cycleDetails.cancelled_issues; + + const renderDate = cycleDetails.start_date || cycleDetails.end_date; + + // const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; + + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + + const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); + + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date()); + return ( <> setUpdateModal(false)} workspaceSlug={workspaceSlug} projectId={projectId} /> setDeleteModal(false)} workspaceSlug={workspaceSlug} projectId={projectId} /> - +
@@ -176,8 +183,8 @@ export const CyclesListItem: FC = (props) => { - - {cycle.name} + + {cycleDetails.name}
@@ -197,25 +204,23 @@ export const CyclesListItem: FC = (props) => { }} > {currentCycle.value === "current" - ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}` + ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` : `${currentCycle.label}`} )}
{renderDate && ( - - {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} - {" - "} - {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + + {renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} )} - +
- {cycle.assignees.length > 0 ? ( + {cycleDetails.assignees.length > 0 ? ( - {cycle.assignees.map((assignee) => ( + {cycleDetails.assignees.map((assignee) => ( ))} @@ -227,7 +232,7 @@ export const CyclesListItem: FC = (props) => {
{isEditingAllowed && - (cycle.is_favorite ? ( + (cycleDetails.is_favorite ? ( @@ -237,7 +242,7 @@ export const CyclesListItem: FC = (props) => { ))} - + {!isCompleted && isEditingAllowed && ( <> diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx index 226807b782e..90fcdd8f9f5 100644 --- a/web/components/cycles/cycles-list.tsx +++ b/web/components/cycles/cycles-list.tsx @@ -1,39 +1,50 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useTheme } from "next-themes"; +// hooks +import { useUser } from "hooks/store"; // components import { CyclePeekOverview, CyclesListItem } from "components/cycles"; +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; // ui import { Loader } from "@plane/ui"; -// types -import { ICycle } from "types"; +// constants +import { CYCLE_EMPTY_STATE_DETAILS } from "constants/cycle"; export interface ICyclesList { - cycles: ICycle[]; + cycleIds: string[]; filter: string; workspaceSlug: string; projectId: string; } export const CyclesList: FC = observer((props) => { - const { cycles, filter, workspaceSlug, projectId } = props; + const { cycleIds, filter, workspaceSlug, projectId } = props; + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { currentUser } = useUser(); - const { - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - } = useMobxStore(); + const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS[filter as keyof typeof CYCLE_EMPTY_STATE_DETAILS]; + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("cycle", filter, isLightMode); return ( <> - {cycles ? ( + {cycleIds ? ( <> - {cycles.length > 0 ? ( + {cycleIds.length > 0 ? (
- {cycles.map((cycle) => ( - + {cycleIds.map((cycleId) => ( + ))}
= observer((props) => {
) : ( -
-
-
- - - - -
-

- {filter === "all" ? "No cycles" : `No ${filter} cycles`} -

- -
-
+ )} ) : ( diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index 3eb37c502df..7b58bde4533 100644 --- a/web/components/cycles/cycles-view.tsx +++ b/web/components/cycles/cycles-view.tsx @@ -1,17 +1,16 @@ import { FC } from "react"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useCycle } from "hooks/store"; // components import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; // ui components import { Loader } from "@plane/ui"; // types -import { TCycleLayout } from "types"; +import { TCycleLayout, TCycleView } from "@plane/types"; export interface ICyclesView { - filter: "all" | "current" | "upcoming" | "draft" | "completed" | "incomplete"; + filter: TCycleView; layout: TCycleLayout; workspaceSlug: string; projectId: string; @@ -20,31 +19,29 @@ export interface ICyclesView { export const CyclesView: FC = observer((props) => { const { filter, layout, workspaceSlug, projectId, peekCycle } = props; - - // store - const { cycle: cycleStore } = useMobxStore(); - - // api call to fetch cycles list - useSWR( - workspaceSlug && projectId && filter ? `CYCLES_LIST_${projectId}_${filter}` : null, - workspaceSlug && projectId && filter ? () => cycleStore.fetchCycles(workspaceSlug, projectId, filter) : null - ); + // store hooks + const { + currentProjectCompletedCycleIds, + currentProjectDraftCycleIds, + currentProjectUpcomingCycleIds, + currentProjectCycleIds, + } = useCycle(); const cyclesList = filter === "completed" - ? cycleStore.projectCompletedCycles + ? currentProjectCompletedCycleIds : filter === "draft" - ? cycleStore.projectDraftCycles - : filter === "upcoming" - ? cycleStore.projectUpcomingCycles - : cycleStore.projectCycles; + ? currentProjectDraftCycleIds + : filter === "upcoming" + ? currentProjectUpcomingCycleIds + : currentProjectCycleIds; return ( <> {layout === "list" && ( <> {cyclesList ? ( - + ) : ( @@ -59,7 +56,7 @@ export const CyclesView: FC = observer((props) => { <> {cyclesList ? ( = observer((props) => { {layout === "gantt" && ( <> {cyclesList ? ( - + ) : ( diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index 33c6254df04..44da175b49a 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -1,17 +1,15 @@ import { Fragment, useState } from "react"; -// next import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; import { AlertTriangle } from "lucide-react"; -// components -import { Button } from "@plane/ui"; // hooks +import { useApplication, useCycle } from "hooks/store"; import useToast from "hooks/use-toast"; +// components +import { Button } from "@plane/ui"; // types -import { ICycle } from "types"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { ICycle } from "@plane/types"; interface ICycleDelete { cycle: ICycle; @@ -23,56 +21,51 @@ interface ICycleDelete { export const CycleDeleteModal: React.FC = observer((props) => { const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props; - // store - const { - cycle: cycleStore, - trackEvent: { postHogEventTracker }, - } = useMobxStore(); - // toast - const { setToastAlert } = useToast(); // states const [loader, setLoader] = useState(false); + // router const router = useRouter(); const { cycleId, peekCycle } = router.query; + // store hooks + const { + eventTracker: { postHogEventTracker }, + } = useApplication(); + const { deleteCycle } = useCycle(); + // toast alert + const { setToastAlert } = useToast(); const formSubmit = async () => { + if (!cycle) return; + setLoader(true); - if (cycle?.id) - try { - await cycleStore - .removeCycle(workspaceSlug, projectId, cycle?.id) - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle deleted successfully.", - }); - postHogEventTracker("CYCLE_DELETE", { - state: "SUCCESS", - }); - }) - .catch(() => { - postHogEventTracker("CYCLE_DELETE", { - state: "FAILED", - }); + try { + await deleteCycle(workspaceSlug, projectId, cycle.id) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Cycle deleted successfully.", + }); + postHogEventTracker("CYCLE_DELETE", { + state: "SUCCESS", }); + }) + .catch(() => { + postHogEventTracker("CYCLE_DELETE", { + state: "FAILED", + }); + }); - if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`); + if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`); - handleClose(); - } catch (error) { - setToastAlert({ - type: "error", - title: "Warning!", - message: "Something went wrong please try again later.", - }); - } - else + handleClose(); + } catch (error) { setToastAlert({ type: "error", title: "Warning!", message: "Something went wrong please try again later.", }); + } setLoader(false); }; diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 2cc087eda27..865cc68a1ab 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -1,27 +1,39 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; +// components +import { DateDropdown, ProjectDropdown } from "components/dropdowns"; // ui import { Button, Input, TextArea } from "@plane/ui"; -import { DateSelect } from "components/ui"; -import { IssueProjectSelect } from "components/issues/select"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types -import { ICycle } from "types"; +import { ICycle } from "@plane/types"; type Props = { handleFormSubmit: (values: Partial) => Promise; handleClose: () => void; + status: boolean; projectId: string; setActiveProject: (projectId: string) => void; data?: ICycle | null; }; +const defaultValues: Partial = { + name: "", + description: "", + start_date: null, + end_date: null, +}; + export const CycleForm: React.FC = (props) => { - const { handleFormSubmit, handleClose, projectId, setActiveProject, data } = props; + const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props; // form data const { formState: { errors, isSubmitting }, handleSubmit, control, watch, + reset, } = useForm({ defaultValues: { project: projectId, @@ -32,6 +44,13 @@ export const CycleForm: React.FC = (props) => { }, }); + useEffect(() => { + reset({ + ...defaultValues, + ...data, + }); + }, [data, reset]); + const startDate = watch("start_date"); const endDate = watch("end_date"); @@ -45,19 +64,23 @@ export const CycleForm: React.FC = (props) => {
- ( - { - onChange(val); - setActiveProject(val); - }} - /> - )} - /> + {!status && ( + ( + { + onChange(val); + setActiveProject(val); + }} + buttonVariant="background-with-text" + tabIndex={7} + /> + )} + /> + )}

{status ? "Update" : "New"} Cycle

@@ -84,6 +107,7 @@ export const CycleForm: React.FC = (props) => { inputSize="md" onChange={onChange} hasError={Boolean(errors?.name)} + tabIndex={1} /> )} /> @@ -101,6 +125,7 @@ export const CycleForm: React.FC = (props) => { hasError={Boolean(errors?.description)} value={value} onChange={onChange} + tabIndex={2} /> )} /> @@ -112,34 +137,45 @@ export const CycleForm: React.FC = (props) => { control={control} name="start_date" render={({ field: { value, onChange } }) => ( - onChange(val)} - minDate={new Date()} - maxDate={maxDate ?? undefined} - /> - )} - /> -
-
- ( - onChange(val)} minDate={minDate} /> +
+ 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} + /> +
+ )} + />
- -
diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index 76a4d923572..46bc04039d7 100644 --- a/web/components/cycles/gantt-chart/blocks.tsx +++ b/web/components/cycles/gantt-chart/blocks.tsx @@ -1,11 +1,10 @@ import { useRouter } from "next/router"; - // ui import { Tooltip, ContrastIcon } from "@plane/ui"; // helpers -import { renderShortDate } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { ICycle } from "types"; +import { ICycle } from "@plane/types"; export const CycleGanttBlock = ({ data }: { data: ICycle }) => { const router = useRouter(); @@ -35,7 +34,7 @@ export const CycleGanttBlock = ({ data }: { data: ICycle }) => {
{data?.name}
- {renderShortDate(data?.start_date ?? "")} to {renderShortDate(data?.end_date ?? "")} + {renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.end_date ?? "")}
} diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 9671c22af58..26d04e103ae 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -1,38 +1,41 @@ import { FC } from "react"; - import { useRouter } from "next/router"; - +import { observer } from "mobx-react-lite"; import { KeyedMutator } from "swr"; - +// hooks +import { useCycle, useUser } from "hooks/store"; // services import { CycleService } from "services/cycle.service"; -// hooks -import useUser from "hooks/use-user"; -import useProjectDetails from "hooks/use-project-details"; // components import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; import { CycleGanttBlock } from "components/cycles"; // types -import { ICycle } from "types"; +import { ICycle } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; - cycles: ICycle[]; + cycleIds: string[]; mutateCycles?: KeyedMutator; }; // services const cycleService = new CycleService(); -export const CyclesListGanttChartView: FC = ({ cycles, mutateCycles }) => { +export const CyclesListGanttChartView: FC = observer((props) => { + const { cycleIds, mutateCycles } = props; + // router const router = useRouter(); const { workspaceSlug } = router.query; - - const { user } = useUser(); - const { projectDetails } = useProjectDetails(); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + const { getCycleById } = useCycle(); const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { - if (!workspaceSlug || !user) return; + if (!workspaceSlug) return; mutateCycles && mutateCycles((prevData: any) => { if (!prevData) return prevData; @@ -63,27 +66,31 @@ export const CyclesListGanttChartView: FC = ({ cycles, mutateCycles }) => cycleService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload); }; - const blockFormat = (blocks: ICycle[]) => - blocks && blocks.length > 0 - ? blocks - .filter((b) => b.start_date && b.end_date && new Date(b.start_date) <= new Date(b.end_date)) - .map((block) => ({ - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: new Date(block.start_date ?? ""), - target_date: new Date(block.end_date ?? ""), - })) - : []; - - const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + const blockFormat = (blocks: (ICycle | null)[]) => { + if (!blocks) return []; + + const filteredBlocks = blocks.filter((b) => b !== null && b.start_date && b.end_date); + + const structuredBlocks = filteredBlocks.map((block) => ({ + data: block, + id: block?.id ?? "", + sort_order: block?.sort_order ?? 0, + start_date: new Date(block?.start_date ?? ""), + target_date: new Date(block?.end_date ?? ""), + })); + + return structuredBlocks; + }; + + const isAllowed = + currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); return (
getCycleById(c))) : null} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)} sidebarToRender={(props) => } blockToRender={(data: ICycle) => } @@ -94,4 +101,4 @@ export const CyclesListGanttChartView: FC = ({ cycles, mutateCycles }) => />
); -}; +}); diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 665f9865b12..8144feef7e0 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -1,14 +1,15 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // services import { CycleService } from "services/cycle.service"; // hooks +import { useApplication, useCycle, useProject } from "hooks/store"; import useToast from "hooks/use-toast"; -import { useMobxStore } from "lib/mobx/store-provider"; +import useLocalStorage from "hooks/use-local-storage"; // components import { CycleForm } from "components/cycles"; // types -import type { CycleDateCheckData, ICycle } from "types"; +import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; type CycleModalProps = { isOpen: boolean; @@ -23,21 +24,24 @@ const cycleService = new CycleService(); export const CycleCreateUpdateModal: React.FC = (props) => { const { isOpen, handleClose, data, workspaceSlug, projectId } = props; - // store - const { - cycle: cycleStore, - trackEvent: { postHogEventTracker }, - } = useMobxStore(); // states - const [activeProject, setActiveProject] = useState(projectId); - // toast + const [activeProject, setActiveProject] = useState(null); + // store hooks + const { + eventTracker: { postHogEventTracker }, + } = useApplication(); + const { workspaceProjectIds } = useProject(); + const { createCycle, updateCycleDetails } = useCycle(); + // toast alert const { setToastAlert } = useToast(); - const createCycle = async (payload: Partial) => { + const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); + + const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; + const selectedProjectId = payload.project ?? projectId.toString(); - await cycleStore - .createCycle(workspaceSlug, selectedProjectId, payload) + await createCycle(workspaceSlug, selectedProjectId, payload) .then((res) => { setToastAlert({ type: "success", @@ -61,11 +65,11 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }; - const updateCycle = async (cycleId: string, payload: Partial) => { + const handleUpdateCycle = async (cycleId: string, payload: Partial) => { if (!workspaceSlug || !projectId) return; + const selectedProjectId = payload.project ?? projectId.toString(); - await cycleStore - .patchCycle(workspaceSlug, selectedProjectId, cycleId, payload) + await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) .then(() => { setToastAlert({ type: "success", @@ -116,8 +120,12 @@ export const CycleCreateUpdateModal: React.FC = (props) => { } if (isDateValid) { - if (data) await updateCycle(data.id, payload); - else await createCycle(payload); + if (data) await handleUpdateCycle(data.id, payload); + else { + await handleCreateCycle(payload).then(() => { + setCycleTab("all"); + }); + } handleClose(); } else setToastAlert({ @@ -127,6 +135,27 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }; + 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) { + setActiveProject(data.project); + return; + } + + // if data is not present, set active project to the project + // in the url. This has the least priority. + if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProject) + setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null); + }, [activeProject, data, projectId, workspaceProjectIds, isOpen]); + return ( @@ -157,7 +186,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index f3576fb001f..4bf76f91f79 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,13 +1,12 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useRef, 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"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // services import { CycleService } from "services/cycle.service"; // hooks +import { useApplication, useCycle, useMember, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { SidebarProgressStats } from "components/core"; @@ -32,13 +31,11 @@ import { copyUrlToClipboard } from "helpers/string.helper"; import { findHowManyDaysLeft, isDateGreaterThanToday, - renderDateFormat, - renderShortDate, - renderShortMonthDate, + renderFormattedPayloadDate, + renderFormattedDate, } from "helpers/date-time.helper"; // types -import { ICycle, IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { ICycle } from "@plane/types"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; // fetch-keys @@ -49,34 +46,40 @@ type Props = { handleClose: () => void; }; +const defaultValues: Partial = { + start_date: null, + end_date: null, +}; + // services const cycleService = new CycleService(); // TODO: refactor the whole component 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; - + // store hooks const { - cycle: cycleDetailsStore, - cycleIssuesFilter: { issueFilters, updateFilters }, - trackEvent: { setTrackElement }, - user: { currentProjectRole }, - } = useMobxStore(); + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { getCycleById, updateCycleDetails } = useCycle(); + const { getUserDetails } = useMember(); - const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined; + const cycleDetails = getCycleById(cycleId); + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; const { setToastAlert } = useToast(); - const defaultValues: Partial = { - start_date: new Date().toString(), - end_date: new Date().toString(), - }; - const { setValue, reset, watch } = useForm({ defaultValues, }); @@ -84,7 +87,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const submitChanges = (data: Partial) => { if (!workspaceSlug || !projectId || !cycleId) return; - cycleDetailsStore.patchCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data); + updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data); }; const handleCopyText = () => { @@ -122,6 +125,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const handleStartDateChange = async (date: string) => { setValue("start_date", date); + + if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click(); + if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { if (!isDateGreaterThanToday(`${watch("end_date")}`)) { setToastAlert({ @@ -129,6 +135,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { title: "Error!", message: "Unable to create cycle in past date. Please enter a valid date.", }); + reset({ ...cycleDetails }); return; } @@ -141,15 +148,14 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { if (isDateValidForExistingCycle) { submitChanges({ - start_date: renderDateFormat(`${watch("start_date")}`), - end_date: renderDateFormat(`${watch("end_date")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), }); setToastAlert({ type: "success", title: "Success!", message: "Cycle updated successfully.", }); - return; } else { setToastAlert({ type: "error", @@ -157,8 +163,10 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { 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", }); - return; } + + reset({ ...cycleDetails }); + return; } const isDateValid = await dateChecker({ @@ -168,8 +176,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { if (isDateValid) { submitChanges({ - start_date: renderDateFormat(`${watch("start_date")}`), - end_date: renderDateFormat(`${watch("end_date")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), }); setToastAlert({ type: "success", @@ -183,6 +191,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { 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 }); } } }; @@ -190,6 +199,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { 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({ @@ -197,6 +208,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { title: "Error!", message: "Unable to create cycle in past date. Please enter a valid date.", }); + reset({ ...cycleDetails }); return; } @@ -209,15 +221,14 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { if (isDateValidForExistingCycle) { submitChanges({ - start_date: renderDateFormat(`${watch("start_date")}`), - end_date: renderDateFormat(`${watch("end_date")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), }); setToastAlert({ type: "success", title: "Success!", message: "Cycle updated successfully.", }); - return; } else { setToastAlert({ type: "error", @@ -225,8 +236,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { 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", }); - return; } + reset({ ...cycleDetails }); + return; } const isDateValid = await dateChecker({ @@ -236,8 +248,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { if (isDateValid) { submitChanges({ - start_date: renderDateFormat(`${watch("start_date")}`), - end_date: renderDateFormat(`${watch("end_date")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), }); setToastAlert({ type: "success", @@ -251,28 +263,30 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { 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 handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { [key]: newValues }, cycleId); - }, - [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] - ); + // TODO: refactor this + // const handleFiltersUpdate = useCallback( + // (key: keyof IIssueFilterOptions, value: string | string[]) => { + // if (!workspaceSlug || !projectId) return; + // const newValues = issueFilters?.filters?.[key] ?? []; + + // if (Array.isArray(value)) { + // value.forEach((val) => { + // if (!newValues.includes(val)) newValues.push(val); + // }); + // } else { + // if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + // else newValues.push(value); + // } + + // updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { [key]: newValues }, cycleId); + // }, + // [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + // ); const cycleStatus = cycleDetails?.status.toLocaleLowerCase(); const isCompleted = cycleStatus === "completed"; @@ -302,9 +316,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 areYearsEqual = - startDate.getFullYear() === endDate.getFullYear() || isNaN(startDate.getFullYear()) || isNaN(endDate.getFullYear()); - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = @@ -345,7 +356,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { {!isCompleted && isEditingAllowed && ( - + { setTrackElement("CYCLE_PAGE_SIDEBAR"); @@ -391,52 +402,56 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- Start Date + Start date
- - - {areYearsEqual - ? renderShortDate(startDate, "No date selected") - : renderShortMonthDate(startDate, "No date selected")} - - - - - - { - if (val) { - setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON"); - handleStartDateChange(val); - } - }} - 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} - /> - - + {({ 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} + /> + + + + )}
@@ -444,54 +459,56 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- Target Date + Target date
- <> - - ( + <> + - {areYearsEqual - ? renderShortDate(endDate, "No date selected") - : renderShortMonthDate(endDate, "No date selected")} - - - - - - { - if (val) { - setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON"); - handleEndDateChange(val); - } - }} - 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} - /> - - - + + {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} + /> + + + + )}
@@ -503,8 +520,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- - {cycleDetails.owned_by.display_name} + + {cycleOwnerDetails?.display_name}
@@ -547,7 +564,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- Invalid date. Please enter valid date. + {cycleDetails?.start_date && cycleDetails?.end_date + ? "This cycle isn't active yet." + : "Invalid date. Please enter valid date."}
)} @@ -570,14 +589,16 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
-
- -
+ {cycleDetails && cycleDetails.distribution && ( +
+ +
+ )}
) : ( "" @@ -595,9 +616,6 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }} totalIssues={cycleDetails.total_issues} isPeekView={Boolean(peekCycle)} - isCompleted={isCompleted} - filters={issueFilters?.filters} - handleFiltersUpdate={handleFiltersUpdate} />
)} diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index dd462e36071..5956e4a1e23 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -1,32 +1,31 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -import useSWR from "swr"; import { Dialog, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; -// services -import { CycleService } from "services/cycle.service"; // hooks import useToast from "hooks/use-toast"; -import { useMobxStore } from "lib/mobx/store-provider"; +import { useCycle, useIssues } from "hooks/store"; //icons import { ContrastIcon, TransferIcon } from "@plane/ui"; import { AlertCircle, Search, X } from "lucide-react"; -// fetch-key -import { INCOMPLETE_CYCLES_LIST } from "constants/fetch-keys"; -// types -import { ICycle } from "types"; +// constants +import { EIssuesStoreType } from "constants/issue"; type Props = { isOpen: boolean; handleClose: () => void; }; -const cycleService = new CycleService(); - -export const TransferIssuesModal: React.FC = observer(({ isOpen, handleClose }) => { +export const TransferIssuesModal: React.FC = observer((props) => { + const { isOpen, handleClose } = props; + // states const [query, setQuery] = useState(""); - const { cycleIssues: cycleIssueStore } = useMobxStore(); + // store hooks + const { currentProjectIncompleteCycleIds, getCycleById } = useCycle(); + const { + issues: { transferIssuesFromCycle }, + } = useIssues(EIssuesStoreType.CYCLE); const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; @@ -34,12 +33,14 @@ export const TransferIssuesModal: React.FC = observer(({ isOpen, handleCl const { setToastAlert } = useToast(); const transferIssue = async (payload: any) => { - await cycleIssueStore - .transferIssuesFromCycle(workspaceSlug as string, projectId as string, cycleId as string, payload) + if (!workspaceSlug || !projectId || !cycleId) return; + + // TODO: import transferIssuesFromCycle from store + await transferIssuesFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), payload) .then(() => { setToastAlert({ type: "success", - title: "Issues transfered successfully", + title: "Issues transferred successfully", message: "Issues have been transferred successfully", }); }) @@ -52,17 +53,11 @@ export const TransferIssuesModal: React.FC = observer(({ isOpen, handleCl }); }; - const { data: incompleteCycles } = useSWR( - workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => cycleService.getCyclesWithParams(workspaceSlug as string, projectId as string, "incomplete") - : null - ); + const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => { + const cycleDetails = getCycleById(optionId); - const filteredOptions = - query === "" - ? incompleteCycles - : incompleteCycles?.filter((option) => option.name.toLowerCase().includes(query.toLowerCase())); + return cycleDetails?.name.toLowerCase().includes(query.toLowerCase()); + }); // useEffect(() => { // const handleKeyDown = (e: KeyboardEvent) => { @@ -121,26 +116,32 @@ export const TransferIssuesModal: React.FC = observer(({ isOpen, handleCl
{filteredOptions ? ( filteredOptions.length > 0 ? ( - filteredOptions.map((option: ICycle) => ( - - )) + filteredOptions.map((optionId) => { + const cycleDetails = getCycleById(optionId); + + if (!cycleDetails) return; + + return ( + + ); + }) ) : (
diff --git a/web/components/dashboard/home-dashboard-widgets.tsx b/web/components/dashboard/home-dashboard-widgets.tsx new file mode 100644 index 00000000000..2e2f9ef88b4 --- /dev/null +++ b/web/components/dashboard/home-dashboard-widgets.tsx @@ -0,0 +1,61 @@ +import { observer } from "mobx-react-lite"; +// hooks +import { useApplication, useDashboard } from "hooks/store"; +// components +import { + AssignedIssuesWidget, + CreatedIssuesWidget, + IssuesByPriorityWidget, + IssuesByStateGroupWidget, + OverviewStatsWidget, + RecentActivityWidget, + RecentCollaboratorsWidget, + RecentProjectsWidget, + WidgetProps, +} from "components/dashboard"; +// types +import { TWidgetKeys } from "@plane/types"; + +const WIDGETS_LIST: { + [key in TWidgetKeys]: { component: React.FC; fullWidth: boolean }; +} = { + overview_stats: { component: OverviewStatsWidget, fullWidth: true }, + assigned_issues: { component: AssignedIssuesWidget, fullWidth: false }, + created_issues: { component: CreatedIssuesWidget, fullWidth: false }, + issues_by_state_groups: { component: IssuesByStateGroupWidget, fullWidth: false }, + issues_by_priority: { component: IssuesByPriorityWidget, fullWidth: false }, + recent_activity: { component: RecentActivityWidget, fullWidth: false }, + recent_projects: { component: RecentProjectsWidget, fullWidth: false }, + recent_collaborators: { component: RecentCollaboratorsWidget, fullWidth: true }, +}; + +export const DashboardWidgets = observer(() => { + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { homeDashboardId, homeDashboardWidgets } = useDashboard(); + + const doesWidgetExist = (widgetKey: TWidgetKeys) => + Boolean(homeDashboardWidgets?.find((widget) => widget.key === widgetKey)); + + if (!workspaceSlug || !homeDashboardId) return null; + + return ( +
+ {Object.entries(WIDGETS_LIST).map(([key, widget]) => { + const WidgetComponent = widget.component; + // if the widget doesn't exist, return null + if (!doesWidgetExist(key as TWidgetKeys)) return null; + // if the widget is full width, return it in a 2 column grid + if (widget.fullWidth) + return ( +
+ +
+ ); + else return ; + })} +
+ ); +}); diff --git a/web/components/dashboard/index.ts b/web/components/dashboard/index.ts new file mode 100644 index 00000000000..129cdb69ea3 --- /dev/null +++ b/web/components/dashboard/index.ts @@ -0,0 +1,3 @@ +export * from "./widgets"; +export * from "./home-dashboard-widgets"; +export * from "./project-empty-state"; diff --git a/web/components/dashboard/project-empty-state.tsx b/web/components/dashboard/project-empty-state.tsx new file mode 100644 index 00000000000..c0ac90f34e5 --- /dev/null +++ b/web/components/dashboard/project-empty-state.tsx @@ -0,0 +1,41 @@ +import Image from "next/image"; +import { observer } from "mobx-react-lite"; +// hooks +import { useApplication, useUser } from "hooks/store"; +// ui +import { Button } from "@plane/ui"; +// assets +import ProjectEmptyStateImage from "public/empty-state/dashboard/project.svg"; +// constants +import { EUserWorkspaceRoles } from "constants/workspace"; + +export const DashboardProjectEmptyState = observer(() => { + // store hooks + const { + commandPalette: { toggleCreateProjectModal }, + } = useApplication(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // derived values + const canCreateProject = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; + + return ( +
+

Overview of your projects, activity, and metrics

+

+ Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this + page will transform into a space that helps you progress. Admins will also see items which help their team + progress. +

+ Project empty state + {canCreateProject && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx new file mode 100644 index 00000000000..d4a27afc154 --- /dev/null +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Tab } from "@headlessui/react"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { + DurationFilterDropdown, + TabsList, + WidgetIssuesList, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// helpers +import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; +// types +import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types"; +// constants +import { ISSUES_TABS_LIST } from "constants/dashboard"; + +const WIDGET_KEY = "assigned_issues"; + +export const AssignedIssuesWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [fetching, setFetching] = useState(false); + // store hooks + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + // derived values + const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + setFetching(true); + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming", + target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + expand: "issue_relation", + }).finally(() => setFetching(false)); + }; + + useEffect(() => { + const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: widgetDetails?.widget_filters.tab ?? "upcoming", + target_date: filterDates, + expand: "issue_relation", + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming"); + + if (!widgetDetails || !widgetStats) return ; + + return ( +
+
+
+ + Assigned to you + +

+ Filtered by{" "} + Due date +

+
+ + handleUpdateFilters({ + target_date: val, + }) + } + /> +
+ t.key === widgetDetails.widget_filters.tab ?? "upcoming")} + onChange={(i) => { + const selectedTab = ISSUES_TABS_LIST[i]; + handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" }); + }} + className="h-full flex flex-col" + > +
+ +
+ + {ISSUES_TABS_LIST.map((tab) => ( + + + + ))} + +
+
+ ); +}); diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx new file mode 100644 index 00000000000..f5727f277df --- /dev/null +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Tab } from "@headlessui/react"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { + DurationFilterDropdown, + TabsList, + WidgetIssuesList, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// helpers +import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; +// types +import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types"; +// constants +import { ISSUES_TABS_LIST } from "constants/dashboard"; + +const WIDGET_KEY = "created_issues"; + +export const CreatedIssuesWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [fetching, setFetching] = useState(false); + // store hooks + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + // derived values + const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + setFetching(true); + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming", + target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + }).finally(() => setFetching(false)); + }; + + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + issue_type: widgetDetails?.widget_filters.tab ?? "upcoming", + target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming"); + + if (!widgetDetails || !widgetStats) return ; + + return ( +
+
+
+ + Created by you + +

+ Filtered by{" "} + Due date +

+
+ + handleUpdateFilters({ + target_date: val, + }) + } + /> +
+ t.key === widgetDetails.widget_filters.tab ?? "upcoming")} + onChange={(i) => { + const selectedTab = ISSUES_TABS_LIST[i]; + handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" }); + }} + className="h-full flex flex-col" + > +
+ +
+ + {ISSUES_TABS_LIST.map((tab) => ( + + + + ))} + +
+
+ ); +}); diff --git a/web/components/dashboard/widgets/dropdowns/duration-filter.tsx b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx new file mode 100644 index 00000000000..0db293a656c --- /dev/null +++ b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx @@ -0,0 +1,36 @@ +import { ChevronDown } from "lucide-react"; +// ui +import { CustomMenu } from "@plane/ui"; +// types +import { TDurationFilterOptions } from "@plane/types"; +// constants +import { DURATION_FILTER_OPTIONS } from "constants/dashboard"; + +type Props = { + onChange: (value: TDurationFilterOptions) => void; + value: TDurationFilterOptions; +}; + +export const DurationFilterDropdown: React.FC = (props) => { + const { onChange, value } = props; + + return ( + + {DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label} + +
+ } + placement="bottom-end" + closeOnSelect + > + {DURATION_FILTER_OPTIONS.map((option) => ( + onChange(option.key)}> + {option.label} + + ))} + + ); +}; diff --git a/web/components/dashboard/widgets/dropdowns/index.ts b/web/components/dashboard/widgets/dropdowns/index.ts new file mode 100644 index 00000000000..cff4cdb449c --- /dev/null +++ b/web/components/dashboard/widgets/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./duration-filter"; diff --git a/web/components/dashboard/widgets/empty-states/assigned-issues.tsx b/web/components/dashboard/widgets/empty-states/assigned-issues.tsx new file mode 100644 index 00000000000..f60d8efe60a --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/assigned-issues.tsx @@ -0,0 +1,30 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// types +import { TIssuesListTypes } from "@plane/types"; +// constants +import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard"; + +type Props = { + type: TIssuesListTypes; +}; + +export const AssignedIssuesEmptyState: React.FC = (props) => { + const { type } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type]; + + const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage; + + // TODO: update empty state logic to use a general component + return ( +
+
+ Assigned issues +
+

{typeDetails.title}

+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/created-issues.tsx b/web/components/dashboard/widgets/empty-states/created-issues.tsx new file mode 100644 index 00000000000..fe93d4404a6 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/created-issues.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// types +import { TIssuesListTypes } from "@plane/types"; +// constants +import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard"; + +type Props = { + type: TIssuesListTypes; +}; + +export const CreatedIssuesEmptyState: React.FC = (props) => { + const { type } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + const typeDetails = CREATED_ISSUES_EMPTY_STATES[type]; + + const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage; + + return ( +
+
+ Assigned issues +
+

{typeDetails.title}

+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/index.ts b/web/components/dashboard/widgets/empty-states/index.ts new file mode 100644 index 00000000000..72ca1dbb2dc --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/index.ts @@ -0,0 +1,6 @@ +export * from "./assigned-issues"; +export * from "./created-issues"; +export * from "./issues-by-priority"; +export * from "./issues-by-state-group"; +export * from "./recent-activity"; +export * from "./recent-collaborators"; diff --git a/web/components/dashboard/widgets/empty-states/issues-by-priority.tsx b/web/components/dashboard/widgets/empty-states/issues-by-priority.tsx new file mode 100644 index 00000000000..83c1d00425e --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/issues-by-priority.tsx @@ -0,0 +1,25 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/issues-by-priority.svg"; +import LightImage from "public/empty-state/dashboard/light/issues-by-priority.svg"; + +export const IssuesByPriorityEmptyState = () => { + // next-themes + const { resolvedTheme } = useTheme(); + + const image = resolvedTheme === "dark" ? DarkImage : LightImage; + + return ( +
+
+ Issues by state group +
+

+ Issues assigned to you, broken down by +
+ priority will show up here. +

+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/issues-by-state-group.tsx b/web/components/dashboard/widgets/empty-states/issues-by-state-group.tsx new file mode 100644 index 00000000000..b4cc81ce799 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/issues-by-state-group.tsx @@ -0,0 +1,25 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/issues-by-state-group.svg"; +import LightImage from "public/empty-state/dashboard/light/issues-by-state-group.svg"; + +export const IssuesByStateGroupEmptyState = () => { + // next-themes + const { resolvedTheme } = useTheme(); + + const image = resolvedTheme === "dark" ? DarkImage : LightImage; + + return ( +
+
+ Issues by state group +
+

+ Issue assigned to you, broken down by state, +
+ will show up here. +

+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/recent-activity.tsx b/web/components/dashboard/widgets/empty-states/recent-activity.tsx new file mode 100644 index 00000000000..ff4218ace24 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/recent-activity.tsx @@ -0,0 +1,25 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/recent-activity.svg"; +import LightImage from "public/empty-state/dashboard/light/recent-activity.svg"; + +export const RecentActivityEmptyState = () => { + // next-themes + const { resolvedTheme } = useTheme(); + + const image = resolvedTheme === "dark" ? DarkImage : LightImage; + + return ( +
+
+ Issues by state group +
+

+ All your issue activities across +
+ projects will show up here. +

+
+ ); +}; diff --git a/web/components/dashboard/widgets/empty-states/recent-collaborators.tsx b/web/components/dashboard/widgets/empty-states/recent-collaborators.tsx new file mode 100644 index 00000000000..ef1c63f7340 --- /dev/null +++ b/web/components/dashboard/widgets/empty-states/recent-collaborators.tsx @@ -0,0 +1,39 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import DarkImage1 from "public/empty-state/dashboard/dark/recent-collaborators-1.svg"; +import DarkImage2 from "public/empty-state/dashboard/dark/recent-collaborators-2.svg"; +import DarkImage3 from "public/empty-state/dashboard/dark/recent-collaborators-3.svg"; +import LightImage1 from "public/empty-state/dashboard/light/recent-collaborators-1.svg"; +import LightImage2 from "public/empty-state/dashboard/light/recent-collaborators-2.svg"; +import LightImage3 from "public/empty-state/dashboard/light/recent-collaborators-3.svg"; + +export const RecentCollaboratorsEmptyState = () => { + // next-themes + const { resolvedTheme } = useTheme(); + + const image1 = resolvedTheme === "dark" ? DarkImage1 : LightImage1; + const image2 = resolvedTheme === "dark" ? DarkImage2 : LightImage2; + const image3 = resolvedTheme === "dark" ? DarkImage3 : LightImage3; + + return ( +
+

+ Compare your activities with the top +
+ seven in your project. +

+
+
+ Recent collaborators +
+
+ Recent collaborators +
+
+ Recent collaborators +
+
+
+ ); +}; diff --git a/web/components/dashboard/widgets/index.ts b/web/components/dashboard/widgets/index.ts new file mode 100644 index 00000000000..a481a88817c --- /dev/null +++ b/web/components/dashboard/widgets/index.ts @@ -0,0 +1,12 @@ +export * from "./dropdowns"; +export * from "./empty-states"; +export * from "./issue-panels"; +export * from "./loaders"; +export * from "./assigned-issues"; +export * from "./created-issues"; +export * from "./issues-by-priority"; +export * from "./issues-by-state-group"; +export * from "./overview-stats"; +export * from "./recent-activity"; +export * from "./recent-collaborators"; +export * from "./recent-projects"; diff --git a/web/components/dashboard/widgets/issue-panels/index.ts b/web/components/dashboard/widgets/issue-panels/index.ts new file mode 100644 index 00000000000..f5b7d53d49e --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/index.ts @@ -0,0 +1,3 @@ +export * from "./issue-list-item"; +export * from "./issues-list"; +export * from "./tabs-list"; diff --git a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx new file mode 100644 index 00000000000..3da862d91bb --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -0,0 +1,297 @@ +import { observer } from "mobx-react-lite"; +import isToday from "date-fns/isToday"; +// hooks +import { useIssueDetail, useMember, useProject } from "hooks/store"; +// ui +import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui"; +// helpers +import { findTotalDaysInRange, renderFormattedDate } from "helpers/date-time.helper"; +// types +import { TIssue, TWidgetIssue } from "@plane/types"; + +export type IssueListItemProps = { + issueId: string; + onClick: (issue: TIssue) => void; + workspaceSlug: string; +}; + +export const AssignedUpcomingIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined; + + if (!issueDetails) return null; + + const projectDetails = getProjectById(issueDetails.project_id); + + const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? []; + + const blockedByIssueProjectDetails = + blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null; + + return ( + onClick(issueDetails)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issueDetails.sequence_id} + +
{issueDetails.name}
+
+
+ {issueDetails.target_date + ? isToday(new Date(issueDetails.target_date)) + ? "Today" + : renderFormattedDate(issueDetails.target_date) + : "-"} +
+
+ {blockedByIssues.length > 0 + ? blockedByIssues.length > 1 + ? `${blockedByIssues.length} blockers` + : `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}` + : "-"} +
+
+ ); +}); + +export const AssignedOverdueIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined; + + if (!issueDetails) return null; + + const projectDetails = getProjectById(issueDetails.project_id); + const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? []; + + const blockedByIssueProjectDetails = + blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null; + + const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false); + + return ( + onClick(issueDetails)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issueDetails.sequence_id} + +
{issueDetails.name}
+
+
+ {dueBy} {`day${dueBy > 1 ? "s" : ""}`} +
+
+ {blockedByIssues.length > 0 + ? blockedByIssues.length > 1 + ? `${blockedByIssues.length} blockers` + : `${blockedByIssueProjectDetails?.identifier} ${blockedByIssues[0]?.sequence_id}` + : "-"} +
+
+ ); +}); + +export const AssignedCompletedIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issueDetails = getIssueById(issueId); + + if (!issueDetails) return null; + + const projectDetails = getProjectById(issueDetails.project_id); + + return ( + onClick(issueDetails)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issueDetails.sequence_id} + +
{issueDetails.name}
+
+
+ ); +}); + +export const CreatedUpcomingIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectDetails = getProjectById(issue.project_id); + + return ( + onClick(issue)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issue.sequence_id} + +
{issue.name}
+
+
+ {issue.target_date + ? isToday(new Date(issue.target_date)) + ? "Today" + : renderFormattedDate(issue.target_date) + : "-"} +
+
+ {issue.assignee_ids.length > 0 ? ( + + {issue.assignee_ids?.map((assigneeId) => { + const userDetails = getUserDetails(assigneeId); + + if (!userDetails) return null; + + return ; + })} + + ) : ( + "-" + )} +
+
+ ); +}); + +export const CreatedOverdueIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectDetails = getProjectById(issue.project_id); + + const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false); + + return ( + onClick(issue)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issue.sequence_id} + +
{issue.name}
+
+
+ {dueBy} {`day${dueBy > 1 ? "s" : ""}`} +
+
+ {issue.assignee_ids.length > 0 ? ( + + {issue.assignee_ids?.map((assigneeId) => { + const userDetails = getUserDetails(assigneeId); + + if (!userDetails) return null; + + return ; + })} + + ) : ( + "-" + )} +
+
+ ); +}); + +export const CreatedCompletedIssueListItem: React.FC = observer((props) => { + const { issueId, onClick, workspaceSlug } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + // derived values + const issue = getIssueById(issueId); + + if (!issue) return null; + + const projectDetails = getProjectById(issue.project_id); + + return ( + onClick(issue)} + className="py-2 px-3 hover:bg-custom-background-80 rounded grid grid-cols-6 gap-1" + > +
+ + + {projectDetails?.identifier} {issue.sequence_id} + +
{issue.name}
+
+
+ {issue.assignee_ids.length > 0 ? ( + + {issue.assignee_ids?.map((assigneeId) => { + const userDetails = getUserDetails(assigneeId); + + if (!userDetails) return null; + + return ; + })} + + ) : ( + "-" + )} +
+
+ ); +}); diff --git a/web/components/dashboard/widgets/issue-panels/issues-list.tsx b/web/components/dashboard/widgets/issue-panels/issues-list.tsx new file mode 100644 index 00000000000..af2c11660bb --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/issues-list.tsx @@ -0,0 +1,126 @@ +import Link from "next/link"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { + AssignedCompletedIssueListItem, + AssignedIssuesEmptyState, + AssignedOverdueIssueListItem, + AssignedUpcomingIssueListItem, + CreatedCompletedIssueListItem, + CreatedIssuesEmptyState, + CreatedOverdueIssueListItem, + CreatedUpcomingIssueListItem, + IssueListItemProps, +} from "components/dashboard/widgets"; +// 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"; + +export type WidgetIssuesListProps = { + isLoading: boolean; + issues: TIssue[]; + tab: TIssuesListTypes; + totalIssues: number; + type: "assigned" | "created"; + workspaceSlug: string; +}; + +export const WidgetIssuesList: React.FC = (props) => { + const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; + // store hooks + const { setPeekIssue } = useIssueDetail(); + + const handleIssuePeekOverview = (issue: TIssue) => + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); + + const filterParams = getRedirectionFilters(tab); + + const ISSUE_LIST_ITEM: { + [key in string]: { + [key in TIssuesListTypes]: React.FC; + }; + } = { + assigned: { + upcoming: AssignedUpcomingIssueListItem, + overdue: AssignedOverdueIssueListItem, + completed: AssignedCompletedIssueListItem, + }, + created: { + upcoming: CreatedUpcomingIssueListItem, + overdue: CreatedOverdueIssueListItem, + completed: CreatedCompletedIssueListItem, + }, + }; + + return ( + <> +
+ {isLoading ? ( + + + + + + + ) : issues.length > 0 ? ( + <> +
+
+ Issues + + {totalIssues} + +
+ {tab === "upcoming" &&
Due date
} + {tab === "overdue" &&
Due by
} + {type === "assigned" && tab !== "completed" &&
Blocked by
} + {type === "created" &&
Assigned to
} +
+
+ {issues.map((issue) => { + const IssueListItem = ISSUE_LIST_ITEM[type][tab]; + + if (!IssueListItem) return null; + + return ( + + ); + })} +
+ + ) : ( +
+ {type === "assigned" && } + {type === "created" && } +
+ )} +
+ {issues.length > 0 && ( + + View all issues + + )} + + ); +}; diff --git a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx new file mode 100644 index 00000000000..6ef6ec0eea5 --- /dev/null +++ b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx @@ -0,0 +1,26 @@ +import { Tab } from "@headlessui/react"; +// helpers +import { cn } from "helpers/common.helper"; +// constants +import { ISSUES_TABS_LIST } from "constants/dashboard"; + +export const TabsList = () => ( + + {ISSUES_TABS_LIST.map((tab) => ( + + cn("font-semibold text-xs rounded py-1.5 focus:outline-none", { + "bg-custom-background-100 text-custom-text-300 shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selected, + "text-custom-text-400": !selected, + }) + } + > + {tab.label} + + ))} + +); diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx new file mode 100644 index 00000000000..45b71466d1f --- /dev/null +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { MarimekkoGraph } from "components/ui"; +import { + DurationFilterDropdown, + IssuesByPriorityEmptyState, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// ui +import { PriorityIcon } from "@plane/ui"; +// helpers +import { getCustomDates } from "helpers/dashboard.helper"; +// types +import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types"; +// constants +import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard"; +import { ISSUE_PRIORITIES } from "constants/issue"; + +const TEXT_COLORS = { + urgent: "#F4A9AA", + high: "#AB4800", + medium: "#AB6400", + low: "#1F2D5C", + none: "#60646C", +}; + +const CustomBar = (props: any) => { + const { bar, workspaceSlug } = props; + // states + const [isMouseOver, setIsMouseOver] = useState(false); + + return ( + + setIsMouseOver(true)} + onMouseLeave={() => setIsMouseOver(false)} + > + + + {bar?.id} + + + + ); +}; + +const WIDGET_KEY = "issues_by_priority"; + +export const IssuesByPriorityWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }; + + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!widgetDetails || !widgetStats) return ; + + const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0); + const chartData = widgetStats + .filter((i) => i.count !== 0) + .map((item) => ({ + priority: item?.priority, + percentage: (item?.count / totalCount) * 100, + urgent: item?.priority === "urgent" ? 1 : 0, + high: item?.priority === "high" ? 1 : 0, + medium: item?.priority === "medium" ? 1 : 0, + low: item?.priority === "low" ? 1 : 0, + none: item?.priority === "none" ? 1 : 0, + })); + + const CustomBarsLayer = (props: any) => { + const { bars } = props; + + return ( + + {bars + ?.filter((b: any) => b?.value === 1) // render only bars with value 1 + .map((bar: any) => ( + + ))} + + ); + }; + + return ( +
+
+
+ + Assigned by priority + +

+ Filtered by{" "} + Due date +

+
+ + handleUpdateFilters({ + target_date: val, + }) + } + /> +
+ {totalCount > 0 ? ( +
+
+ ({ + id: p.key, + value: p.key, + }))} + axisBottom={null} + axisLeft={null} + height="119px" + margin={{ + top: 11, + right: 0, + bottom: 0, + left: 0, + }} + defs={PRIORITY_GRAPH_GRADIENTS} + fill={ISSUE_PRIORITIES.map((p) => ({ + match: { + id: p.key, + }, + id: `gradient${p.title}`, + }))} + tooltip={() => <>} + enableGridX={false} + enableGridY={false} + layers={[CustomBarsLayer]} + /> +
+ {chartData.map((item) => ( +

+ + {item.percentage.toFixed(0)}% +

+ ))} +
+
+
+ ) : ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx new file mode 100644 index 00000000000..bd4171cfa2d --- /dev/null +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { PieGraph } from "components/ui"; +import { + DurationFilterDropdown, + IssuesByStateGroupEmptyState, + WidgetLoader, + WidgetProps, +} from "components/dashboard/widgets"; +// helpers +import { getCustomDates } from "helpers/dashboard.helper"; +// types +import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types"; +// constants +import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard"; +import { STATE_GROUPS } from "constants/state"; + +const WIDGET_KEY = "issues_by_state_groups"; + +export const IssuesByStateGroupWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [defaultStateGroup, setDefaultStateGroup] = useState(null); + const [activeStateGroup, setActiveStateGroup] = useState(null); + // router + const router = useRouter(); + // store hooks + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + // derived values + const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + const handleUpdateFilters = async (filters: Partial) => { + if (!widgetDetails) return; + + await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, { + widgetKey: WIDGET_KEY, + filters, + }); + + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"), + }); + }; + + // fetch widget stats + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // set active group for center metric + useEffect(() => { + if (!widgetStats) return; + + const startedCount = widgetStats?.find((item) => item?.state === "started")?.count ?? 0; + const unStartedCount = widgetStats?.find((item) => item?.state === "unstarted")?.count ?? 0; + const backlogCount = widgetStats?.find((item) => item?.state === "backlog")?.count ?? 0; + const completedCount = widgetStats?.find((item) => item?.state === "completed")?.count ?? 0; + const canceledCount = widgetStats?.find((item) => item?.state === "cancelled")?.count ?? 0; + + const stateGroup = + startedCount > 0 + ? "started" + : unStartedCount > 0 + ? "unstarted" + : backlogCount > 0 + ? "backlog" + : completedCount > 0 + ? "completed" + : canceledCount > 0 + ? "cancelled" + : null; + + setActiveStateGroup(stateGroup); + setDefaultStateGroup(stateGroup); + }, [widgetStats]); + + if (!widgetDetails || !widgetStats) return ; + + const totalCount = widgetStats?.reduce((acc, item) => acc + item?.count, 0); + const chartData = widgetStats?.map((item) => ({ + color: STATE_GROUP_GRAPH_COLORS[item?.state as keyof typeof STATE_GROUP_GRAPH_COLORS], + id: item?.state, + label: item?.state, + value: (item?.count / totalCount) * 100, + })); + + const CenteredMetric = ({ dataWithArc, centerX, centerY }: any) => { + const data = dataWithArc?.find((datum: any) => datum?.id === activeStateGroup); + const percentage = chartData?.find((item) => item.id === activeStateGroup)?.value?.toFixed(0); + + return ( + + + {percentage}% + + + {data?.id} + + + ); + }; + + return ( +
+
+
+ + Assigned by state + +

+ Filtered by{" "} + Due date +

+
+ + handleUpdateFilters({ + target_date: val, + }) + } + /> +
+ {totalCount > 0 ? ( +
+
+
+ datum.data.color} + padAngle={1} + enableArcLinkLabels={false} + enableArcLabels={false} + activeOuterRadiusOffset={5} + tooltip={() => <>} + margin={{ + top: 0, + right: 5, + bottom: 0, + left: 5, + }} + defs={STATE_GROUP_GRAPH_GRADIENTS} + fill={Object.values(STATE_GROUPS).map((p) => ({ + match: { + id: p.key, + }, + id: `gradient${p.label}`, + }))} + onClick={(datum, e) => { + e.preventDefault(); + e.stopPropagation(); + router.push(`/${workspaceSlug}/workspace-views/assigned/?state_group=${datum.id}`); + }} + onMouseEnter={(datum) => setActiveStateGroup(datum.id as TStateGroups)} + onMouseLeave={() => setActiveStateGroup(defaultStateGroup)} + layers={["arcs", CenteredMetric]} + /> +
+
+ {chartData.map((item) => ( +
+
+
+ {item.label} +
+ {item.value.toFixed(0)}% +
+ ))} +
+
+
+ ) : ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/dashboard/widgets/loaders/assigned-issues.tsx b/web/components/dashboard/widgets/loaders/assigned-issues.tsx new file mode 100644 index 00000000000..4de381b29fc --- /dev/null +++ b/web/components/dashboard/widgets/loaders/assigned-issues.tsx @@ -0,0 +1,22 @@ +// ui +import { Loader } from "@plane/ui"; + +export const AssignedIssuesWidgetLoader = () => ( + +
+ + +
+
+ + +
+
+ + + + + +
+
+); diff --git a/web/components/dashboard/widgets/loaders/index.ts b/web/components/dashboard/widgets/loaders/index.ts new file mode 100644 index 00000000000..ee5286f0fbf --- /dev/null +++ b/web/components/dashboard/widgets/loaders/index.ts @@ -0,0 +1 @@ +export * from "./loader"; diff --git a/web/components/dashboard/widgets/loaders/issues-by-priority.tsx b/web/components/dashboard/widgets/loaders/issues-by-priority.tsx new file mode 100644 index 00000000000..4051a290831 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/issues-by-priority.tsx @@ -0,0 +1,15 @@ +// ui +import { Loader } from "@plane/ui"; + +export const IssuesByPriorityWidgetLoader = () => ( + + +
+ + + + + +
+
+); diff --git a/web/components/dashboard/widgets/loaders/issues-by-state-group.tsx b/web/components/dashboard/widgets/loaders/issues-by-state-group.tsx new file mode 100644 index 00000000000..d2316802d8f --- /dev/null +++ b/web/components/dashboard/widgets/loaders/issues-by-state-group.tsx @@ -0,0 +1,21 @@ +// ui +import { Loader } from "@plane/ui"; + +export const IssuesByStateGroupWidgetLoader = () => ( + + +
+
+
+ +
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+ +); diff --git a/web/components/dashboard/widgets/loaders/loader.tsx b/web/components/dashboard/widgets/loaders/loader.tsx new file mode 100644 index 00000000000..141bb55336c --- /dev/null +++ b/web/components/dashboard/widgets/loaders/loader.tsx @@ -0,0 +1,31 @@ +// components +import { AssignedIssuesWidgetLoader } from "./assigned-issues"; +import { IssuesByPriorityWidgetLoader } from "./issues-by-priority"; +import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group"; +import { OverviewStatsWidgetLoader } from "./overview-stats"; +import { RecentActivityWidgetLoader } from "./recent-activity"; +import { RecentProjectsWidgetLoader } from "./recent-projects"; +import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators"; +// types +import { TWidgetKeys } from "@plane/types"; + +type Props = { + widgetKey: TWidgetKeys; +}; + +export const WidgetLoader: React.FC = (props) => { + const { widgetKey } = props; + + const loaders = { + overview_stats: , + assigned_issues: , + created_issues: , + issues_by_state_groups: , + issues_by_priority: , + recent_activity: , + recent_projects: , + recent_collaborators: , + }; + + return loaders[widgetKey]; +}; diff --git a/web/components/dashboard/widgets/loaders/overview-stats.tsx b/web/components/dashboard/widgets/loaders/overview-stats.tsx new file mode 100644 index 00000000000..f72d66ce4e1 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/overview-stats.tsx @@ -0,0 +1,13 @@ +// ui +import { Loader } from "@plane/ui"; + +export const OverviewStatsWidgetLoader = () => ( + + {Array.from({ length: 4 }).map((_, index) => ( +
+ + +
+ ))} +
+); diff --git a/web/components/dashboard/widgets/loaders/recent-activity.tsx b/web/components/dashboard/widgets/loaders/recent-activity.tsx new file mode 100644 index 00000000000..47e895a6e71 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/recent-activity.tsx @@ -0,0 +1,19 @@ +// ui +import { Loader } from "@plane/ui"; + +export const RecentActivityWidgetLoader = () => ( + + + {Array.from({ length: 7 }).map((_, index) => ( +
+
+ +
+
+ + +
+
+ ))} +
+); diff --git a/web/components/dashboard/widgets/loaders/recent-collaborators.tsx b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx new file mode 100644 index 00000000000..d838967af76 --- /dev/null +++ b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx @@ -0,0 +1,18 @@ +// ui +import { Loader } from "@plane/ui"; + +export const RecentCollaboratorsWidgetLoader = () => ( + + +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+ +
+ +
+ ))} +
+
+); diff --git a/web/components/dashboard/widgets/loaders/recent-projects.tsx b/web/components/dashboard/widgets/loaders/recent-projects.tsx new file mode 100644 index 00000000000..fc181ffab9d --- /dev/null +++ b/web/components/dashboard/widgets/loaders/recent-projects.tsx @@ -0,0 +1,19 @@ +// ui +import { Loader } from "@plane/ui"; + +export const RecentProjectsWidgetLoader = () => ( + + + {Array.from({ length: 5 }).map((_, index) => ( +
+
+ +
+
+ + +
+
+ ))} +
+); diff --git a/web/components/dashboard/widgets/overview-stats.tsx b/web/components/dashboard/widgets/overview-stats.tsx new file mode 100644 index 00000000000..1a4c2646b4f --- /dev/null +++ b/web/components/dashboard/widgets/overview-stats.tsx @@ -0,0 +1,88 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +// hooks +import { useDashboard } from "hooks/store"; +// components +import { WidgetLoader } from "components/dashboard/widgets"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import { TOverviewStatsWidgetResponse } from "@plane/types"; + +export type WidgetProps = { + dashboardId: string; + workspaceSlug: string; +}; + +const WIDGET_KEY = "overview_stats"; + +export const OverviewStatsWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { fetchWidgetStats, getWidgetStats } = useDashboard(); + // derived values + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + const today = renderFormattedPayloadDate(new Date()); + const STATS_LIST = [ + { + key: "assigned", + title: "Issues assigned", + count: widgetStats?.assigned_issues_count, + link: `/${workspaceSlug}/workspace-views/assigned`, + }, + { + key: "overdue", + title: "Issues overdue", + count: widgetStats?.pending_issues_count, + link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`, + }, + { + key: "created", + title: "Issues created", + count: widgetStats?.created_issues_count, + link: `/${workspaceSlug}/workspace-views/created`, + }, + { + key: "completed", + title: "Issues completed", + count: widgetStats?.completed_issues_count, + link: `/${workspaceSlug}/workspace-views/assigned?state_group=completed`, + }, + ]; + + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!widgetStats) return ; + + return ( +
+ {STATS_LIST.map((stat) => ( +
+ +
+
+
{stat.count}
+

{stat.title}

+
+
+ +
+ ))} +
+ ); +}); diff --git a/web/components/dashboard/widgets/recent-activity.tsx b/web/components/dashboard/widgets/recent-activity.tsx new file mode 100644 index 00000000000..fc16946d8e1 --- /dev/null +++ b/web/components/dashboard/widgets/recent-activity.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { History } from "lucide-react"; +// hooks +import { useDashboard, useUser } from "hooks/store"; +// components +import { ActivityIcon, ActivityMessage, IssueLink } from "components/core"; +import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +// ui +import { Avatar } from "@plane/ui"; +// helpers +import { calculateTimeAgo } from "helpers/date-time.helper"; +// types +import { TRecentActivityWidgetResponse } from "@plane/types"; + +const WIDGET_KEY = "recent_activity"; + +export const RecentActivityWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { currentUser } = useUser(); + // derived values + const { fetchWidgetStats, getWidgetStats } = useDashboard(); + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!widgetStats) return ; + + return ( +
+ + Your issue activities + + {widgetStats.length > 0 ? ( +
+ {widgetStats.map((activity) => ( +
+
+ {activity.field ? ( + activity.new_value === "restore" ? ( + + ) : ( +
+ +
+ ) + ) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( + + ) : ( +
+ {activity.actor_detail.is_bot + ? activity.actor_detail.first_name.charAt(0) + : activity.actor_detail.display_name.charAt(0)} +
+ )} +
+
+

+ + {currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail.display_name}{" "} + + {activity.field ? ( + + ) : ( + + created + + )} +

+

{calculateTimeAgo(activity.created_at)}

+
+
+ ))} +
+ ) : ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/dashboard/widgets/recent-collaborators.tsx b/web/components/dashboard/widgets/recent-collaborators.tsx new file mode 100644 index 00000000000..2fafbb9acaf --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +// hooks +import { useDashboard, useMember, useUser } from "hooks/store"; +// components +import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { TRecentCollaboratorsWidgetResponse } from "@plane/types"; + +type CollaboratorListItemProps = { + issueCount: number; + userId: string; + workspaceSlug: string; +}; + +const WIDGET_KEY = "recent_collaborators"; + +const CollaboratorListItem: React.FC = observer((props) => { + const { issueCount, userId, workspaceSlug } = props; + // store hooks + const { currentUser } = useUser(); + const { getUserDetails } = useMember(); + // derived values + const userDetails = getUserDetails(userId); + const isCurrentUser = userId === currentUser?.id; + + if (!userDetails) return null; + + return ( + +
+ +
+
+ {isCurrentUser ? "You" : userDetails?.display_name} +
+

+ {issueCount} active issue{issueCount > 1 ? "s" : ""} +

+ + ); +}); + +export const RecentCollaboratorsWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { fetchWidgetStats, getWidgetStats } = useDashboard(); + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!widgetStats) return ; + + return ( +
+
+

Most active members

+

+ Top eight active members in your project by last activity +

+
+ {widgetStats.length > 1 ? ( +
+ {widgetStats.map((user) => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+ ); +}); diff --git a/web/components/dashboard/widgets/recent-projects.tsx b/web/components/dashboard/widgets/recent-projects.tsx new file mode 100644 index 00000000000..aae8ff54b47 --- /dev/null +++ b/web/components/dashboard/widgets/recent-projects.tsx @@ -0,0 +1,125 @@ +import { useEffect } from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Plus } from "lucide-react"; +// hooks +import { useApplication, useDashboard, useProject, useUser } from "hooks/store"; +// components +import { WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +// ui +import { Avatar, AvatarGroup } from "@plane/ui"; +// helpers +import { renderEmoji } from "helpers/emoji.helper"; +// types +import { TRecentProjectsWidgetResponse } from "@plane/types"; +// constants +import { EUserWorkspaceRoles } from "constants/workspace"; +import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard"; + +const WIDGET_KEY = "recent_projects"; + +type ProjectListItemProps = { + projectId: string; + workspaceSlug: string; +}; + +const ProjectListItem: React.FC = observer((props) => { + const { projectId, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const projectDetails = getProjectById(projectId); + + const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)]; + + if (!projectDetails) return null; + + return ( + +
+ {projectDetails.emoji ? ( + + {renderEmoji(projectDetails.emoji)} + + ) : projectDetails.icon_prop ? ( +
{renderEmoji(projectDetails.icon_prop)}
+ ) : ( + + {projectDetails.name.charAt(0)} + + )} +
+
+
+ {projectDetails.name} +
+
+ + {projectDetails.members?.map((member) => ( + + ))} + +
+
+ + ); +}); + +export const RecentProjectsWidget: React.FC = observer((props) => { + const { dashboardId, workspaceSlug } = props; + // store hooks + const { + commandPalette: { toggleCreateProjectModal }, + } = useApplication(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); + const { fetchWidgetStats, getWidgetStats } = useDashboard(); + // derived values + const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const canCreateProject = currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + + useEffect(() => { + fetchWidgetStats(workspaceSlug, dashboardId, { + widget_key: WIDGET_KEY, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!widgetStats) return ; + + return ( +
+ + Your projects + +
+ {canCreateProject && ( + + )} + {widgetStats.map((projectId) => ( + + ))} +
+
+ ); +}); diff --git a/web/components/dnd/StrictModeDroppable.tsx b/web/components/dnd/StrictModeDroppable.tsx deleted file mode 100644 index 9feba79b215..00000000000 --- a/web/components/dnd/StrictModeDroppable.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { useState, useEffect } from "react"; - -// react beautiful dnd -import { Droppable, DroppableProps } from "@hello-pangea/dnd"; - -const StrictModeDroppable = ({ children, ...props }: DroppableProps) => { - const [enabled, setEnabled] = useState(false); - - useEffect(() => { - const animation = requestAnimationFrame(() => setEnabled(true)); - - return () => { - cancelAnimationFrame(animation); - setEnabled(false); - }; - }, []); - - if (!enabled) return null; - - return {children}; -}; - -export default StrictModeDroppable; diff --git a/web/components/dropdowns/buttons.tsx b/web/components/dropdowns/buttons.tsx new file mode 100644 index 00000000000..93d8c187cec --- /dev/null +++ b/web/components/dropdowns/buttons.tsx @@ -0,0 +1,101 @@ +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TButtonVariants } from "./types"; +// constants +import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants"; +import { Tooltip } from "@plane/ui"; + +export type DropdownButtonProps = { + children: React.ReactNode; + className?: string; + isActive: boolean; + tooltipContent: string | React.ReactNode; + tooltipHeading: string; + showTooltip: boolean; + variant: TButtonVariants; +}; + +type ButtonProps = { + children: React.ReactNode; + className?: string; + isActive: boolean; + tooltipContent: string | React.ReactNode; + tooltipHeading: string; + showTooltip: boolean; +}; + +export const DropdownButton: React.FC = (props) => { + const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip, variant } = props; + + const ButtonToRender: React.FC = BORDER_BUTTON_VARIANTS.includes(variant) + ? BorderButton + : BACKGROUND_BUTTON_VARIANTS.includes(variant) + ? BackgroundButton + : TransparentButton; + + return ( + + {children} + + ); +}; + +const BorderButton: React.FC = (props) => { + const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip } = props; + + return ( + +
+ {children} +
+
+ ); +}; + +const BackgroundButton: React.FC = (props) => { + const { children, className, tooltipContent, tooltipHeading, showTooltip } = props; + + return ( + +
+ {children} +
+
+ ); +}; + +const TransparentButton: React.FC = (props) => { + const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip } = props; + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/web/components/dropdowns/constants.ts b/web/components/dropdowns/constants.ts new file mode 100644 index 00000000000..ce52ad5054c --- /dev/null +++ b/web/components/dropdowns/constants.ts @@ -0,0 +1,20 @@ +// types +import { TButtonVariants } from "./types"; + +export const BORDER_BUTTON_VARIANTS: TButtonVariants[] = ["border-with-text", "border-without-text"]; + +export const BACKGROUND_BUTTON_VARIANTS: TButtonVariants[] = ["background-with-text", "background-without-text"]; + +export const TRANSPARENT_BUTTON_VARIANTS: TButtonVariants[] = ["transparent-with-text", "transparent-without-text"]; + +export const BUTTON_VARIANTS_WITHOUT_TEXT: TButtonVariants[] = [ + "border-without-text", + "background-without-text", + "transparent-without-text", +]; + +export const BUTTON_VARIANTS_WITH_TEXT: TButtonVariants[] = [ + "border-with-text", + "background-with-text", + "transparent-with-text", +]; diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx new file mode 100644 index 00000000000..d6d4da432ec --- /dev/null +++ b/web/components/dropdowns/cycle.tsx @@ -0,0 +1,255 @@ +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; + 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, + placeholder = "Cycle", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // 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, + }, + }, + ], + }); + // 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())); + + // fetch cycles of the project if not already present in the store + useEffect(() => { + if (!workspaceSlug) return; + + if (!cycleIds) fetchAllCycles(workspaceSlug, projectId); + }, [cycleIds, fetchAllCycles, projectId, workspaceSlug]); + + const selectedCycle = value ? getCycleById(value) : null; + + const onOpen = () => { + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (isOpen) setIsOpen(false); + if (referenceElement) referenceElement.blur(); + }; + + 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); + + 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/date.tsx b/web/components/dropdowns/date.tsx new file mode 100644 index 00000000000..1dba6f78037 --- /dev/null +++ b/web/components/dropdowns/date.tsx @@ -0,0 +1,164 @@ +import React, { useRef, useState } from "react"; +import { Combobox } from "@headlessui/react"; +import DatePicker from "react-datepicker"; +import { usePopper } from "react-popper"; +import { CalendarDays, X } from "lucide-react"; +// hooks +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { DropdownButton } from "./buttons"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +import { cn } from "helpers/common.helper"; +// types +import { TDropdownProps } from "./types"; +// constants +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; + +type Props = TDropdownProps & { + clearIconClassName?: string; + icon?: React.ReactNode; + isClearable?: boolean; + minDate?: Date; + maxDate?: Date; + onChange: (val: Date | null) => void; + value: Date | string | null; + closeOnSelect?: boolean; +}; + +export const DateDropdown: React.FC = (props) => { + const { + buttonClassName = "", + buttonContainerClassName, + buttonVariant, + className = "", + clearIconClassName = "", + closeOnSelect = true, + disabled = false, + hideIcon = false, + icon = , + isClearable = true, + minDate, + maxDate, + onChange, + placeholder = "Date", + placement, + showTooltip = false, + tabIndex, + value, + } = props; + const [isOpen, setIsOpen] = useState(false); + // 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 isDateSelected = value && value.toString().trim() !== ""; + + const onOpen = () => { + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (isOpen) setIsOpen(false); + if (referenceElement) referenceElement.blur(); + }; + + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + }; + + const dropdownOnChange = (val: Date | null) => { + onChange(val); + if (closeOnSelect) handleClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/web/components/dropdowns/estimate.tsx b/web/components/dropdowns/estimate.tsx new file mode 100644 index 00000000000..88fec31993d --- /dev/null +++ b/web/components/dropdowns/estimate.tsx @@ -0,0 +1,244 @@ +import { Fragment, ReactNode, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Combobox } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Check, ChevronDown, Search, Triangle } from "lucide-react"; +import sortBy from "lodash/sortBy"; +// hooks +import { useApplication, useEstimate } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { DropdownButton } from "./buttons"; +// 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: number | null) => void; + projectId: string; + value: number | null; +}; + +type DropdownOptions = + | { + value: number | null; + query: string; + content: JSX.Element; + }[] + | undefined; + +export const EstimateDropdown: React.FC = observer((props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + onChange, + placeholder = "Estimate", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // 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, + }, + }, + ], + }); + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { fetchProjectEstimates, getProjectActiveEstimateDetails, getEstimatePointValue } = useEstimate(); + const activeEstimate = getProjectActiveEstimateDetails(projectId); + + const options: DropdownOptions = sortBy(activeEstimate?.points ?? [], "key")?.map((point) => ({ + value: point.key, + query: `${point?.value}`, + content: ( +
+ + {point.value} +
+ ), + })); + options?.unshift({ + value: null, + query: "No estimate", + content: ( +
+ + No estimate +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + const selectedEstimate = value !== null ? getEstimatePointValue(value, projectId) : null; + + const onOpen = () => { + if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId); + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (isOpen) setIsOpen(false); + if (referenceElement) referenceElement.blur(); + }; + + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + }; + + const dropdownOnChange = (val: number | 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 && ( + +
+
+ + 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/index.ts b/web/components/dropdowns/index.ts new file mode 100644 index 00000000000..036ed9f757f --- /dev/null +++ b/web/components/dropdowns/index.ts @@ -0,0 +1,8 @@ +export * from "./member"; +export * from "./cycle"; +export * from "./date"; +export * from "./estimate"; +export * from "./module"; +export * from "./priority"; +export * from "./project"; +export * from "./state"; diff --git a/web/components/dropdowns/member/avatar.tsx b/web/components/dropdowns/member/avatar.tsx new file mode 100644 index 00000000000..067d609c5db --- /dev/null +++ b/web/components/dropdowns/member/avatar.tsx @@ -0,0 +1,37 @@ +import { observer } from "mobx-react-lite"; +// hooks +import { useMember } from "hooks/store"; +// ui +import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui"; + +type AvatarProps = { + showTooltip: boolean; + userIds: string | string[] | null; +}; + +export const ButtonAvatars: React.FC = observer((props) => { + const { showTooltip, userIds } = props; + // store hooks + const { getUserDetails } = useMember(); + + if (Array.isArray(userIds)) { + if (userIds.length > 0) + return ( + + {userIds.map((userId) => { + const userDetails = getUserDetails(userId); + + if (!userDetails) return; + return ; + })} + + ); + } else { + if (userIds) { + const userDetails = getUserDetails(userIds); + return ; + } + } + + return ; +}); diff --git a/web/components/dropdowns/member/index.ts b/web/components/dropdowns/member/index.ts new file mode 100644 index 00000000000..a9f7e09c8cd --- /dev/null +++ b/web/components/dropdowns/member/index.ts @@ -0,0 +1,2 @@ +export * from "./project-member"; +export * from "./workspace-member"; diff --git a/web/components/dropdowns/member/project-member.tsx b/web/components/dropdowns/member/project-member.tsx new file mode 100644 index 00000000000..cfbdf52e69a --- /dev/null +++ b/web/components/dropdowns/member/project-member.tsx @@ -0,0 +1,242 @@ +import { Fragment, 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; +} & MemberDropdownProps; + +export const ProjectMemberDropdown: React.FC = observer((props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + multiple, + onChange, + placeholder = "Members", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // 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, + }, + }, + ], + }); + // 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); + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (isOpen) setIsOpen(false); + if (referenceElement) referenceElement.blur(); + }; + + 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); + + 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/types.d.ts b/web/components/dropdowns/member/types.d.ts new file mode 100644 index 00000000000..673bea8aa20 --- /dev/null +++ b/web/components/dropdowns/member/types.d.ts @@ -0,0 +1,19 @@ +import { TDropdownProps } from "../types"; + +export type MemberDropdownProps = TDropdownProps & { + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + placeholder?: string; +} & ( + | { + multiple: false; + onChange: (val: string | null) => void; + value: string | null; + } + | { + multiple: true; + onChange: (val: string[]) => void; + value: string[]; + } + ); diff --git a/web/components/dropdowns/member/workspace-member.tsx b/web/components/dropdowns/member/workspace-member.tsx new file mode 100644 index 00000000000..980f344a69a --- /dev/null +++ b/web/components/dropdowns/member/workspace-member.tsx @@ -0,0 +1,232 @@ +import { Fragment, 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, + placeholder = "Members", + placement, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // 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, + }, + }, + ], + }); + // 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 onOpen = () => { + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (isOpen) setIsOpen(false); + if (referenceElement) referenceElement.blur(); + }; + + 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); + + 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.tsx new file mode 100644 index 00000000000..e673293e003 --- /dev/null +++ b/web/components/dropdowns/module.tsx @@ -0,0 +1,375 @@ +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"; +// hooks +import { useApplication, 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"; +// icons +import { DiceIcon, Tooltip } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TDropdownProps } from "./types"; +// constants +import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; + +type Props = TDropdownProps & { + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + projectId: string; + showCount?: boolean; +} & ( + | { + multiple: false; + onChange: (val: string | null) => void; + value: string | null; + } + | { + multiple: true; + onChange: (val: string[]) => void; + value: string[]; + } + ); + +type DropdownOptions = + | { + value: string | null; + query: string; + content: JSX.Element; + }[] + | undefined; + +type ButtonContentProps = { + disabled: boolean; + dropdownArrow: boolean; + dropdownArrowClassName: string; + hideIcon: boolean; + hideText: boolean; + onChange: (moduleIds: string[]) => void; + placeholder: string; + showCount: boolean; + value: string | string[] | null; +}; + +const ButtonContent: React.FC = (props) => { + const { + disabled, + dropdownArrow, + dropdownArrowClassName, + hideIcon, + hideText, + onChange, + placeholder, + showCount, + value, + } = props; + // store hooks + const { getModuleById } = useModule(); + + if (Array.isArray(value)) + return ( + <> + {showCount ? ( + <> + {!hideIcon && } + + {value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder} + + + ) : value.length > 0 ? ( +
+ {value.map((moduleId) => { + const moduleDetails = getModuleById(moduleId); + return ( +
+ {!hideIcon && } + {!hideText && ( + + {moduleDetails?.name} + + )} + {!disabled && ( + + + + )} +
+ ); + })} +
+ ) : ( + <> + {!hideIcon && } + {placeholder} + + )} + {dropdownArrow && ( +
)} - + ); }; diff --git a/web/components/gantt-chart/types/index.ts b/web/components/gantt-chart/types/index.ts index 9cab40f5cc2..1360f9f45a6 100644 --- a/web/components/gantt-chart/types/index.ts +++ b/web/components/gantt-chart/types/index.ts @@ -13,8 +13,8 @@ export interface IGanttBlock { width: number; }; sort_order: number; - start_date: Date; - target_date: Date; + start_date: Date | null; + target_date: Date | null; } export interface IBlockUpdateData { diff --git a/web/components/gantt-chart/views/month-view.ts b/web/components/gantt-chart/views/month-view.ts index fc145d69c3b..13d054da1ab 100644 --- a/web/components/gantt-chart/views/month-view.ts +++ b/web/components/gantt-chart/views/month-view.ts @@ -167,6 +167,8 @@ export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, const { startDate } = chartData.data; const { start_date: itemStartDate, target_date: itemTargetDate } = itemData; + if (!itemStartDate || !itemTargetDate) return null; + startDate.setHours(0, 0, 0, 0); itemStartDate.setHours(0, 0, 0, 0); itemTargetDate.setHours(0, 0, 0, 0); diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 2526199b594..fc0075030e7 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -1,13 +1,23 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import Link from "next/link"; // hooks +import { + useApplication, + useCycle, + useLabel, + useMember, + useProject, + useProjectState, + useUser, + useIssues, +} from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; // components import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { ProjectAnalyticsModal } from "components/analytics"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // icons @@ -16,37 +26,62 @@ import { ArrowRight, Plus } from "lucide-react"; import { truncateText } from "helpers/string.helper"; import { renderEmoji } from "helpers/emoji.helper"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // constants -import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; -import { EFilterType } from "store/issues/types"; -import { EProjectStore } from "store/command-palette.store"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; + +const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // store hooks + const { getCycleById } = useCycle(); + // derived values + const cycle = getCycleById(cycleId); + + if (!cycle) return null; + + return ( + + + + {truncateText(cycle.name, 40)} + + + ); +}; export const CycleIssuesHeader: React.FC = observer(() => { + // states const [analyticsModal, setAnalyticsModal] = useState(false); - + // router const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query as { workspaceSlug: string; projectId: string; cycleId: string; }; - + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCycleIds, getCycleById } = useCycle(); + const { + commandPalette: { toggleCreateIssueModal }, + eventTracker: { setTrackElement }, + } = useApplication(); const { - cycle: cycleStore, - projectIssuesFilter: projectIssueFiltersStore, - project: { currentProjectDetails }, - projectMember: { projectMembers }, - projectLabel: { projectLabels }, - projectState: projectStateStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - cycleIssuesFilter: { issueFilters, updateFilters }, - user: { currentProjectRole }, - } = useMobxStore(); - - const activeLayout = projectIssueFiltersStore.issueFilters?.displayFilters?.layout; + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const activeLayout = issueFilters?.displayFilters?.layout; const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); @@ -58,7 +93,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { const handleLayoutChange = useCallback( (layout: TIssueLayouts) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); }, [workspaceSlug, projectId, cycleId, updateFilters] ); @@ -77,7 +112,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { else newValues.push(value); } - updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { [key]: newValues }, cycleId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId); }, [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] ); @@ -85,7 +120,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); }, [workspaceSlug, projectId, cycleId, updateFilters] ); @@ -93,16 +128,15 @@ export const CycleIssuesHeader: React.FC = observer(() => { const handleDisplayProperties = useCallback( (property: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_PROPERTIES, property, cycleId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId); }, [workspaceSlug, projectId, cycleId, updateFilters] ); - const cyclesList = cycleStore.projectCycles; - const cycleDetails = cycleId ? cycleStore.getCycleById(cycleId.toString()) : undefined; - + // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const canUserCreateIssue = - currentProjectRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentProjectRole); + currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); return ( <> @@ -113,6 +147,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { />
+ { } className="ml-1.5 flex-shrink-0" - width="auto" placement="bottom-start" > - {cyclesList?.map((cycle) => ( - router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)} - > -
- - {truncateText(cycle.name, 40)} -
-
+ {currentProjectCycleIds?.map((cycleId) => ( + ))} } @@ -179,9 +205,9 @@ export const CycleIssuesHeader: React.FC = observer(() => { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } - labels={projectLabels ?? undefined} - members={projectMembers?.map((m) => m.member)} - states={projectStateStore.states?.[projectId ?? ""] ?? undefined} + labels={projectLabels} + memberIds={projectMemberIds ?? undefined} + states={projectStates} /> @@ -204,7 +230,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { -
+ {currentProjectDetails?.inbox_view && ( +
+ setCreateIssueModal(false)} /> + +
+ )}
); }); diff --git a/web/components/headers/project-issue-details.tsx b/web/components/headers/project-issue-details.tsx index 4eee7d8ebe7..7b45d3fcf96 100644 --- a/web/components/headers/project-issue-details.tsx +++ b/web/components/headers/project-issue-details.tsx @@ -2,27 +2,28 @@ import { FC } from "react"; import useSWR from "swr"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - +// hooks +import { useProject } from "hooks/store"; // ui import { Breadcrumbs, LayersIcon } from "@plane/ui"; -// helper +// helpers import { renderEmoji } from "helpers/emoji.helper"; // services import { IssueService } from "services/issue"; // constants import { ISSUE_DETAILS } from "constants/fetch-keys"; -import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // services const issueService = new IssueService(); export const ProjectIssueDetailsHeader: FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; - - const { project: projectStore } = useMobxStore(); - - const { currentProjectDetails } = projectStore; + // store hooks + const { currentProjectDetails, getProjectById } = useProject(); const { data: issueDetails } = useSWR( workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, @@ -34,6 +35,7 @@ export const ProjectIssueDetailsHeader: FC = observer(() => { return (
+
{
diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index d4d7c633fff..04a7ecb6435 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -3,42 +3,47 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useApplication, useLabel, useProject, useProjectState, useUser, useInbox, useMember } from "hooks/store"; // components import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { ProjectAnalyticsModal } from "components/analytics"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // ui import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; // constants -import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // helper import { renderEmoji } from "helpers/emoji.helper"; -import { EFilterType } from "store/issues/types"; -import { EProjectStore } from "store/command-palette.store"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { useIssues } from "hooks/store/use-issues"; export const ProjectIssuesHeader: React.FC = observer(() => { + // states const [analyticsModal, setAnalyticsModal] = useState(false); - + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - + // store hooks + const { + project: { projectMemberIds }, + } = useMember(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); const { - project: { currentProjectDetails }, - projectLabel: { projectLabels }, - projectMember: { projectMembers }, - projectState: projectStateStore, - inbox: inboxStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - // issue filters - projectIssuesFilter: { issueFilters, updateFilters }, - projectIssues: {}, - user: { currentProjectRole }, - } = useMobxStore(); + commandPalette: { toggleCreateIssueModal }, + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { getInboxesByProjectId, getInboxById } = useInbox(); const activeLayout = issueFilters?.displayFilters?.layout; @@ -56,7 +61,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { else newValues.push(value); } - updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { [key]: newValues }); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); }, [workspaceSlug, projectId, issueFilters, updateFilters] ); @@ -64,7 +69,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const handleLayoutChange = useCallback( (layout: TIssueLayouts) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, { layout: layout }); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, [workspaceSlug, projectId, updateFilters] ); @@ -72,7 +77,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); }, [workspaceSlug, projectId, updateFilters] ); @@ -80,17 +85,17 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const handleDisplayProperties = useCallback( (property: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EFilterType.DISPLAY_PROPERTIES, property); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); }, [workspaceSlug, projectId, updateFilters] ); - const inboxDetails = projectId ? inboxStore.inboxesList?.[projectId]?.[0] : undefined; + 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 && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentProjectRole); + currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); return ( <> @@ -101,6 +106,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { />
+
@@ -211,7 +218,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 370dfe6d439..96929f7b09e 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,32 +1,31 @@ -import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; import { Search, Plus, Briefcase } from "lucide-react"; +// hooks +import { useApplication, useProject, useUser } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; -// hooks -import { useMobxStore } from "lib/mobx/store-provider"; -import { observer } from "mobx-react-lite"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +// components +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; export const ProjectsHeader = observer(() => { - const router = useRouter(); - const { workspaceSlug } = router.query; - - // store + // store hooks const { - project: projectStore, commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - user: { currentWorkspaceRole }, - } = useMobxStore(); - - const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : []; + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); + const { workspaceProjectIds, searchQuery, setSearchQuery } = useProject(); const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; return (
+
{
- {projectsList?.length > 0 && ( + {workspaceProjectIds && workspaceProjectIds?.length > 0 && (
projectStore.setSearchQuery(e.target.value)} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search" />
diff --git a/web/components/headers/user-profile.tsx b/web/components/headers/user-profile.tsx index 8109b6af413..dca0dc7e6d6 100644 --- a/web/components/headers/user-profile.tsx +++ b/web/components/headers/user-profile.tsx @@ -1,9 +1,12 @@ // ui import { Breadcrumbs } from "@plane/ui"; +// components +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; export const UserProfileHeader = () => (
+
diff --git a/web/components/headers/workspace-active-cycles.tsx b/web/components/headers/workspace-active-cycles.tsx new file mode 100644 index 00000000000..90cbccd81e9 --- /dev/null +++ b/web/components/headers/workspace-active-cycles.tsx @@ -0,0 +1,24 @@ +import { observer } from "mobx-react-lite"; +// ui +import { Breadcrumbs, ContrastIcon } from "@plane/ui"; +// icons +import { Crown } from "lucide-react"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; + +export const WorkspaceActiveCycleHeader = observer(() => ( +
+
+ +
+ + } + label="Active Cycles" + /> + + +
+
+
+)); diff --git a/web/components/headers/workspace-analytics.tsx b/web/components/headers/workspace-analytics.tsx index fd86b678074..2ae3734719d 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/components/headers/workspace-analytics.tsx @@ -2,6 +2,8 @@ import { useRouter } from "next/router"; import { ArrowLeft, BarChart2 } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; +// components +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; export const WorkspaceAnalyticsHeader = () => { const router = useRouter(); @@ -12,6 +14,7 @@ export const WorkspaceAnalyticsHeader = () => { className={`relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`} >
+
- -
- {currentIssueIndex + 1}/{issuesList?.length ?? 0} -
-
-
- {isAllowed && (issueStatus === 0 || issueStatus === -2) && ( -
- - - - - - {({ close }) => ( -
- { - if (!val) return; - setDate(val); - }} - dateFormat="dd-MM-yyyy" - minDate={tomorrow} - inline - /> - -
- )} -
-
-
- )} - {isAllowed && issueStatus === -2 && ( -
- -
- )} - {isAllowed && (issueStatus === 0 || issueStatus === -2) && ( -
- -
- )} - {isAllowed && issueStatus === -2 && ( -
- -
- )} - {(isAllowed || user?.id === issue?.created_by) && ( -
- -
- )} -
-
- )} -
- - ); -}); diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx new file mode 100644 index 00000000000..26f58131e77 --- /dev/null +++ b/web/components/inbox/content/root.tsx @@ -0,0 +1,86 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Inbox } from "lucide-react"; +// hooks +import { useInboxIssues } from "hooks/store"; +// components +import { InboxIssueActionsHeader } from "components/inbox"; +import { InboxIssueDetailRoot } from "components/issues/issue-detail/inbox"; +// ui +import { Loader } from "@plane/ui"; + +type TInboxContentRoot = { + workspaceSlug: string; + projectId: string; + inboxId: string; + inboxIssueId: string | undefined; +}; + +export const InboxContentRoot: FC = observer((props) => { + const { workspaceSlug, projectId, inboxId, inboxIssueId } = props; + // hooks + const { + issues: { loader, getInboxIssuesByInboxId }, + } = useInboxIssues(); + + const inboxIssuesList = inboxId ? getInboxIssuesByInboxId(inboxId) : undefined; + + return ( + <> + {loader === "init-loader" ? ( + +
+ + + + +
+
+ + + + +
+
+ ) : ( + <> + {!inboxIssueId ? ( +
+
+
+ + {inboxIssuesList && inboxIssuesList.length > 0 ? ( + + {inboxIssuesList?.length} issues found. Select an issue from the sidebar to view its details. + + ) : ( + No issues found + )} +
+
+
+ ) : ( +
+
+ +
+
+ +
+
+ )} + + )} + + ); +}); diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx new file mode 100644 index 00000000000..0ca28b9506f --- /dev/null +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -0,0 +1,361 @@ +import { FC, useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import DatePicker from "react-datepicker"; +import { Popover } from "@headlessui/react"; +// hooks +import { useApplication, useUser, useInboxIssues, useIssueDetail, useWorkspace } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { + AcceptIssueModal, + DeclineIssueModal, + DeleteInboxIssueModal, + SelectDuplicateInboxIssueModal, +} from "components/inbox"; +// ui +import { Button } from "@plane/ui"; +// icons +import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react"; +// types +import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; +import { EUserProjectRoles } from "constants/project"; + +type TInboxIssueActionsHeader = { + workspaceSlug: string; + projectId: string; + inboxId: string; + inboxIssueId: string | undefined; +}; + +type TInboxIssueOperations = { + updateInboxIssueStatus: (data: TInboxStatus) => Promise; + removeInboxIssue: () => Promise; +}; + +export const InboxIssueActionsHeader: FC = observer((props) => { + const { workspaceSlug, projectId, inboxId, inboxIssueId } = props; + // router + const router = useRouter(); + // hooks + const { + eventTracker: { postHogEventTracker }, + } = useApplication(); + const { currentWorkspace } = useWorkspace(); + const { + issues: { getInboxIssuesByInboxId, getInboxIssueByIssueId, updateInboxIssueStatus, removeInboxIssue }, + } = useInboxIssues(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { + currentUser, + membership: { currentProjectRole }, + } = useUser(); + const { setToastAlert } = useToast(); + + // states + const [date, setDate] = useState(new Date()); + const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false); + const [acceptIssueModal, setAcceptIssueModal] = useState(false); + const [declineIssueModal, setDeclineIssueModal] = useState(false); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + + // derived values + const inboxIssues = getInboxIssuesByInboxId(inboxId); + const issueStatus = (inboxIssueId && inboxId && getInboxIssueByIssueId(inboxId, inboxIssueId)) || undefined; + const issue = (inboxIssueId && getIssueById(inboxIssueId)) || undefined; + + const currentIssueIndex = inboxIssues?.findIndex((issue) => issue === inboxIssueId) ?? 0; + + const inboxIssueOperations: TInboxIssueOperations = useMemo( + () => ({ + updateInboxIssueStatus: async (data: TInboxDetailedStatus) => { + try { + if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) throw new Error("Missing required parameters"); + await updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data); + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong while updating inbox status. Please try again.", + }); + } + }, + removeInboxIssue: async () => { + try { + if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId || !currentWorkspace) + throw new Error("Missing required parameters"); + await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); + postHogEventTracker( + "ISSUE_DELETED", + { + state: "SUCCESS", + }, + { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: currentWorkspace?.id!, + } + ); + router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + }); + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong while deleting inbox issue. Please try again.", + }); + postHogEventTracker( + "ISSUE_DELETED", + { + state: "FAILED", + }, + { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: currentWorkspace?.id!, + } + ); + } + }, + }), + [ + currentWorkspace, + workspaceSlug, + projectId, + inboxId, + inboxIssueId, + updateInboxIssueStatus, + removeInboxIssue, + setToastAlert, + postHogEventTracker, + router, + ] + ); + + const handleInboxIssueNavigation = useCallback( + (direction: "next" | "prev") => { + if (!inboxIssues || !inboxIssueId) return; + const nextIssueIndex = + direction === "next" + ? (currentIssueIndex + 1) % inboxIssues.length + : (currentIssueIndex - 1 + inboxIssues.length) % inboxIssues.length; + const nextIssueId = inboxIssues[nextIssueIndex]; + if (!nextIssueId) return; + router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + query: { + inboxIssueId: nextIssueId, + }, + }); + }, + [workspaceSlug, projectId, inboxId, inboxIssues, inboxIssueId, currentIssueIndex, router] + ); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "ArrowUp") { + handleInboxIssueNavigation("prev"); + } else if (e.key === "ArrowDown") { + handleInboxIssueNavigation("next"); + } + }, + [handleInboxIssueNavigation] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [onKeyDown]); + + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + useEffect(() => { + if (!issueStatus || !issueStatus.snoozed_till) return; + setDate(new Date(issueStatus.snoozed_till)); + }, [issueStatus]); + + if (!issueStatus || !issue || !inboxIssues) return <>; + return ( + <> + {issue && ( + <> + setSelectDuplicateIssue(false)} + value={issueStatus.duplicate_to} + onSubmit={(dupIssueId) => { + inboxIssueOperations + .updateInboxIssueStatus({ + status: 2, + duplicate_to: dupIssueId, + }) + .finally(() => setSelectDuplicateIssue(false)); + }} + /> + + setAcceptIssueModal(false)} + onSubmit={async () => { + await inboxIssueOperations + .updateInboxIssueStatus({ + status: 1, + }) + .finally(() => setAcceptIssueModal(false)); + }} + /> + + setDeclineIssueModal(false)} + onSubmit={async () => { + await inboxIssueOperations + .updateInboxIssueStatus({ + status: -1, + }) + .finally(() => setDeclineIssueModal(false)); + }} + /> + + setDeleteIssueModal(false)} + onSubmit={async () => { + await inboxIssueOperations.removeInboxIssue().finally(() => setDeclineIssueModal(false)); + }} + /> + + )} + + {inboxIssueId && ( +
+
+ + +
+ {currentIssueIndex + 1}/{inboxIssues?.length ?? 0} +
+
+ +
+ {isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && ( +
+ + + + + + {({ close }) => ( +
+ { + if (!val) return; + setDate(val); + }} + dateFormat="dd-MM-yyyy" + minDate={tomorrow} + inline + /> + +
+ )} +
+
+
+ )} + + {isAllowed && issueStatus.status === -2 && ( +
+ +
+ )} + + {isAllowed && (issueStatus.status === 0 || issueStatus.status === -2) && ( +
+ +
+ )} + + {isAllowed && issueStatus.status === -2 && ( +
+ +
+ )} + + {(isAllowed || currentUser?.id === issue?.created_by) && ( +
+ +
+ )} +
+
+ )} + + ); +}); diff --git a/web/components/inbox/inbox-issue-status.tsx b/web/components/inbox/inbox-issue-status.tsx new file mode 100644 index 00000000000..301583b4ba7 --- /dev/null +++ b/web/components/inbox/inbox-issue-status.tsx @@ -0,0 +1,55 @@ +import React from "react"; +// hooks +import { useInboxIssues } from "hooks/store"; +// constants +import { INBOX_STATUS } from "constants/inbox"; + +type Props = { + workspaceSlug: string; + projectId: string; + inboxId: string; + issueId: string; + iconSize?: number; + showDescription?: boolean; +}; + +export const InboxIssueStatus: React.FC = (props) => { + const { workspaceSlug, projectId, inboxId, issueId, iconSize = 18, showDescription = false } = props; + // hooks + const { + issues: { getInboxIssueByIssueId }, + } = useInboxIssues(); + + const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId); + if (!inboxIssueDetail) return <>; + + const inboxIssueStatusDetail = INBOX_STATUS.find((s) => s.status === inboxIssueDetail.status); + if (!inboxIssueStatusDetail) return <>; + + const isSnoozedDatePassed = + inboxIssueDetail.status === 0 && new Date(inboxIssueDetail.snoozed_till ?? "") < new Date(); + + return ( +
+ + {showDescription ? ( + inboxIssueStatusDetail.description( + workspaceSlug, + projectId, + inboxIssueDetail.duplicate_to ?? "", + new Date(inboxIssueDetail.snoozed_till ?? "") + ) + ) : ( + {inboxIssueStatusDetail.title} + )} +
+ ); +}; diff --git a/web/components/inbox/index.ts b/web/components/inbox/index.ts index ef1a9e92dd5..bc8be5506f4 100644 --- a/web/components/inbox/index.ts +++ b/web/components/inbox/index.ts @@ -1,8 +1,14 @@ export * from "./modals"; -export * from "./actions-header"; -export * from "./filters-dropdown"; -export * from "./filters-list"; -export * from "./issue-activity"; -export * from "./issue-card"; -export * from "./issues-list-sidebar"; -export * from "./main-content"; + +export * from "./inbox-issue-actions"; +export * from "./inbox-issue-status"; + +export * from "./content/root"; + +export * from "./sidebar/root"; + +export * from "./sidebar/filter/filter-selection"; +export * from "./sidebar/filter/applied-filters"; + +export * from "./sidebar/inbox-list"; +export * from "./sidebar/inbox-list-item"; diff --git a/web/components/inbox/issue-activity.tsx b/web/components/inbox/issue-activity.tsx deleted file mode 100644 index 2b8fe7d9ba5..00000000000 --- a/web/components/inbox/issue-activity.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; -import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { AddComment, IssueActivitySection } from "components/issues"; -// services -import { IssueService, IssueCommentService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -// types -import { IIssue, IIssueActivity } from "types"; -// fetch-keys -import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; - -type Props = { issueDetails: IIssue }; - -// services -const issueService = new IssueService(); -const issueCommentService = new IssueCommentService(); - -export const InboxIssueActivity: React.FC = observer(({ issueDetails }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { - user: userStore, - trackEvent: { postHogEventTracker }, - workspace: { currentWorkspace }, - } = useMobxStore(); - - const { setToastAlert } = useToast(); - - const { data: issueActivity, mutate: mutateIssueActivity } = useSWR( - workspaceSlug && projectId && issueDetails ? PROJECT_ISSUES_ACTIVITY(issueDetails.id) : null, - workspaceSlug && projectId && issueDetails - ? () => issueService.getIssueActivities(workspaceSlug.toString(), projectId.toString(), issueDetails.id) - : null - ); - - const user = userStore.currentUser; - - const handleCommentUpdate = async (commentId: string, data: Partial) => { - if (!workspaceSlug || !projectId || !issueDetails.id || !user) return; - - await issueCommentService - .patchIssueComment(workspaceSlug as string, projectId as string, issueDetails.id as string, commentId, data) - .then((res) => { - mutateIssueActivity(); - postHogEventTracker( - "COMMENT_UPDATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleCommentDelete = async (commentId: string) => { - if (!workspaceSlug || !projectId || !issueDetails.id || !user) return; - - mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false); - - await issueCommentService - .deleteIssueComment(workspaceSlug as string, projectId as string, issueDetails.id as string, commentId) - .then(() => { - mutateIssueActivity(); - postHogEventTracker( - "COMMENT_DELETED", - { - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleAddComment = async (formData: IIssueActivity) => { - if (!workspaceSlug || !issueDetails || !user) return; - - await issueCommentService - .createIssueComment(workspaceSlug.toString(), issueDetails.project, issueDetails.id, formData) - .then((res) => { - mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id)); - postHogEventTracker( - "COMMENT_ADDED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }) - .catch(() => - setToastAlert({ - type: "error", - title: "Error!", - message: "Comment could not be posted. Please try again.", - }) - ); - }; - - return ( -
-

Comments/Activity

- - -
- ); -}); diff --git a/web/components/inbox/issue-card.tsx b/web/components/inbox/issue-card.tsx deleted file mode 100644 index af0648ca7df..00000000000 --- a/web/components/inbox/issue-card.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useRouter } from "next/router"; -import Link from "next/link"; - -// ui -import { Tooltip, PriorityIcon } from "@plane/ui"; -// icons -import { AlertTriangle, CalendarDays, CheckCircle2, Clock, Copy, XCircle } from "lucide-react"; -// helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; -// types -import { IInboxIssue } from "types"; -// constants -import { INBOX_STATUS } from "constants/inbox"; - -type Props = { - issue: IInboxIssue; - active: boolean; -}; - -export const InboxIssueCard: React.FC = (props) => { - const { issue, active } = props; - - const router = useRouter(); - const { workspaceSlug, projectId, inboxId } = router.query; - - const issueStatus = issue.issue_inbox[0].status; - - return ( - -
-
-

- {issue.project_detail?.identifier}-{issue.sequence_id} -

-
{issue.name}
-
-
- - - - -
- - {renderShortDateWithYearFormat(issue.created_at ?? "")} -
-
-
-
s.value === issueStatus)?.textColor - }`} - > - {issueStatus === -2 ? ( - <> - - Pending - - ) : issueStatus === -1 ? ( - <> - - Declined - - ) : issueStatus === 0 ? ( - <> - - - {new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date() ? "Snoozed date passed" : "Snoozed"} - - - ) : issueStatus === 1 ? ( - <> - - Accepted - - ) : ( - <> - - Duplicate - - )} -
-
- - ); -}; diff --git a/web/components/inbox/issues-list-sidebar.tsx b/web/components/inbox/issues-list-sidebar.tsx deleted file mode 100644 index 3f4ccec4496..00000000000 --- a/web/components/inbox/issues-list-sidebar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { InboxIssueCard, InboxFiltersList } from "components/inbox"; -// ui -import { Loader } from "@plane/ui"; - -export const InboxIssuesListSidebar = observer(() => { - const router = useRouter(); - const { inboxId, inboxIssueId } = router.query; - - const { inboxIssues: inboxIssuesStore } = useMobxStore(); - - const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined; - - return ( -
- - {issuesList ? ( - issuesList.length > 0 ? ( -
- {issuesList.map((issue) => ( - - ))} -
- ) : ( -
- {/* TODO: add filtersLength logic here */} - {/* {filtersLength > 0 && "No issues found for the selected filters. Try changing the filters."} */} -
- ) - ) : ( - - - - - - - )} -
- ); -}); diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx deleted file mode 100644 index 3a0faf248fd..00000000000 --- a/web/components/inbox/main-content.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import Router, { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import useSWR from "swr"; -import { useForm } from "react-hook-form"; -import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle } from "lucide-react"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues"; -import { InboxIssueActivity } from "components/inbox"; -// ui -import { Loader, StateGroupIcon } from "@plane/ui"; -// helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; -// types -import { IInboxIssue, IIssue } from "types"; -import { EUserWorkspaceRoles } from "constants/workspace"; - -const defaultValues: Partial = { - name: "", - description_html: "", - assignees: [], - priority: "low", - target_date: new Date().toString(), - labels: [], -}; - -export const InboxMainContent: React.FC = observer(() => { - const router = useRouter(); - const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; - - // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); - - const { - inboxIssues: inboxIssuesStore, - inboxIssueDetails: inboxIssueDetailsStore, - user: { currentUser, currentProjectRole }, - projectState: { states }, - } = useMobxStore(); - - const { reset, control, watch } = useForm({ - defaultValues, - }); - - useSWR( - workspaceSlug && projectId && inboxId && inboxIssueId ? `INBOX_ISSUE_DETAILS_${inboxIssueId.toString()}` : null, - workspaceSlug && projectId && inboxId && inboxIssueId - ? () => - inboxIssueDetailsStore.fetchIssueDetails( - workspaceSlug.toString(), - projectId.toString(), - inboxId.toString(), - inboxIssueId.toString() - ) - : null - ); - - const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined; - const issueDetails = inboxIssueId ? inboxIssueDetailsStore.issueDetails[inboxIssueId.toString()] : undefined; - const currentIssueState = projectId - ? states[projectId.toString()]?.find((s) => s.id === issueDetails?.state) - : undefined; - - const submitChanges = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return; - - await inboxIssueDetailsStore.updateIssue( - workspaceSlug.toString(), - projectId.toString(), - inboxId.toString(), - issueDetails.issue_inbox[0].id, - formData - ); - }, - [workspaceSlug, inboxIssueId, projectId, inboxId, issueDetails, inboxIssueDetailsStore] - ); - - const onKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!issuesList || !inboxIssueId) return; - - const currentIssueIndex = issuesList.findIndex((issue) => issue.issue_inbox[0].id === inboxIssueId); - - switch (e.key) { - case "ArrowUp": - Router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, - query: { - inboxIssueId: - currentIssueIndex === 0 - ? issuesList[issuesList.length - 1].issue_inbox[0].id - : issuesList[currentIssueIndex - 1].issue_inbox[0].id, - }, - }); - break; - case "ArrowDown": - Router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, - query: { - inboxIssueId: - currentIssueIndex === issuesList.length - 1 - ? issuesList[0].issue_inbox[0].id - : issuesList[currentIssueIndex + 1].issue_inbox[0].id, - }, - }); - break; - default: - break; - } - }, - [workspaceSlug, projectId, inboxIssueId, inboxId, issuesList] - ); - - useEffect(() => { - document.addEventListener("keydown", onKeyDown); - - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [onKeyDown]); - - useEffect(() => { - if (!issueDetails || !inboxIssueId) return; - - reset({ - ...issueDetails, - assignees: issueDetails.assignees ?? (issueDetails.assignee_details ?? []).map((user) => user.id), - labels: issueDetails.labels ?? issueDetails.labels, - }); - }, [issueDetails, reset, inboxIssueId]); - - const issueStatus = issueDetails?.issue_inbox[0].status; - - if (!inboxIssueId) - return ( -
-
-
- - {issuesList && issuesList.length > 0 ? ( - - {issuesList?.length} issues found. Select an issue from the sidebar to view its details. - - ) : ( - No issues found - )} -
-
-
- ); - - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - - return ( - <> - {issueDetails ? ( -
-
- -
- {currentIssueState && ( - - )} - -
-
- setIsSubmitting(value)} - isSubmitting={isSubmitting} - workspaceSlug={workspaceSlug as string} - issue={{ - name: issueDetails.name, - description_html: issueDetails.description_html, - id: issueDetails.id, - }} - handleFormSubmit={submitChanges} - isAllowed={isAllowed || currentUser?.id === issueDetails.created_by} - /> -
- - {workspaceSlug && projectId && ( - - )} - -
- -
- -
-
- ) : ( - -
- - - - -
-
- - - - -
-
- )} - - ); -}); diff --git a/web/components/inbox/modals/accept-issue-modal.tsx b/web/components/inbox/modals/accept-issue-modal.tsx index 376ccbfdddc..5ec63ea8add 100644 --- a/web/components/inbox/modals/accept-issue-modal.tsx +++ b/web/components/inbox/modals/accept-issue-modal.tsx @@ -1,15 +1,15 @@ import React, { useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; - // icons import { CheckCircle } from "lucide-react"; // ui import { Button } from "@plane/ui"; // types -import type { IInboxIssue } from "types"; +import type { TIssue } from "@plane/types"; +import { useProject } from "hooks/store"; type Props = { - data: IInboxIssue; + data: TIssue; isOpen: boolean; onClose: () => void; onSubmit: () => Promise; @@ -17,6 +17,8 @@ type Props = { export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSubmit }) => { const [isAccepting, setIsAccepting] = useState(false); + // hooks + const { getProjectById } = useProject(); const handleClose = () => { setIsAccepting(false); @@ -25,7 +27,6 @@ export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSub const handleAccept = () => { setIsAccepting(true); - onSubmit().finally(() => setIsAccepting(false)); }; @@ -69,7 +70,7 @@ export const AcceptIssueModal: React.FC = ({ isOpen, onClose, data, onSub

Are you sure you want to accept issue{" "} - {data?.project_detail?.identifier}-{data?.sequence_id} + {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} {""}? Once accepted, this issue will be added to the project issues list.

diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index dec274a9d11..e152c1b031a 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -1,36 +1,34 @@ -import React, { useRef, useState } from "react"; +import { Fragment, useRef, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; import { Controller, useForm } from "react-hook-form"; import { RichTextEditorWithRef } from "@plane/rich-text-editor"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { Sparkle } from "lucide-react"; +// hooks +import { useApplication, useWorkspace, useInboxIssues, useMention } from "hooks/store"; +import useToast from "hooks/use-toast"; // services import { FileService } from "services/file.service"; +import { AIService } from "services/ai.service"; // components -import { IssuePrioritySelect } from "components/issues/select"; +import { PriorityDropdown } from "components/dropdowns"; +import { GptAssistantPopover } from "components/core"; // ui import { Button, Input, ToggleSwitch } from "@plane/ui"; // types -import { IIssue } from "types"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; -import { GptAssistantModal } from "components/core"; -import { Sparkle } from "lucide-react"; -import useToast from "hooks/use-toast"; -import { AIService } from "services/ai.service"; +import { TIssue } from "@plane/types"; type Props = { isOpen: boolean; onClose: () => void; }; -const defaultValues: Partial = { - project: "", +const defaultValues: Partial = { + project_id: "", name: "", description_html: "

", - parent: null, + parent_id: null, priority: "none", }; @@ -40,30 +38,34 @@ const fileService = new FileService(); export const CreateInboxIssueModal: React.FC = observer((props) => { const { isOpen, onClose } = props; - // states const [createMore, setCreateMore] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - + // refs const editorRef = useRef(null); - + // toast alert const { setToastAlert } = useToast(); - const editorSuggestion = useEditorSuggestions(); - + const { mentionHighlights, mentionSuggestions } = useMention(); + // router const router = useRouter(); const { workspaceSlug, projectId, inboxId } = router.query as { workspaceSlug: string; projectId: string; inboxId: string; }; + const workspaceStore = useWorkspace(); + const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; + // store hooks const { - inboxIssueDetails: inboxIssueDetailsStore, - trackEvent: { postHogEventTracker }, - appConfig: { envConfig }, - workspace: { currentWorkspace }, - } = useMobxStore(); + issues: { createInboxIssue }, + } = useInboxIssues(); + const { + config: { envConfig }, + eventTracker: { postHogEventTracker }, + } = useApplication(); + const { currentWorkspace } = useWorkspace(); const { control, @@ -82,14 +84,13 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const issueName = watch("name"); - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial) => { if (!workspaceSlug || !projectId || !inboxId) return; - await inboxIssueDetailsStore - .createIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData) + await createInboxIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), formData) .then((res) => { if (!createMore) { - router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}`); + router.push(`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.id}`); handleClose(); } else reset(defaultValues); postHogEventTracker( @@ -101,12 +102,12 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { { isGrouping: true, groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, + groupId: currentWorkspace?.id!, } ); }) .catch((error) => { - console.log(error); + console.error(error); postHogEventTracker( "ISSUE_CREATED", { @@ -115,7 +116,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { { isGrouping: true, groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, + groupId: currentWorkspace?.id!, } ); }); @@ -124,7 +125,7 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const handleAiAssistance = async (response: string) => { if (!workspaceSlug || !projectId) return; - setValue("description", {}); + // setValue("description", {}); setValue("description_html", `${watch("description_html")}

${response}

`); editorRef.current?.setEditorValue(`${watch("description_html")}`); }; @@ -169,10 +170,10 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { }; return ( - + = observer((props) => {
= observer((props) => { />
-
+
{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" + /> + )}
= observer((props) => {

" : value} @@ -271,28 +291,11 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { onChange={(description, description_html: string) => { onChange(description_html); }} - mentionSuggestions={editorSuggestion.mentionSuggestions} - mentionHighlights={editorSuggestion.mentionHighlights} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} /> )} /> - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> - )}
@@ -300,7 +303,13 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { control={control} name="priority" render={({ field: { value, onChange } }) => ( - +
+ +
)} />
diff --git a/web/components/inbox/modals/decline-issue-modal.tsx b/web/components/inbox/modals/decline-issue-modal.tsx index 5267f747b24..a69c8d0e1c5 100644 --- a/web/components/inbox/modals/decline-issue-modal.tsx +++ b/web/components/inbox/modals/decline-issue-modal.tsx @@ -1,15 +1,15 @@ import React, { useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; - // icons import { AlertTriangle } from "lucide-react"; // ui import { Button } from "@plane/ui"; // types -import type { IInboxIssue } from "types"; +import type { TIssue } from "@plane/types"; +import { useProject } from "hooks/store"; type Props = { - data: IInboxIssue; + data: TIssue; isOpen: boolean; onClose: () => void; onSubmit: () => Promise; @@ -17,6 +17,8 @@ type Props = { export const DeclineIssueModal: React.FC = ({ isOpen, onClose, data, onSubmit }) => { const [isDeclining, setIsDeclining] = useState(false); + // hooks + const { getProjectById } = useProject(); const handleClose = () => { setIsDeclining(false); @@ -25,7 +27,6 @@ export const DeclineIssueModal: React.FC = ({ isOpen, onClose, data, onSu const handleDecline = () => { setIsDeclining(true); - onSubmit().finally(() => setIsDeclining(false)); }; @@ -69,7 +70,7 @@ export const DeclineIssueModal: React.FC = ({ isOpen, onClose, data, onSu

Are you sure you want to decline issue{" "} - {data?.project_detail?.identifier}-{data?.sequence_id} + {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} {""}? This action cannot be undone.

diff --git a/web/components/inbox/modals/delete-issue-modal.tsx b/web/components/inbox/modals/delete-issue-modal.tsx index 01a3cf643d9..c06621c034a 100644 --- a/web/components/inbox/modals/delete-issue-modal.tsx +++ b/web/components/inbox/modals/delete-issue-modal.tsx @@ -1,38 +1,27 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // hooks -import useToast from "hooks/use-toast"; +import { useProject } from "hooks/store"; // icons import { AlertTriangle } from "lucide-react"; // ui import { Button } from "@plane/ui"; // types -import type { IInboxIssue } from "types"; +import type { TIssue } from "@plane/types"; type Props = { - data: IInboxIssue; + data: TIssue; isOpen: boolean; onClose: () => void; + onSubmit: () => Promise; }; -export const DeleteInboxIssueModal: React.FC = observer(({ isOpen, onClose, data }) => { +export const DeleteInboxIssueModal: React.FC = observer(({ isOpen, onClose, onSubmit, data }) => { + // states const [isDeleting, setIsDeleting] = useState(false); - const router = useRouter(); - const { workspaceSlug, projectId, inboxId } = router.query; - - const { - inboxIssueDetails: inboxIssueDetailsStore, - trackEvent: { postHogEventTracker }, - workspace: { currentWorkspace }, - } = useMobxStore(); - - const { setToastAlert } = useToast(); + const { getProjectById } = useProject(); const handleClose = () => { setIsDeleting(false); @@ -40,60 +29,13 @@ export const DeleteInboxIssueModal: React.FC = observer(({ isOpen, onClos }; const handleDelete = () => { - if (!workspaceSlug || !projectId || !inboxId) return; - setIsDeleting(true); - - inboxIssueDetailsStore - .deleteIssue(workspaceSlug.toString(), projectId.toString(), inboxId.toString(), data.issue_inbox[0].id) - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue deleted successfully.", - }); - postHogEventTracker( - "ISSUE_DELETED", - { - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - // remove inboxIssueId from the url - router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, - }); - - handleClose(); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be deleted. Please try again.", - }); - postHogEventTracker( - "ISSUE_DELETED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }) - .finally(() => setIsDeleting(false)); + onSubmit().finally(() => setIsDeleting(false)); }; return ( - + = observer(({ isOpen, onClos

Are you sure you want to delete issue{" "} - {data?.project_detail?.identifier}-{data?.sequence_id} + {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} {""}? The issue will only be deleted from the inbox and this action cannot be undone.

-
@@ -105,11 +105,11 @@ export const JiraGetImportDetail: React.FC = observer(() => { name="metadata.email" rules={{ required: "Please enter email address.", + validate: (value) => checkEmailValidity(value) || "Please enter a valid email address", }} render={({ field: { value, onChange, ref } }) => ( { /> )} /> + {errors.metadata?.email &&

{errors.metadata.email.message}

}
@@ -134,12 +135,11 @@ export const JiraGetImportDetail: React.FC = observer(() => { name="metadata.cloud_hostname" rules={{ required: "Please enter your cloud host name.", + validate: (value) => !/^https?:\/\//.test(value) || "Hostname should not begin with http:// or https://", }} render={({ field: { value, onChange, ref } }) => ( { /> )} /> + {errors.metadata?.cloud_hostname && ( +

{errors.metadata.cloud_hostname.message}

+ )}
@@ -166,24 +169,30 @@ export const JiraGetImportDetail: React.FC = observer(() => { - {value && value !== "" ? ( - projects?.find((p) => p.id === value)?.name + {value && value.trim() !== "" ? ( + getProjectById(value)?.name ) : ( Select a project )} } + optionsClassName="w-full" > - {projects && projects.length > 0 ? ( - projects.map((project) => ( - - {project.name} - - )) + {workspaceProjectIds && workspaceProjectIds.length > 0 ? ( + workspaceProjectIds.map((projectId) => { + const projectDetails = getProjectById(projectId); + + if (!projectDetails) return; + + return ( + + {projectDetails.name} + + ); + }) ) : (

You don{"'"}t have any project. Please create a project first.

diff --git a/web/components/integration/jira/import-users.tsx b/web/components/integration/jira/import-users.tsx index 93e0e0ec039..63cf84b4f7d 100644 --- a/web/components/integration/jira/import-users.tsx +++ b/web/components/integration/jira/import-users.tsx @@ -7,7 +7,7 @@ import { WorkspaceService } from "services/workspace.service"; // ui import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui"; // types -import { IJiraImporterForm } from "types"; +import { IJiraImporterForm } from "@plane/types"; // fetch keys import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; @@ -82,7 +82,7 @@ export const JiraImportUsers: FC = () => { input value={value} onChange={onChange} - width="w-full" + optionsClassName="w-full" label={{Boolean(value) ? value : ("Ignore" as any)}} > Invite by email diff --git a/web/components/integration/jira/index.ts b/web/components/integration/jira/index.ts index 321e4f31382..e3ba5d68556 100644 --- a/web/components/integration/jira/index.ts +++ b/web/components/integration/jira/index.ts @@ -4,7 +4,7 @@ export * from "./jira-project-detail"; export * from "./import-users"; export * from "./confirm-import"; -import { IJiraImporterForm } from "types"; +import { IJiraImporterForm } from "@plane/types"; export type TJiraIntegrationSteps = | "import-configure" diff --git a/web/components/integration/jira/jira-project-detail.tsx b/web/components/integration/jira/jira-project-detail.tsx index ac8eb3e904d..9e3166563d3 100644 --- a/web/components/integration/jira/jira-project-detail.tsx +++ b/web/components/integration/jira/jira-project-detail.tsx @@ -15,7 +15,7 @@ import { JiraImporterService } from "services/integrations"; // fetch keys import { JIRA_IMPORTER_DETAIL } from "constants/fetch-keys"; -import { IJiraImporterForm, IJiraMetadata } from "types"; +import { IJiraImporterForm, IJiraMetadata } from "@plane/types"; // components import { ToggleSwitch, Spinner } from "@plane/ui"; diff --git a/web/components/integration/jira/root.tsx b/web/components/integration/jira/root.tsx index e9816ae248a..3c610d03f27 100644 --- a/web/components/integration/jira/root.tsx +++ b/web/components/integration/jira/root.tsx @@ -24,7 +24,7 @@ import { // assets import JiraLogo from "public/services/jira.svg"; // types -import { IUser, IJiraImporterForm } from "types"; +import { IJiraImporterForm } from "@plane/types"; const integrationWorkflowData: Array<{ title: string; @@ -53,14 +53,10 @@ const integrationWorkflowData: Array<{ }, ]; -type Props = { - user: IUser | undefined; -}; - // services const jiraImporterService = new JiraImporterService(); -export const JiraImporterRoot: React.FC = () => { +export const JiraImporterRoot: React.FC = () => { const [currentStep, setCurrentStep] = useState({ state: "import-configure", }); @@ -87,7 +83,7 @@ export const JiraImporterRoot: React.FC = () => { router.push(`/${workspaceSlug}/settings/imports`); }) .catch((err) => { - console.log(err); + console.error(err); }); }; diff --git a/web/components/integration/single-import.tsx b/web/components/integration/single-import.tsx index 433747f31cf..f7bd0f5fa6c 100644 --- a/web/components/integration/single-import.tsx +++ b/web/components/integration/single-import.tsx @@ -3,9 +3,9 @@ import { CustomMenu } from "@plane/ui"; // icons import { Trash2 } from "lucide-react"; // helpers -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { IImporterService } from "types"; +import { IImporterService } from "@plane/types"; // constants import { IMPORTERS_LIST } from "constants/workspace"; @@ -29,17 +29,17 @@ export const SingleImport: React.FC = ({ service, refreshing, handleDelet service.status === "completed" ? "bg-green-500/20 text-green-500" : service.status === "processing" - ? "bg-yellow-500/20 text-yellow-500" - : service.status === "failed" - ? "bg-red-500/20 text-red-500" - : "" + ? "bg-yellow-500/20 text-yellow-500" + : service.status === "failed" + ? "bg-red-500/20 text-red-500" + : "" }`} > {refreshing ? "Refreshing..." : service.status}
- {renderShortDateWithYearFormat(service.created_at)}| + {renderFormattedDate(service.created_at)}| Imported by {service.initiated_by_detail.display_name}
diff --git a/web/components/integration/single-integration-card.tsx b/web/components/integration/single-integration-card.tsx index e07f580e7aa..70bbb5fa452 100644 --- a/web/components/integration/single-integration-card.tsx +++ b/web/components/integration/single-integration-card.tsx @@ -2,12 +2,12 @@ import { useState } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; - +import { observer } from "mobx-react-lite"; import useSWR, { mutate } from "swr"; - // services import { IntegrationService } from "services/integrations"; // hooks +import { useApplication, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; import useIntegrationPopup from "hooks/use-integration-popup"; // ui @@ -17,11 +17,9 @@ import GithubLogo from "public/services/github.png"; import SlackLogo from "public/services/slack.png"; import { CheckCircle } from "lucide-react"; // types -import { IAppIntegration, IWorkspaceIntegration } from "types"; +import { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; // fetch-keys import { WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; type Props = { integration: IAppIntegration; @@ -44,20 +42,23 @@ const integrationDetails: { [key: string]: any } = { const integrationService = new IntegrationService(); export const SingleIntegrationCard: React.FC = observer(({ integration }) => { - const { - appConfig: { envConfig }, - user: { currentWorkspaceRole }, - } = useMobxStore(); - - const isUserAdmin = currentWorkspaceRole === 20; - + // states const [deletingIntegration, setDeletingIntegration] = useState(false); - + // router const router = useRouter(); const { workspaceSlug } = router.query; - + // store hooks + const { + config: { envConfig }, + } = useApplication(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // toast alert const { setToastAlert } = useToast(); + const isUserAdmin = currentWorkspaceRole === 20; + const { startAuth, isConnecting: isInstalling } = useIntegrationPopup({ provider: integration.provider, github_app_name: envConfig?.github_app_name || "", @@ -139,7 +140,7 @@ export const SingleIntegrationCard: React.FC = observer(({ integration }) variant="danger" onClick={() => { if (!isUserAdmin) return; - handleRemoveIntegration; + handleRemoveIntegration(); }} disabled={!isUserAdmin} loading={deletingIntegration} diff --git a/web/components/integration/slack/select-channel.tsx b/web/components/integration/slack/select-channel.tsx index a746569fec2..57fb8231986 100644 --- a/web/components/integration/slack/select-channel.tsx +++ b/web/components/integration/slack/select-channel.tsx @@ -2,18 +2,17 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; import { observer } from "mobx-react-lite"; +// hooks +import { useApplication } from "hooks/store"; +import useIntegrationPopup from "hooks/use-integration-popup"; // services import { AppInstallationService } from "services/app_installation.service"; // ui import { Loader } from "@plane/ui"; -// hooks -import useIntegrationPopup from "hooks/use-integration-popup"; // types -import { IWorkspaceIntegration, ISlackIntegration } from "types"; +import { IWorkspaceIntegration, ISlackIntegration } from "@plane/types"; // fetch-keys import { SLACK_CHANNEL_INFO } from "constants/fetch-keys"; -// lib -import { useMobxStore } from "lib/mobx/store-provider"; type Props = { integration: IWorkspaceIntegration; @@ -22,10 +21,10 @@ type Props = { const appInstallationService = new AppInstallationService(); export const SelectChannel: React.FC = observer(({ integration }) => { - // store + // store hooks const { - appConfig: { envConfig }, - } = useMobxStore(); + config: { envConfig }, + } = useApplication(); // states const [slackChannelAvailabilityToggle, setSlackChannelAvailabilityToggle] = useState(false); const [slackChannel, setSlackChannel] = useState(null); @@ -67,8 +66,9 @@ export const SelectChannel: React.FC = observer(({ integration }) => { }, [projectIntegration, projectId]); const handleDelete = async () => { + if (!workspaceSlug || !projectId) return; if (projectIntegration.length === 0) return; - mutate(SLACK_CHANNEL_INFO, (prevData: any) => { + mutate(SLACK_CHANNEL_INFO(workspaceSlug?.toString(), projectId?.toString()), (prevData: any) => { if (!prevData) return; return prevData.id !== integration.id; }).then(() => { @@ -77,7 +77,7 @@ export const SelectChannel: React.FC = observer(({ integration }) => { }); appInstallationService .removeSlackChannel(workspaceSlug as string, projectId as string, integration.id as string, slackChannel?.id) - .catch((err) => console.log(err)); + .catch((err) => console.error(err)); }; const handleAuth = async () => { diff --git a/web/components/issues/activity.tsx b/web/components/issues/activity.tsx deleted file mode 100644 index b2831ab66fd..00000000000 --- a/web/components/issues/activity.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -// components -import { ActivityIcon, ActivityMessage } from "components/core"; -import { CommentCard } from "components/issues/comment"; -// ui -import { Loader, Tooltip } from "@plane/ui"; -// helpers -import { render24HourFormatTime, renderLongDateFormat, timeAgo } from "helpers/date-time.helper"; -// types -import { IIssueActivity } from "types"; -import { History } from "lucide-react"; - -type Props = { - activity: IIssueActivity[] | undefined; - handleCommentUpdate: (commentId: string, data: Partial) => Promise; - handleCommentDelete: (commentId: string) => Promise; - showAccessSpecifier?: boolean; -}; - -export const IssueActivitySection: React.FC = ({ - activity, - handleCommentUpdate, - handleCommentDelete, - showAccessSpecifier = false, -}) => { - const router = useRouter(); - const { workspaceSlug } = router.query; - - if (!activity) - return ( - -
- - -
-
- - -
-
- - -
-
- ); - - return ( -
-
    - {activity.map((activityItem, index) => { - // determines what type of action is performed - const message = activityItem.field ? : "created the issue."; - - if ("field" in activityItem && activityItem.field !== "updated_by") { - return ( -
  • -
    - {activity.length > 1 && index !== activity.length - 1 ? ( -
    -
  • - ); - } else if ("comment_json" in activityItem) - return ( -
    - -
    - ); - })} -
-
- ); -}; diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx new file mode 100644 index 00000000000..0d345a6191f --- /dev/null +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -0,0 +1,93 @@ +import { FC, useState } from "react"; +import Link from "next/link"; +import { AlertCircle, X } from "lucide-react"; +// hooks +import { useIssueDetail, useMember } from "hooks/store"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal"; +// icons +import { getFileIcon } from "components/icons"; +// helper +import { truncateText } from "helpers/string.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; +import { convertBytesToSize, getFileExtension, getFileName } from "helpers/attachment.helper"; +// types +import { TAttachmentOperations } from "./root"; + +type TAttachmentOperationsRemoveModal = Exclude; + +type TIssueAttachmentsDetail = { + attachmentId: string; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; +}; + +export const IssueAttachmentsDetail: FC = (props) => { + // props + const { attachmentId, handleAttachmentOperations, disabled } = props; + // store hooks + const { getUserDetails } = useMember(); + const { + attachment: { getAttachmentById }, + } = useIssueDetail(); + // states + const [attachmentDeleteModal, setAttachmentDeleteModal] = useState(false); + + const attachment = attachmentId && getAttachmentById(attachmentId); + + if (!attachment) return <>; + return ( + <> + + +
+ +
+
{getFileIcon(getFileExtension(attachment.asset))}
+
+
+ + {truncateText(`${getFileName(attachment.attributes.name)}`, 10)} + + + + + + +
+ +
+ {getFileExtension(attachment.asset).toUpperCase()} + {convertBytesToSize(attachment.attributes.size)} +
+
+
+ + + {!disabled && ( + + )} +
+ + ); +}; diff --git a/web/components/issues/attachment/attachment-upload.tsx b/web/components/issues/attachment/attachment-upload.tsx index c1b323e7429..bf197980aa1 100644 --- a/web/components/issues/attachment/attachment-upload.tsx +++ b/web/components/issues/attachment/attachment-upload.tsx @@ -1,79 +1,48 @@ import { useCallback, useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; import { useDropzone } from "react-dropzone"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import { IssueAttachmentService } from "services/issue"; // hooks -import useToast from "hooks/use-toast"; -// types -import { IIssueAttachment } from "types"; -// fetch-keys -import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { useApplication } from "hooks/store"; // constants import { MAX_FILE_SIZE } from "constants/common"; +// helpers +import { generateFileName } from "helpers/attachment.helper"; +// types +import { TAttachmentOperations } from "./root"; + +type TAttachmentOperationsModal = Exclude; type Props = { + workspaceSlug: string; disabled?: boolean; + handleAttachmentOperations: TAttachmentOperationsModal; }; -const issueAttachmentService = new IssueAttachmentService(); - export const IssueAttachmentUpload: React.FC = observer((props) => { - const { disabled = false } = props; + const { workspaceSlug, disabled = false, handleAttachmentOperations } = props; + // store hooks + const { + config: { envConfig }, + } = useApplication(); // states const [isLoading, setIsLoading] = useState(false); - // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; - - const { setToastAlert } = useToast(); - - const { - appConfig: { envConfig }, - } = useMobxStore(); const onDrop = useCallback((acceptedFiles: File[]) => { - if (!acceptedFiles[0] || !workspaceSlug) return; + const currentFile: File = acceptedFiles[0]; + if (!currentFile || !workspaceSlug) return; + const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { type: currentFile.type }); const formData = new FormData(); - formData.append("asset", acceptedFiles[0]); + formData.append("asset", uploadedFile); formData.append( "attributes", JSON.stringify({ - name: acceptedFiles[0].name, - size: acceptedFiles[0].size, + name: uploadedFile.name, + size: uploadedFile.size, }) ); setIsLoading(true); - - issueAttachmentService - .uploadIssueAttachment(workspaceSlug as string, projectId as string, issueId as string, formData) - .then((res) => { - mutate( - ISSUE_ATTACHMENTS(issueId as string), - (prevData) => [res, ...(prevData ?? [])], - false - ); - mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); - setToastAlert({ - type: "success", - title: "Success!", - message: "File added successfully.", - }); - setIsLoading(false); - }) - .catch(() => { - setIsLoading(false); - setToastAlert({ - type: "error", - title: "error!", - message: "Something went wrong. please check file type & size (max 5 MB)", - }); - }); + handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/web/components/issues/attachment/attachments-list.tsx b/web/components/issues/attachment/attachments-list.tsx new file mode 100644 index 00000000000..2129a4f61b6 --- /dev/null +++ b/web/components/issues/attachment/attachments-list.tsx @@ -0,0 +1,42 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueAttachmentsDetail } from "./attachment-detail"; +// types +import { TAttachmentOperations } from "./root"; + +type TAttachmentOperationsRemoveModal = Exclude; + +type TIssueAttachmentsList = { + issueId: string; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; + disabled?: boolean; +}; + +export const IssueAttachmentsList: FC = observer((props) => { + const { issueId, handleAttachmentOperations, disabled } = props; + // store hooks + const { + attachment: { getAttachmentsByIssueId }, + } = useIssueDetail(); + + const issueAttachments = getAttachmentsByIssueId(issueId); + + if (!issueAttachments) return <>; + + return ( + <> + {issueAttachments && + issueAttachments.length > 0 && + issueAttachments.map((attachmentId) => ( + + ))} + + ); +}); diff --git a/web/components/issues/attachment/attachments.tsx b/web/components/issues/attachment/attachments.tsx deleted file mode 100644 index 1b491557948..00000000000 --- a/web/components/issues/attachment/attachments.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// ui -import { Tooltip } from "@plane/ui"; -import { DeleteAttachmentModal } from "./delete-attachment-modal"; -// icons -import { getFileIcon } from "components/icons"; -import { AlertCircle, X } from "lucide-react"; -// services -import { IssueAttachmentService } from "services/issue"; -import { ProjectMemberService } from "services/project"; -// fetch-key -import { ISSUE_ATTACHMENTS, PROJECT_MEMBERS } from "constants/fetch-keys"; -// helper -import { truncateText } from "helpers/string.helper"; -import { renderLongDateFormat } from "helpers/date-time.helper"; -import { convertBytesToSize, getFileExtension, getFileName } from "helpers/attachment.helper"; -// type -import { IIssueAttachment } from "types"; - -// services -const issueAttachmentService = new IssueAttachmentService(); -const projectMemberService = new ProjectMemberService(); - -type Props = { - editable: boolean; -}; - -export const IssueAttachments: React.FC = (props) => { - const { editable } = props; - - // states - const [deleteAttachment, setDeleteAttachment] = useState(null); - const [attachmentDeleteModal, setAttachmentDeleteModal] = useState(false); - - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; - - const { data: attachments } = useSWR( - workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null, - workspaceSlug && projectId && issueId - ? () => issueAttachmentService.getIssueAttachment(workspaceSlug as string, projectId as string, issueId as string) - : null - ); - - const { data: people } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectMemberService.fetchProjectMembers(workspaceSlug as string, projectId as string) - : null - ); - - return ( - <> - - {attachments && - attachments.length > 0 && - attachments.map((file) => ( -
- -
-
{getFileIcon(getFileExtension(file.asset))}
-
-
- - {truncateText(`${getFileName(file.attributes.name)}`, 10)} - - person.member.id === file.updated_by)?.member.display_name ?? "" - } uploaded on ${renderLongDateFormat(file.updated_at)}`} - > - - - - -
- -
- {getFileExtension(file.asset).toUpperCase()} - {convertBytesToSize(file.attributes.size)} -
-
-
- - - {editable && ( - - )} -
- ))} - - ); -}; diff --git a/web/components/issues/attachment/delete-attachment-modal.tsx b/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx similarity index 69% rename from web/components/issues/attachment/delete-attachment-modal.tsx rename to web/components/issues/attachment/delete-attachment-confirmation-modal.tsx index d4f39145975..e01d2828e48 100644 --- a/web/components/issues/attachment/delete-attachment-modal.tsx +++ b/web/components/issues/attachment/delete-attachment-confirmation-modal.tsx @@ -1,72 +1,45 @@ -import React from "react"; - -import { useRouter } from "next/router"; - -import { mutate } from "swr"; - +import { FC, Fragment, Dispatch, SetStateAction, useState } from "react"; +import { AlertTriangle } from "lucide-react"; // headless ui import { Dialog, Transition } from "@headlessui/react"; -// services -import { IssueAttachmentService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; // ui import { Button } from "@plane/ui"; -// icons -import { AlertTriangle } from "lucide-react"; // helper import { getFileName } from "helpers/attachment.helper"; // types -import type { IIssueAttachment } from "types"; -// fetch-keys -import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import type { TIssueAttachment } from "@plane/types"; +import { TAttachmentOperations } from "./root"; + +export type TAttachmentOperationsRemoveModal = Exclude; type Props = { isOpen: boolean; - setIsOpen: React.Dispatch>; - data: IIssueAttachment | null; + setIsOpen: Dispatch>; + data: TIssueAttachment; + handleAttachmentOperations: TAttachmentOperationsRemoveModal; }; -// services -const issueAttachmentService = new IssueAttachmentService(); - -export const DeleteAttachmentModal: React.FC = ({ isOpen, setIsOpen, data }) => { - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; - - const { setToastAlert } = useToast(); +export const IssueAttachmentDeleteModal: FC = (props) => { + const { isOpen, setIsOpen, data, handleAttachmentOperations } = props; + // state + const [loader, setLoader] = useState(false); const handleClose = () => { setIsOpen(false); + setLoader(false); }; const handleDeletion = async (assetId: string) => { - if (!workspaceSlug || !projectId || !data) return; - - mutate( - ISSUE_ATTACHMENTS(issueId as string), - (prevData) => (prevData ?? [])?.filter((p) => p.id !== assetId), - false - ); - - await issueAttachmentService - .deleteIssueAttachment(workspaceSlug as string, projectId as string, issueId as string, assetId as string) - .then(() => mutate(PROJECT_ISSUES_ACTIVITY(issueId as string))) - .catch(() => { - setToastAlert({ - type: "error", - title: "error!", - message: "Something went wrong please try again.", - }); - }); + setLoader(true); + handleAttachmentOperations.remove(assetId).finally(() => handleClose()); }; return ( data && ( - + = ({ isOpen, setIsOpen, data
= ({ isOpen, setIsOpen, data tabIndex={1} onClick={() => { handleDeletion(data.id); - handleClose(); }} + disabled={loader} > - Delete + {loader ? "Deleting..." : "Delete"}
diff --git a/web/components/issues/attachment/index.ts b/web/components/issues/attachment/index.ts index 9546de31e7e..d4385e7da7c 100644 --- a/web/components/issues/attachment/index.ts +++ b/web/components/issues/attachment/index.ts @@ -1,3 +1,7 @@ +export * from "./root"; + export * from "./attachment-upload"; -export * from "./attachments"; -export * from "./delete-attachment-modal"; +export * from "./delete-attachment-confirmation-modal"; + +export * from "./attachments-list"; +export * from "./attachment-detail"; diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx new file mode 100644 index 00000000000..79a6dc8408e --- /dev/null +++ b/web/components/issues/attachment/root.tsx @@ -0,0 +1,85 @@ +import { FC, useMemo } from "react"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { IssueAttachmentUpload } from "./attachment-upload"; +import { IssueAttachmentsList } from "./attachments-list"; + +export type TIssueAttachmentRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled?: boolean; +}; + +export type TAttachmentOperations = { + create: (data: FormData) => Promise; + remove: (linkId: string) => Promise; +}; + +export const IssueAttachmentRoot: FC = (props) => { + // props + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // hooks + const { createAttachment, removeAttachment } = useIssueDetail(); + const { setToastAlert } = useToast(); + + const handleAttachmentOperations: TAttachmentOperations = useMemo( + () => ({ + create: async (data: FormData) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await createAttachment(workspaceSlug, projectId, issueId, data); + setToastAlert({ + message: "The attachment has been successfully uploaded", + type: "success", + title: "Attachment uploaded", + }); + } catch (error) { + setToastAlert({ + message: "The attachment could not be uploaded", + type: "error", + title: "Attachment not uploaded", + }); + } + }, + remove: async (attachmentId: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); + setToastAlert({ + message: "The attachment has been successfully removed", + type: "success", + title: "Attachment removed", + }); + } catch (error) { + setToastAlert({ + message: "The Attachment could not be removed", + type: "error", + title: "Attachment not removed", + }); + } + }, + }), + [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, setToastAlert] + ); + + return ( +
+

Attachments

+
+ + +
+
+ ); +}; diff --git a/web/components/issues/comment/add-comment.tsx b/web/components/issues/comment/add-comment.tsx deleted file mode 100644 index 658e825bff1..00000000000 --- a/web/components/issues/comment/add-comment.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from "react"; -import { useRouter } from "next/router"; -import { useForm, Controller } from "react-hook-form"; - -// services -import { FileService } from "services/file.service"; -// components -import { LiteTextEditorWithRef } from "@plane/lite-text-editor"; -// ui -import { Button } from "@plane/ui"; -import { Globe2, Lock } from "lucide-react"; - -// types -import type { IIssueActivity } from "types"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; - -const defaultValues: Partial = { - access: "INTERNAL", - comment_html: "", -}; - -type Props = { - disabled?: boolean; - onSubmit: (data: IIssueActivity) => Promise; - showAccessSpecifier?: boolean; -}; - -type commentAccessType = { - icon: any; - key: string; - label: "Private" | "Public"; -}; -const commentAccess: commentAccessType[] = [ - { - icon: Lock, - key: "INTERNAL", - label: "Private", - }, - { - icon: Globe2, - key: "EXTERNAL", - label: "Public", - }, -]; - -// services -const fileService = new FileService(); - -export const AddComment: React.FC = ({ disabled = false, onSubmit, showAccessSpecifier = false }) => { - const editorRef = React.useRef(null); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const editorSuggestions = useEditorSuggestions(); - - const { - control, - formState: { isSubmitting }, - handleSubmit, - reset, - } = useForm({ defaultValues }); - - const handleAddComment = async (formData: IIssueActivity) => { - if (!formData.comment_html || isSubmitting) return; - - await onSubmit(formData).then(() => { - reset(defaultValues); - editorRef.current?.clearEditor(); - }); - }; - - return ( -
- -
- ( - ( -

" : commentValue} - customClassName="p-2 h-full" - editorContentCustomClassNames="min-h-[35px]" - debouncedUpdatesEnabled={false} - onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)} - commentAccessSpecifier={ - showAccessSpecifier - ? { accessValue: accessValue ?? "INTERNAL", onAccessChange, showAccessSpecifier, commentAccess } - : undefined - } - mentionSuggestions={editorSuggestions.mentionSuggestions} - mentionHighlights={editorSuggestions.mentionHighlights} - submitButton={ - - } - /> - )} - /> - )} - /> -
- -
- ); -}; diff --git a/web/components/issues/comment/comment-card.tsx b/web/components/issues/comment/comment-card.tsx deleted file mode 100644 index 09f29da739a..00000000000 --- a/web/components/issues/comment/comment-card.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; - -// services -import { FileService } from "services/file.service"; -// icons -import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react"; -// hooks -import useUser from "hooks/use-user"; -// ui -import { CustomMenu } from "@plane/ui"; -import { CommentReaction } from "components/issues"; -import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor"; -// helpers -import { timeAgo } from "helpers/date-time.helper"; -// types -import type { IIssueActivity } from "types"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; - -// services -const fileService = new FileService(); - -type Props = { - comment: IIssueActivity; - handleCommentDeletion: (comment: string) => void; - onSubmit: (commentId: string, data: Partial) => void; - showAccessSpecifier?: boolean; - workspaceSlug: string; -}; - -export const CommentCard: React.FC = ({ - comment, - handleCommentDeletion, - onSubmit, - showAccessSpecifier = false, - workspaceSlug, -}) => { - const { user } = useUser(); - - const editorRef = React.useRef(null); - const showEditorRef = React.useRef(null); - - const editorSuggestions = useEditorSuggestions(); - - const [isEditing, setIsEditing] = useState(false); - - const { - formState: { isSubmitting }, - handleSubmit, - setFocus, - watch, - setValue, - } = useForm({ - defaultValues: comment, - }); - - const onEnter = (formData: Partial) => { - if (isSubmitting) return; - setIsEditing(false); - - onSubmit(comment.id, formData); - - editorRef.current?.setEditorValue(formData.comment_html); - showEditorRef.current?.setEditorValue(formData.comment_html); - }; - - useEffect(() => { - isEditing && setFocus("comment"); - }, [isEditing, setFocus]); - - return ( -
-
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( - { - ) : ( -
- {comment.actor_detail.is_bot - ? comment.actor_detail.first_name.charAt(0) - : comment.actor_detail.display_name.charAt(0)} -
- )} - - - -
-
-
-
- {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name} -
-

commented {timeAgo(comment.created_at)}

-
-
-
-
- setValue("comment_html", comment_html)} - mentionSuggestions={editorSuggestions.mentionSuggestions} - mentionHighlights={editorSuggestions.mentionHighlights} - /> -
-
- - -
-
-
- {showAccessSpecifier && ( -
- {comment.access === "INTERNAL" ? : } -
- )} - - -
-
-
- {user?.id === comment.actor && ( - - setIsEditing(true)} className="flex items-center gap-1"> - - Edit comment - - {showAccessSpecifier && ( - <> - {comment.access === "INTERNAL" ? ( - onSubmit(comment.id, { access: "EXTERNAL" })} - className="flex items-center gap-1" - > - - Switch to public comment - - ) : ( - onSubmit(comment.id, { access: "INTERNAL" })} - className="flex items-center gap-1" - > - - Switch to private comment - - )} - - )} - { - handleCommentDeletion(comment.id); - }} - className="flex items-center gap-1" - > - - Delete comment - - - )} -
- ); -}; diff --git a/web/components/issues/comment/comment-reaction.tsx b/web/components/issues/comment/comment-reaction.tsx deleted file mode 100644 index c920caeba74..00000000000 --- a/web/components/issues/comment/comment-reaction.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { FC } from "react"; -import { useRouter } from "next/router"; -// hooks -import useUser from "hooks/use-user"; -import useCommentReaction from "hooks/use-comment-reaction"; -// ui -import { ReactionSelector } from "components/core"; -// helper -import { renderEmoji } from "helpers/emoji.helper"; -import { IssueCommentReaction } from "types"; - -type Props = { - projectId?: string | string[]; - commentId: string; - readonly?: boolean; -}; - -export const CommentReaction: FC = (props) => { - const { projectId, commentId, readonly = false } = props; - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { user } = useUser(); - - const { commentReactions, groupedReactions, handleReactionCreate, handleReactionDelete } = useCommentReaction( - workspaceSlug, - projectId, - commentId - ); - - const handleReactionClick = (reaction: string) => { - if (!workspaceSlug || !projectId || !commentId) return; - - const isSelected = commentReactions?.some( - (r: IssueCommentReaction) => r.actor === user?.id && r.reaction === reaction - ); - - if (isSelected) { - handleReactionDelete(reaction); - } else { - handleReactionCreate(reaction); - } - }; - - return ( -
- {!readonly && ( - reaction.actor === user?.id) - .map((r: IssueCommentReaction) => r.reaction) || [] - } - onSelect={handleReactionClick} - /> - )} - - {Object.keys(groupedReactions || {}).map( - (reaction) => - groupedReactions?.[reaction]?.length && - groupedReactions[reaction].length > 0 && ( - - ) - )} -
- ); -}; diff --git a/web/components/issues/comment/index.ts b/web/components/issues/comment/index.ts deleted file mode 100644 index 61ac899ada6..00000000000 --- a/web/components/issues/comment/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./add-comment"; -export * from "./comment-card"; -export * from "./comment-reaction"; diff --git a/web/components/issues/delete-archived-issue-modal.tsx b/web/components/issues/delete-archived-issue-modal.tsx index 14ecd7edd16..49d9e19ddee 100644 --- a/web/components/issues/delete-archived-issue-modal.tsx +++ b/web/components/issues/delete-archived-issue-modal.tsx @@ -3,19 +3,19 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; import { AlertTriangle } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useToast from "hooks/use-toast"; +import { useIssues, useProject } from "hooks/store"; // ui import { Button } from "@plane/ui"; // types -import type { IIssue } from "types"; +import type { TIssue } from "@plane/types"; +import { EIssuesStoreType } from "constants/issue"; type Props = { isOpen: boolean; handleClose: () => void; - data: IIssue; + data: TIssue; onSubmit?: () => Promise; }; @@ -26,8 +26,11 @@ export const DeleteArchivedIssueModal: React.FC = observer((props) => { const { workspaceSlug } = router.query; const { setToastAlert } = useToast(); + const { getProjectById } = useProject(); - const { archivedIssueDetail: archivedIssueDetailStore } = useMobxStore(); + const { + issues: { removeIssue }, + } = useIssues(EIssuesStoreType.ARCHIVED); const [isDeleteLoading, setIsDeleteLoading] = useState(false); @@ -45,8 +48,7 @@ export const DeleteArchivedIssueModal: React.FC = observer((props) => { setIsDeleteLoading(true); - await archivedIssueDetailStore - .deleteArchivedIssue(workspaceSlug.toString(), data.project, data.id) + await removeIssue(workspaceSlug.toString(), data.project_id, data.id) .then(() => { if (onSubmit) onSubmit(); }) @@ -106,7 +108,7 @@ export const DeleteArchivedIssueModal: React.FC = observer((props) => {

Are you sure you want to delete issue{" "} - {data?.project_detail.identifier}-{data?.sequence_id} + {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 index 955d8ac78b6..6a2caba1801 100644 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ b/web/components/issues/delete-draft-issue-modal.tsx @@ -1,9 +1,6 @@ import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // services import { IssueDraftService } from "services/issue"; // hooks @@ -13,29 +10,29 @@ import { AlertTriangle } from "lucide-react"; // ui import { Button } from "@plane/ui"; // types -import type { IIssue } from "types"; +import type { TIssue } from "@plane/types"; +import { useProject } from "hooks/store"; type Props = { isOpen: boolean; handleClose: () => void; - data: IIssue | null; + data: TIssue | null; onSubmit?: () => Promise | void; }; const issueDraftService = new IssueDraftService(); -export const DeleteDraftIssueModal: React.FC = observer((props) => { +export const DeleteDraftIssueModal: React.FC = (props) => { const { isOpen, handleClose, data, onSubmit } = props; - + // states const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - const { user: userStore } = useMobxStore(); - const user = userStore.currentUser; - + // router const router = useRouter(); const { workspaceSlug } = router.query; - + // toast alert const { setToastAlert } = useToast(); + // hooks + const { getProjectById } = useProject(); useEffect(() => { setIsDeleteLoading(false); @@ -47,12 +44,12 @@ export const DeleteDraftIssueModal: React.FC = observer((props) => { }; const handleDeletion = async () => { - if (!workspaceSlug || !data || !user) return; + if (!workspaceSlug || !data) return; setIsDeleteLoading(true); await issueDraftService - .deleteDraftIssue(workspaceSlug as string, data.project, data.id) + .deleteDraftIssue(workspaceSlug.toString(), data.project_id, data.id) .then(() => { setIsDeleteLoading(false); handleClose(); @@ -64,7 +61,7 @@ export const DeleteDraftIssueModal: React.FC = observer((props) => { }); }) .catch((error) => { - console.log(error); + console.error(error); handleClose(); setToastAlert({ title: "Error", @@ -116,7 +113,7 @@ export const DeleteDraftIssueModal: React.FC = observer((props) => {

Are you sure you want to delete issue{" "} - {data?.project_detail.identifier}-{data?.sequence_id} + {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. @@ -138,4 +135,4 @@ export const DeleteDraftIssueModal: React.FC = observer((props) => {

); -}); +}; diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index 2f53a825f79..a063980c083 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -6,26 +6,37 @@ import { Button } from "@plane/ui"; // hooks import useToast from "hooks/use-toast"; // types -import type { IIssue } from "types"; +import { useIssues } from "hooks/store/use-issues"; +import { TIssue } from "@plane/types"; +import { useProject } from "hooks/store"; type Props = { isOpen: boolean; handleClose: () => void; - data: IIssue; + dataId?: string | null | undefined; + data?: TIssue; onSubmit?: () => Promise; }; export const DeleteIssueModal: React.FC = (props) => { - const { data, isOpen, handleClose, onSubmit } = props; + const { dataId, data, isOpen, handleClose, onSubmit } = props; + + const { issueMap } = useIssues(); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const { setToastAlert } = useToast(); + // hooks + const { getProjectById } = useProject(); useEffect(() => { setIsDeleteLoading(false); }, [isOpen]); + if (!dataId && !data) return null; + + const issue = data ? data : issueMap[dataId!]; + const onClose = () => { setIsDeleteLoading(false); handleClose(); @@ -36,11 +47,6 @@ export const DeleteIssueModal: React.FC = (props) => { if (onSubmit) await onSubmit() .then(() => { - setToastAlert({ - title: "Success", - type: "success", - message: "Issue deleted successfully", - }); onClose(); }) .catch(() => { @@ -93,7 +99,7 @@ export const DeleteIssueModal: React.FC = (props) => {

Are you sure you want to delete issue{" "} - {data?.project_detail?.identifier}-{data?.sequence_id} + {getProjectById(issue?.project_id)?.identifier}-{issue?.sequence_id} {""}? All of the data related to the issue will be permanently removed. This action cannot be undone. diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 677ab5e2292..ca6d7e0e7b6 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -5,12 +5,13 @@ import useReloadConfirmations from "hooks/use-reload-confirmation"; import debounce from "lodash/debounce"; // components import { TextArea } from "@plane/ui"; -import { RichTextEditor } from "@plane/rich-text-editor"; +import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; +import { TIssueOperations } from "./issue-detail"; // services import { FileService } from "services/file.service"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; +import { useMention, useWorkspace } from "hooks/store"; export interface IssueDescriptionFormValues { name: string; @@ -18,15 +19,17 @@ export interface IssueDescriptionFormValues { } export interface IssueDetailsProps { + workspaceSlug: string; + projectId: string; + issueId: string; issue: { name: string; description_html: string; id: string; project_id?: string; }; - workspaceSlug: string; - handleFormSubmit: (value: IssueDescriptionFormValues) => Promise; - isAllowed: boolean; + issueOperations: TIssueOperations; + disabled: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } @@ -34,21 +37,24 @@ export interface IssueDetailsProps { const fileService = new FileService(); export const IssueDescriptionForm: FC = (props) => { - const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props; + const { workspaceSlug, projectId, issueId, issue, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; + const workspaceStore = useWorkspace(); + const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string; + // states const [characterLimit, setCharacterLimit] = useState(false); const { setShowAlert } = useReloadConfirmations(); - - const editorSuggestion = useEditorSuggestions(); - + // store hooks + const { mentionHighlights, mentionSuggestions } = useMention(); + // form info const { handleSubmit, watch, reset, control, formState: { errors }, - } = useForm({ + } = useForm({ defaultValues: { name: "", description_html: "", @@ -72,15 +78,21 @@ export const IssueDescriptionForm: FC = (props) => { }, [issue.id]); // TODO: verify the exhaustive-deps warning const handleDescriptionFormSubmit = useCallback( - async (formData: Partial) => { + async (formData: Partial) => { if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; - await handleFormSubmit({ - name: formData.name ?? "", - description_html: formData.description_html ?? "

", - }); + await issueOperations.update( + workspaceSlug, + projectId, + issueId, + { + name: formData.name ?? "", + description_html: formData.description_html ?? "

", + }, + false + ); }, - [handleFormSubmit] + [workspaceSlug, projectId, issueId, issueOperations] ); useEffect(() => { @@ -116,7 +128,7 @@ export const IssueDescriptionForm: FC = (props) => { return (
- {isAllowed ? ( + {!disabled ? ( = (props) => { debouncedFormSave(); }} required - className={`min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary ${ - !isAllowed ? "hover:cursor-not-allowed" : "" - }`} - hasError={Boolean(errors?.description)} + className="min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary" + hasError={Boolean(errors?.name)} role="textbox" - disabled={!isAllowed} /> )} /> ) : (

{issue.name}

)} - {characterLimit && isAllowed && ( + {characterLimit && !disabled && (
255 ? "text-red-500" : ""}`}> {watch("name").length} @@ -161,31 +170,37 @@ export const IssueDescriptionForm: FC = (props) => { ( - { - setShowAlert(true); - setIsSubmitting("submitting"); - onChange(description_html); - debouncedFormSave(); - }} - mentionSuggestions={editorSuggestion.mentionSuggestions} - mentionHighlights={editorSuggestion.mentionHighlights} - /> - )} + render={({ field: { onChange } }) => + !disabled ? ( + { + setShowAlert(true); + setIsSubmitting("submitting"); + onChange(description_html); + debouncedFormSave(); + }} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} + /> + ) : ( + + ) + } />
diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index a556c9485df..cfd6370fad2 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -1,72 +1,64 @@ import React, { FC, useState, useEffect, useRef } from "react"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; -// services -import { AIService } from "services/ai.service"; -import { FileService } from "services/file.service"; +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 { GptAssistantModal } from "components/core"; +import { GptAssistantPopover } from "components/core"; import { ParentIssuesListModal } from "components/issues"; -import { - IssueAssigneeSelect, - IssueDateSelect, - IssueEstimateSelect, - IssueLabelSelect, - IssuePrioritySelect, - IssueProjectSelect, - IssueStateSelect, -} from "components/issues/select"; +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 {} from "components/ui"; import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; -// icons -import { Sparkle, X } from "lucide-react"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types -import type { IUser, IIssue, ISearchIssueResponse } from "types"; -// components -import { RichTextEditorWithRef } from "@plane/rich-text-editor"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; +import type { IUser, TIssue, ISearchIssueResponse } from "@plane/types"; const aiService = new AIService(); const fileService = new FileService(); -const defaultValues: Partial = { - project: "", +const defaultValues: Partial = { + project_id: "", name: "", - description: { - type: "doc", - content: [ - { - type: "paragraph", - }, - ], - }, description_html: "

", estimate_point: null, - state: "", - parent: null, + state_id: "", + parent_id: null, priority: "none", - assignees: [], - labels: [], - start_date: null, - target_date: null, + assignee_ids: [], + label_ids: [], + start_date: undefined, + target_date: undefined, }; interface IssueFormProps { handleFormSubmit: ( - formData: Partial, + formData: Partial, action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" ) => Promise; - data?: Partial | null; + data?: Partial | null; isOpen: boolean; - prePopulatedData?: Partial | null; + prePopulatedData?: Partial | null; projectId: string; setActiveProject: React.Dispatch>; createMore: boolean; @@ -112,19 +104,25 @@ export const DraftIssueForm: FC = observer((props) => { 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(); - const editorSuggestions = useEditorSuggestions(); // 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 { - appConfig: { envConfig }, - } = useMobxStore(); + config: { envConfig }, + } = useApplication(); + const { getProjectById } = useProject(); // form info const { formState: { errors, isSubmitting }, @@ -135,27 +133,26 @@ export const DraftIssueForm: FC = observer((props) => { getValues, setValue, setFocus, - } = useForm({ + } = useForm({ defaultValues: prePopulatedData ?? defaultValues, reValidateMode: "onChange", }); const issueName = watch("name"); - const payload: Partial = { + const payload: Partial = { name: watch("name"), - description: watch("description"), description_html: watch("description_html"), - state: watch("state"), + state_id: watch("state_id"), priority: watch("priority"), - assignees: watch("assignees"), - labels: watch("labels"), + assignee_ids: watch("assignee_ids"), + label_ids: watch("label_ids"), start_date: watch("start_date"), target_date: watch("target_date"), - project: watch("project"), - parent: watch("parent"), - cycle: watch("cycle"), - module: watch("module"), + project_id: watch("project_id"), + parent_id: watch("parent_id"), + cycle_id: watch("cycle_id"), + module_ids: watch("module_ids"), }; useEffect(() => { @@ -173,47 +170,29 @@ export const DraftIssueForm: FC = observer((props) => { // handleClose(); // }; - 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 handleCreateUpdateIssue = async ( - formData: Partial, + formData: Partial, action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" ) => { await handleFormSubmit( { ...(data ?? {}), ...formData, - is_draft: action === "createDraft" || action === "updateDraft", + // is_draft: action === "createDraft" || action === "updateDraft", }, action ); + // TODO: check_with_backend setGptAssistantModal(false); reset({ ...defaultValues, - project: projectId, - description: { - type: "doc", - content: [ - { - type: "paragraph", - }, - ], - }, + project_id: projectId, description_html: "

", }); editorRef?.current?.clearEditor(); @@ -222,7 +201,7 @@ export const DraftIssueForm: FC = observer((props) => { const handleAiAssistance = async (response: string) => { if (!workspaceSlug || !projectId) return; - setValue("description", {}); + // setValue("description", {}); setValue("description_html", `${watch("description_html")}

${response}

`); editorRef.current?.setEditorValue(`${watch("description_html")}`); }; @@ -268,19 +247,13 @@ export const DraftIssueForm: FC = observer((props) => { useEffect(() => { setFocus("name"); - - reset({ - ...defaultValues, - ...(prePopulatedData ?? {}), - ...(data ?? {}), - }); - }, [setFocus, prePopulatedData, reset, data]); + }, [setFocus]); // update projectId in form when projectId changes useEffect(() => { reset({ ...getValues(), - project: projectId, + project_id: projectId, }); }, [getValues, projectId, reset]); @@ -293,6 +266,8 @@ export const DraftIssueForm: FC = observer((props) => { const maxDate = targetDate ? new Date(targetDate) : null; maxDate?.setDate(maxDate.getDate()); + const projectDetails = getProjectById(projectId); + return ( <> {projectId && ( @@ -302,7 +277,7 @@ export const DraftIssueForm: FC = observer((props) => { isOpen={labelModal} handleClose={() => setLabelModal(false)} projectId={projectId} - onSuccess={(response) => setValue("labels", [...watch("labels"), response.id])} + onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])} /> )} @@ -316,23 +291,26 @@ export const DraftIssueForm: FC = observer((props) => { {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( ( - { - onChange(val); - setActiveProject(val); - }} - /> +
+ { + onChange(val); + setActiveProject(val); + }} + buttonVariant="border-with-text" + /> +
)} /> )}

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

- {watch("parent") && + {watch("parent_id") && (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && selectedParentIssue && (
@@ -350,7 +328,7 @@ export const DraftIssueForm: FC = observer((props) => { { - setValue("parent", null); + setValue("parent_id", null); setSelectedParentIssue(null); }} /> @@ -389,11 +367,11 @@ export const DraftIssueForm: FC = observer((props) => { )} {(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" + /> + )}
= observer((props) => { = observer((props) => { customClassName="min-h-[150px]" onChange={(description: Object, description_html: string) => { onChange(description_html); - setValue("description", description); }} - mentionHighlights={editorSuggestions.mentionHighlights} - mentionSuggestions={editorSuggestions.mentionSuggestions} + mentionHighlights={mentionHighlights} + mentionSuggestions={mentionSuggestions} /> )} /> - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> - )}
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( ( - +
+ +
)} /> )} @@ -482,80 +462,137 @@ export const DraftIssueForm: FC = observer((props) => { control={control} name="priority" render={({ field: { value, onChange } }) => ( - +
+ +
)} /> )} {(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} - onChange={onChange} + /> +
+ )} + /> + )} + {projectDetails?.cycle_view && ( + ( +
+ onChange(cycleId)} value={value} + buttonVariant="border-with-text" /> - )} - /> -
+
+ )} + /> + )} + + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ +
+ )} + /> )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( -
+ + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && + areEstimatesEnabledForProject(projectId) && ( ( - +
+ +
)} /> -
- )} + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( ( = observer((props) => { )} {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - {watch("parent") ? ( + {watch("parent_id") ? ( <> setParentIssueListModalOpen(true)}> Change parent issue - setValue("parent", null)}> + setValue("parent_id", null)}> Remove parent issue diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index 51ff30d4060..0324c1b0387 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -3,27 +3,27 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { mutate } from "swr"; import { Dialog, Transition } from "@headlessui/react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // 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 { IIssue } from "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?: IIssue | null; + data?: TIssue | null; handleClose: () => void; isOpen: boolean; isUpdatingSingleIssue?: boolean; - prePopulateData?: Partial; + prePopulateData?: Partial; fieldsToShow?: ( | "project" | "name" @@ -38,7 +38,7 @@ interface IssuesModalProps { | "parent" | "all" )[]; - onSubmit?: (data: Partial) => Promise | void; + onSubmit?: (data: Partial) => Promise | void; } // services @@ -59,15 +59,16 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( // states const [createMore, setCreateMore] = useState(false); const [activeProject, setActiveProject] = useState(null); - const [prePopulateData, setPreloadedData] = useState | undefined>(undefined); - + const [prePopulateData, setPreloadedData] = useState | undefined>(undefined); + // router const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - - const { project: projectStore, user: userStore, projectDraftIssues: draftIssueStore } = useMobxStore(); - - const user = userStore.currentUser; - const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined; + // store + const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); + const { currentUser } = useUser(); + const { workspaceProjectIds: workspaceProjects } = useProject(); + // derived values + const projects = workspaceProjects; const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {}); @@ -86,14 +87,14 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( useEffect(() => { setPreloadedData(prePopulateDataProps ?? {}); - if (cycleId && !prePopulateDataProps?.cycle) { + if (cycleId && !prePopulateDataProps?.cycle_id) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, cycle: cycleId.toString(), })); } - if (moduleId && !prePopulateDataProps?.module) { + if (moduleId && !prePopulateDataProps?.module_ids) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, @@ -102,27 +103,27 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( } if ( (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignees + !prePopulateDataProps?.assignee_ids ) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], })); } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); useEffect(() => { setPreloadedData(prePopulateDataProps ?? {}); - if (cycleId && !prePopulateDataProps?.cycle) { + if (cycleId && !prePopulateDataProps?.cycle_id) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, cycle: cycleId.toString(), })); } - if (moduleId && !prePopulateDataProps?.module) { + if (moduleId && !prePopulateDataProps?.module_ids) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, @@ -131,15 +132,15 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( } if ( (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignees + !prePopulateDataProps?.assignee_ids ) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], })); } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); useEffect(() => { // if modal is closed, reset active project to null @@ -151,32 +152,35 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( // 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) return setActiveProject(data.project); + if (data && data.project_id) return setActiveProject(data.project_id); - if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project); + if (prePopulateData && prePopulateData.project_id && !activeProject) + return setActiveProject(prePopulateData.project_id); - if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project); + 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((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); + setActiveProject(projects?.find((id) => id === projectId) ?? projects?.[0] ?? null); }, [activeProject, data, projectId, projects, isOpen, prePopulateData]); - const createDraftIssue = async (payload: Partial) => { - if (!workspaceSlug || !activeProject || !user) return; + const createDraftIssue = async (payload: Partial) => { + if (!workspaceSlug || !activeProject || !currentUser) return; - await draftIssueStore + await draftIssues .createIssue(workspaceSlug as string, activeProject ?? "", payload) .then(async () => { - await draftIssueStore.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation"); + await draftIssues.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation"); setToastAlert({ type: "success", title: "Success!", message: "Issue created successfully.", }); - if (payload.assignees?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug.toString())); + if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) + mutate(USER_ISSUE(workspaceSlug.toString())); }) .catch(() => { setToastAlert({ @@ -189,22 +193,20 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( if (!createMore) onClose(); }; - const updateDraftIssue = async (payload: Partial) => { - if (!user) return; - - await draftIssueStore + 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); + mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); } else { - if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString())); + if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString())); } - if (!payload.is_draft) { - if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); - if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); - } + // 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(); @@ -224,29 +226,29 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( }; const addIssueToCycle = async (issueId: string, cycleId: string) => { - if (!workspaceSlug || !activeProject || !user) return; + if (!workspaceSlug || !activeProject) return; await issueService.addIssueToCycle(workspaceSlug as string, activeProject ?? "", cycleId, { issues: [issueId], }); }; - const addIssueToModule = async (issueId: string, moduleId: string) => { - if (!workspaceSlug || !activeProject || !user) return; + const addIssueToModule = async (issueId: string, moduleIds: string[]) => { + if (!workspaceSlug || !activeProject) return; - await moduleService.addIssuesToModule(workspaceSlug as string, activeProject ?? "", moduleId as string, { - issues: [issueId], + await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, { + modules: moduleIds, }); }; - const createIssue = async (payload: Partial) => { - if (!workspaceSlug || !activeProject || !user) return; + const createIssue = async (payload: Partial) => { + if (!workspaceSlug || !activeProject) return; await issueService .createIssue(workspaceSlug.toString(), activeProject, payload) .then(async (res) => { - if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle); - if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module); + 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", @@ -256,9 +258,10 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( if (!createMore) onClose(); - if (payload.assignees?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug as string)); + if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); - if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id)); }) .catch(() => { setToastAlert({ @@ -270,14 +273,14 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( }; const handleFormSubmit = async ( - formData: Partial, + formData: Partial, action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" ) => { if (!workspaceSlug || !activeProject) return; - const payload: Partial = { + const payload: Partial = { ...formData, - description: formData.description ?? "", + // description: formData.description ?? "", description_html: formData.description_html ?? "

", }; @@ -319,7 +322,7 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - + = observer( projectId={activeProject ?? ""} setActiveProject={setActiveProject} status={data ? true : false} - user={user ?? undefined} + user={currentUser ?? undefined} fieldsToShow={fieldsToShow} /> diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx deleted file mode 100644 index c0d1ebc5c85..00000000000 --- a/web/components/issues/form.tsx +++ /dev/null @@ -1,640 +0,0 @@ -import React, { FC, useState, useEffect, useRef } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { Controller, useForm } from "react-hook-form"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import { AIService } from "services/ai.service"; -import { FileService } from "services/file.service"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { GptAssistantModal } from "components/core"; -import { ParentIssuesListModal } from "components/issues"; -import { - IssueAssigneeSelect, - IssueDateSelect, - IssueEstimateSelect, - IssueLabelSelect, - IssuePrioritySelect, - IssueProjectSelect, - IssueStateSelect, - IssueModuleSelect, - IssueCycleSelect, -} from "components/issues/select"; -import { CreateStateModal } from "components/states"; -import { CreateLabelModal } from "components/labels"; -// ui -import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; -// icons -import { LayoutPanelTop, Sparkle, X } from "lucide-react"; -// types -import type { IIssue, ISearchIssueResponse } from "types"; -// components -import { RichTextEditorWithRef } from "@plane/rich-text-editor"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; - -const defaultValues: Partial = { - project: "", - name: "", - description_html: "

", - estimate_point: null, - state: "", - parent: null, - priority: "none", - assignees: [], - labels: [], - start_date: null, - target_date: null, -}; - -export interface IssueFormProps { - handleFormSubmit: (values: Partial) => Promise; - initialData?: Partial; - projectId: string; - setActiveProject: React.Dispatch>; - createMore: boolean; - setCreateMore: React.Dispatch>; - handleDiscardClose: () => void; - status: boolean; - handleFormDirty: (payload: Partial | null) => void; - fieldsToShow: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - | "module" - | "cycle" - )[]; -} - -// services -const aiService = new AIService(); -const fileService = new FileService(); - -export const IssueForm: FC = observer((props) => { - const { - handleFormSubmit, - initialData, - projectId, - setActiveProject, - createMore, - setCreateMore, - handleDiscardClose, - status, - fieldsToShow, - handleFormDirty, - } = 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); - // refs - const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - // store - const { - user: userStore, - appConfig: { envConfig }, - } = useMobxStore(); - const user = userStore.currentUser; - // hooks - const editorSuggestion = useEditorSuggestions(); - const { setToastAlert } = useToast(); - // form info - const { - formState: { errors, isSubmitting, isDirty }, - handleSubmit, - reset, - watch, - control, - getValues, - setValue, - setFocus, - } = useForm({ - defaultValues: initialData ?? defaultValues, - reValidateMode: "onChange", - }); - - const issueName = watch("name"); - - const payload: Partial = { - name: getValues("name"), - description: getValues("description"), - state: getValues("state"), - priority: getValues("priority"), - assignees: getValues("assignees"), - labels: getValues("labels"), - start_date: getValues("start_date"), - target_date: getValues("target_date"), - project: getValues("project"), - parent: getValues("parent"), - cycle: getValues("cycle"), - module: getValues("module"), - }; - - useEffect(() => { - if (isDirty) handleFormDirty(payload); - else handleFormDirty(null); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(payload), isDirty]); - - const handleCreateUpdateIssue = async (formData: Partial) => { - await handleFormSubmit(formData); - - setGptAssistantModal(false); - - reset({ - ...defaultValues, - project: projectId, - description: { - type: "doc", - content: [ - { - type: "paragraph", - }, - ], - }, - 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 || !user) 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"); - - reset({ - ...defaultValues, - ...initialData, - project: projectId, - }); - }, [setFocus, initialData, reset]); - - // update projectId in form when projectId changes - useEffect(() => { - reset({ - ...getValues(), - project: 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()); - - return ( - <> - {projectId && ( - <> - setStateModal(false)} projectId={projectId} /> - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => setValue("labels", [...watch("labels"), response.id])} - /> - - )} -
-
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( - ( - { - onChange(val); - setActiveProject(val); - }} - /> - )} - /> - )} -

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

-
- {watch("parent") && - (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && - selectedParentIssue && ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - { - setValue("parent", null); - setSelectedParentIssue(null); - }} - /> -
-
- )} -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( -
-
- {issueName && issueName !== "" && ( - - )} - -
- ( - { - onChange(description_html); - setValue("description", description); - }} - mentionHighlights={editorSuggestion.mentionHighlights} - mentionSuggestions={editorSuggestion.mentionSuggestions} - /> - )} - /> - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> - )} -
- )} -
- {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( - ( - - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( - ( - - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( - ( - - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( - ( - - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && ( - ( - { - onChange(val); - }} - /> - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && ( - ( - { - onChange(val); - }} - /> - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( - <> - ( - - )} - /> - - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - <> - {watch("parent") ? ( - -
- - - {selectedParentIssue && - `${selectedParentIssue.project__identifier}- - ${selectedParentIssue.sequence_id}`} - -
- - } - placement="bottom-start" - > - setParentIssueListModalOpen(true)}> - Change parent issue - - setValue("parent", null)}> - Remove parent issue - -
- ) : ( - - )} - - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - projectId={projectId} - /> - )} - /> - - )} -
-
-
-
-
- {!status && ( -
setCreateMore((prevData) => !prevData)} - > -
- {}} size="sm" /> -
- Create more -
- )} -
- - -
-
-
- - ); -}); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 53b873894a1..3cf88cb7c4a 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,22 +1,20 @@ export * from "./attachment"; -export * from "./comment"; -export * from "./sidebar-select"; +export * from "./issue-modal"; export * from "./view-select"; -export * from "./activity"; export * from "./delete-issue-modal"; export * from "./description-form"; -export * from "./form"; export * from "./issue-layouts"; -export * from "./peek-overview"; -export * from "./main-content"; -export * from "./modal"; + export * from "./parent-issues-list-modal"; -export * from "./sidebar"; export * from "./label"; -export * from "./issue-reaction"; export * from "./confirm-issue-discard"; export * from "./issue-update-status"; +// issue details +export * from "./issue-detail"; + +export * from "./peek-overview"; + // draft issue export * from "./draft-issue-form"; export * from "./draft-issue-modal"; diff --git a/web/components/issues/issue-detail/cycle-select.tsx b/web/components/issues/issue-detail/cycle-select.tsx new file mode 100644 index 00000000000..fb8449d6f59 --- /dev/null +++ b/web/components/issues/issue-detail/cycle-select.tsx @@ -0,0 +1,62 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { CycleDropdown } from "components/dropdowns"; +// ui +import { Spinner } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import type { TIssueOperations } from "./root"; + +type TIssueCycleSelect = { + className?: string; + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + disabled?: boolean; +}; + +export const IssueCycleSelect: React.FC = observer((props) => { + const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props; + // states + const [isUpdating, setIsUpdating] = useState(false); + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + const disableSelect = disabled || isUpdating; + + const handleIssueCycleChange = async (cycleId: string | null) => { + if (!issue || issue.cycle_id === cycleId) return; + setIsUpdating(true); + if (cycleId) await issueOperations.addIssueToCycle?.(workspaceSlug, projectId, cycleId, [issueId]); + else await issueOperations.removeIssueFromCycle?.(workspaceSlug, projectId, issue.cycle_id ?? "", issueId); + setIsUpdating(false); + }; + + return ( +
+ + {isUpdating && } +
+ ); +}); diff --git a/web/components/issues/issue-detail/inbox/index.ts b/web/components/issues/issue-detail/inbox/index.ts new file mode 100644 index 00000000000..97c28cc7cc8 --- /dev/null +++ b/web/components/issues/issue-detail/inbox/index.ts @@ -0,0 +1,3 @@ +export * from "./root" +export * from "./main-content" +export * from "./sidebar" \ No newline at end of file diff --git a/web/components/issues/issue-detail/inbox/main-content.tsx b/web/components/issues/issue-detail/inbox/main-content.tsx new file mode 100644 index 00000000000..4a1f79bee5f --- /dev/null +++ b/web/components/issues/issue-detail/inbox/main-content.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail, useProjectState, useUser } from "hooks/store"; +// components +import { IssueDescriptionForm, IssueUpdateStatus, TIssueOperations } from "components/issues"; +import { IssueReaction } from "../reactions"; +import { IssueActivity } from "../issue-activity"; +import { InboxIssueStatus } from "../../../inbox/inbox-issue-status"; +// ui +import { StateGroupIcon } from "@plane/ui"; + +type Props = { + workspaceSlug: string; + projectId: string; + inboxId: string; + issueId: string; + issueOperations: TIssueOperations; + is_editable: boolean; +}; + +export const InboxIssueMainContent: React.FC = observer((props) => { + const { workspaceSlug, projectId, inboxId, issueId, issueOperations, is_editable } = props; + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + // hooks + const { currentUser } = useUser(); + const { projectStates } = useProjectState(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const issue = getIssueById(issueId); + if (!issue) return <>; + + const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + + return ( + <> +
+ + +
+ {currentIssueState && ( + + )} + +
+ + setIsSubmitting(value)} + isSubmitting={isSubmitting} + issue={issue} + issueOperations={issueOperations} + disabled={!is_editable} + /> + + {currentUser && ( + + )} +
+ +
+ +
+ + ); +}); diff --git a/web/components/issues/issue-detail/inbox/root.tsx b/web/components/issues/issue-detail/inbox/root.tsx new file mode 100644 index 00000000000..b8f12a944cc --- /dev/null +++ b/web/components/issues/issue-detail/inbox/root.tsx @@ -0,0 +1,130 @@ +import { FC, useMemo } from "react"; +import useSWR from "swr"; +// components +import { InboxIssueMainContent } from "./main-content"; +import { InboxIssueDetailsSidebar } from "./sidebar"; +// hooks +import { useInboxIssues, useIssueDetail, useUser } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { TIssue } from "@plane/types"; +import { TIssueOperations } from "../root"; +// constants +import { EUserProjectRoles } from "constants/project"; + +export type TInboxIssueDetailRoot = { + workspaceSlug: string; + projectId: string; + inboxId: string; + issueId: string; +}; + +export const InboxIssueDetailRoot: FC = (props) => { + const { workspaceSlug, projectId, inboxId, issueId } = props; + // hooks + const { + issues: { fetchInboxIssueById, updateInboxIssue, removeInboxIssue }, + } = useInboxIssues(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + const { + membership: { currentProjectRole }, + } = useUser(); + + const issueOperations: TIssueOperations = useMemo( + () => ({ + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await fetchInboxIssueById(workspaceSlug, projectId, inboxId, issueId); + } catch (error) { + console.error("Error fetching the parent issue"); + } + }, + update: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial, + showToast: boolean = true + ) => { + try { + await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); + if (showToast) { + setToastAlert({ + title: "Issue updated successfully", + type: "success", + message: "Issue updated successfully", + }); + } + } catch (error) { + setToastAlert({ + title: "Issue update failed", + type: "error", + message: "Issue update failed", + }); + } + }, + remove: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await removeInboxIssue(workspaceSlug, projectId, inboxId, issueId); + setToastAlert({ + title: "Issue deleted successfully", + type: "success", + message: "Issue deleted successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue delete failed", + type: "error", + message: "Issue delete failed", + }); + } + }, + }), + [inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue, setToastAlert] + ); + + useSWR( + workspaceSlug && projectId && inboxId && issueId + ? `INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxId}_${issueId}` + : null, + async () => { + if (workspaceSlug && projectId && inboxId && issueId) { + await issueOperations.fetch(workspaceSlug, projectId, issueId); + } + } + ); + + // checking if issue is editable, based on user role + const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + // issue details + const issue = getIssueById(issueId); + + if (!issue) return <>; + return ( +
+
+ +
+
+ +
+
+ ); +}; diff --git a/web/components/issues/issue-detail/inbox/sidebar.tsx b/web/components/issues/issue-detail/inbox/sidebar.tsx new file mode 100644 index 00000000000..e0b2aca28a3 --- /dev/null +++ b/web/components/issues/issue-detail/inbox/sidebar.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { CalendarCheck2, Signal, Tag } from "lucide-react"; +// hooks +import { useIssueDetail, useProject, useProjectState } from "hooks/store"; +// components +import { IssueLabel, TIssueOperations } from "components/issues"; +import { DateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; +// icons +import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; +// helper +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + is_editable: boolean; +}; + +export const InboxIssueDetailsSidebar: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, is_editable } = props; + // store hooks + const { getProjectById } = useProject(); + const { projectStates } = useProjectState(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const issue = getIssueById(issueId); + if (!issue) return <>; + + const projectDetails = issue ? getProjectById(issue.project_id) : null; + + const minDate = issue.start_date ? new Date(issue.start_date) : null; + minDate?.setDate(minDate.getDate()); + + const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + + return ( +
+
+
+ {currentIssueState && ( + + )} +

+ {projectDetails?.identifier}-{issue?.sequence_id} +

+
+
+ +
+
Properties
+
+
+ {/* State */} +
+
+ + State +
+ issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} + projectId={projectId?.toString() ?? ""} + disabled={!is_editable} + buttonVariant="transparent-with-text" + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
+ {/* Assignee */} +
+
+ + Assignees +
+ issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} + disabled={!is_editable} + projectId={projectId?.toString() ?? ""} + placeholder="Add assignees" + multiple + buttonVariant={issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"} + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm justify-between ${ + issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400" + }`} + hideIcon={issue.assignee_ids?.length === 0} + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
+ {/* Priority */} +
+
+ + Priority +
+ issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} + disabled={!is_editable} + buttonVariant="border-with-text" + className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80" + buttonContainerClassName="w-full text-left" + buttonClassName="w-min h-auto whitespace-nowrap" + /> +
+
+
+
+
+ {/* Due Date */} +
+
+ + Due date +
+ + issueOperations.update(workspaceSlug, projectId, issueId, { + target_date: val ? renderFormattedPayloadDate(val) : null, + }) + } + minDate={minDate ?? undefined} + disabled={!is_editable} + 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"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + /> +
+ {/* Labels */} +
+
+ + Labels +
+
+ +
+
+
+
+
+
+ ); +}); diff --git a/web/components/issues/issue-detail/index.ts b/web/components/issues/issue-detail/index.ts new file mode 100644 index 00000000000..63ef560a1b4 --- /dev/null +++ b/web/components/issues/issue-detail/index.ts @@ -0,0 +1,14 @@ +export * from "./root"; + +export * from "./main-content"; +export * from "./sidebar"; + +// select +export * from "./cycle-select"; +export * from "./module-select"; +export * from "./parent-select"; +export * from "./relation-select"; +export * from "./parent"; +export * from "./label"; +export * from "./subscription"; +export * from "./links"; diff --git a/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx new file mode 100644 index 00000000000..575e8d8414a --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -0,0 +1,51 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityList } from "./activity/activity-list"; +import { IssueCommentCard } from "./comments/comment-card"; +// types +import { TActivityOperations } from "./root"; + +type TIssueActivityCommentRoot = { + workspaceSlug: string; + issueId: string; + activityOperations: TActivityOperations; + showAccessSpecifier?: boolean; +}; + +export const IssueActivityCommentRoot: FC = observer((props) => { + const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props; + // hooks + const { + activity: { getActivityCommentByIssueId }, + comment: {}, + } = useIssueDetail(); + + const activityComments = getActivityCommentByIssueId(issueId); + + if (!activityComments || (activityComments && activityComments.length <= 0)) return <>; + return ( +
+ {activityComments.map((activityComment, index) => + activityComment.activity_type === "COMMENT" ? ( + + ) : activityComment.activity_type === "ACTIVITY" ? ( + + ) : ( + <> + ) + )} +
+ ); +}); 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 new file mode 100644 index 00000000000..55f07870ca7 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx @@ -0,0 +1,30 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { MessageSquare } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent } from "./"; + +type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +export const IssueArchivedAtActivity: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx new file mode 100644 index 00000000000..449297cbed8 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx @@ -0,0 +1,45 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; +// icons +import { UserGroupIcon } from "@plane/ui"; + +type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueAssigneeActivity: FC = observer((props) => { + const { activityId, ends, showIssue = true } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + } + activityId={activityId} + ends={ends} + > + <> + {activity.old_value === "" ? `added a new assignee ` : `removed the assignee `} + + + {activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value} + + + {showIssue && (activity.old_value === "" ? ` to ` : ` from `)} + {showIssue && }. + + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/attachment.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/attachment.tsx new file mode 100644 index 00000000000..d9b4475c52a --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/attachment.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Paperclip } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssueAttachmentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueAttachmentActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx new file mode 100644 index 00000000000..8336e516f22 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx @@ -0,0 +1,69 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent } from "./"; +// icons +import { ContrastIcon } from "@plane/ui"; + +type TIssueCycleActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +export const IssueCycleActivity: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + } + activityId={activityId} + ends={ends} + > + <> + {activity.verb === "created" ? ( + <> + added this issue to the cycle + + {activity.new_value} + + + ) : activity.verb === "updated" ? ( + <> + set the cycle to + + {activity.new_value} + + + ) : ( + <> + removed the issue from the cycle + + {activity.new_value} + + + )} + + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx new file mode 100644 index 00000000000..e4538753569 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx @@ -0,0 +1,31 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent } from "./"; +// icons +import { LayersIcon } from "@plane/ui"; + +type TIssueDefaultActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +export const IssueDefaultActivity: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/description.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/description.tsx new file mode 100644 index 00000000000..30f445ec0a5 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/description.tsx @@ -0,0 +1,34 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { MessageSquare } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssueDescriptionActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueDescriptionActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx new file mode 100644 index 00000000000..e01b94e1b34 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx @@ -0,0 +1,50 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Triangle } from "lucide-react"; +// hooks +import { useEstimate, useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssueEstimateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueEstimateActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + + const estimateValue = getEstimatePointValue(Number(activity.new_value), null); + const currentPoint = Number(activity.new_value) + 1; + + 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 new file mode 100644 index 00000000000..eabe5d5187d --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -0,0 +1,52 @@ +import { FC, ReactNode } from "react"; +import { Network } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { IssueUser } from "../"; +// helpers +import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; + +type TIssueActivityBlockComponent = { + icon?: ReactNode; + activityId: string; + ends: "top" | "bottom" | undefined; + children: ReactNode; +}; + +export const IssueActivityBlockComponent: FC = (props) => { + const { icon, activityId, ends, children } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( +
+
+
+ {icon ? icon : } +
+
+ + {children} + + + {calculateTimeAgo(activity.created_at)} + + +
+
+ ); +}; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx new file mode 100644 index 00000000000..e86b1fb57e3 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// ui +import { Tooltip } from "@plane/ui"; + +type TIssueLink = { + activityId: string; +}; + +export const IssueLink: FC = (props) => { + const { activityId } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + + {activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}{" "} + {activity.issue_detail?.name} + + + ); +}; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-user.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-user.tsx new file mode 100644 index 00000000000..dd44879cf16 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-user.tsx @@ -0,0 +1,29 @@ +import { FC } from "react"; + +// hooks +import { useIssueDetail } from "hooks/store"; +// ui + +type TIssueUser = { + activityId: string; +}; + +export const IssueUser: FC = (props) => { + const { activityId } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + {activity.actor_detail?.display_name} + + ); +}; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/index.ts b/web/components/issues/issue-detail/issue-activity/activity/actions/index.ts new file mode 100644 index 00000000000..02108d70b11 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/index.ts @@ -0,0 +1,22 @@ +export * from "./default"; +export * from "./name"; +export * from "./description"; +export * from "./state"; +export * from "./assignee"; +export * from "./priority"; +export * from "./estimate"; +export * from "./parent"; +export * from "./relation"; +export * from "./start_date"; +export * from "./target_date"; +export * from "./cycle"; +export * from "./module"; +export * from "./label"; +export * from "./link"; +export * from "./attachment"; +export * from "./archived-at"; + +// helpers +export * from "./helpers/activity-block"; +export * from "./helpers/issue-user"; +export * from "./helpers/issue-link"; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/label.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/label.tsx new file mode 100644 index 00000000000..b9c59c9b333 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/label.tsx @@ -0,0 +1,58 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Tag } from "lucide-react"; +// hooks +import { useIssueDetail, useLabel } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssueLabelActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueLabelActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + const { projectLabels } = useLabel(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/link.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/link.tsx new file mode 100644 index 00000000000..15343392f99 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/link.tsx @@ -0,0 +1,70 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { MessageSquare } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssueLinkActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueLinkActivity: FC = observer((props) => { + const { activityId, showIssue = false, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx new file mode 100644 index 00000000000..c8089d23308 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx @@ -0,0 +1,69 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent } from "./"; +// icons +import { DiceIcon } from "@plane/ui"; + +type TIssueModuleActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +export const IssueModuleActivity: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + } + activityId={activityId} + ends={ends} + > + <> + {activity.verb === "created" ? ( + <> + added this issue to the module + + {activity.new_value} + + + ) : activity.verb === "updated" ? ( + <> + set the module to + + {activity.new_value} + + + ) : ( + <> + removed the issue from the module + + {activity.old_value} + + + )} + + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/name.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/name.tsx new file mode 100644 index 00000000000..7a78be7bde0 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/name.tsx @@ -0,0 +1,30 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { MessageSquare } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent } from "./"; + +type TIssueNameActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +export const IssueNameActivity: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/parent.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/parent.tsx new file mode 100644 index 00000000000..afe814ee283 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/parent.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { LayoutPanelTop } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssueParentActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueParentActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/priority.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/priority.tsx new file mode 100644 index 00000000000..273bd319bb5 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/priority.tsx @@ -0,0 +1,34 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Signal } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; + +type TIssuePriorityActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssuePriorityActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx new file mode 100644 index 00000000000..e68a7c37371 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx @@ -0,0 +1,50 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent } from "./"; +// component helpers +import { issueRelationObject } from "components/issues/issue-detail/relation-select"; +// types +import { TIssueRelationTypes } from "@plane/types"; + +type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined }; + +export const IssueRelationActivity: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + } + activityId={activityId} + ends={ends} + > + <> + {activity.field === "blocking" && + (activity.old_value === "" ? `marked this issue is blocking issue ` : `removed the blocking issue `)} + {activity.field === "blocked_by" && + (activity.old_value === "" + ? `marked this issue is being blocked by ` + : `removed this issue being blocked by issue `)} + {activity.field === "duplicate" && + (activity.old_value === "" ? `marked this issue as duplicate of ` : `removed this issue as a duplicate of `)} + {activity.field === "relates_to" && + (activity.old_value === "" ? `marked that this issue relates to ` : `removed the relation from `)} + + {activity.old_value === "" ? ( + {activity.new_value}. + ) : ( + {activity.old_value}. + )} + + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx new file mode 100644 index 00000000000..95b3cda80ec --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx @@ -0,0 +1,41 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { CalendarDays } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; + +type TIssueStartDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueStartDateActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx new file mode 100644 index 00000000000..7cc47c2c8ae --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx @@ -0,0 +1,35 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; +// icons +import { DoubleCircleIcon } from "@plane/ui"; + +type TIssueStateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueStateActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + } + activityId={activityId} + ends={ends} + > + <> + set the state to {activity.new_value} + {showIssue ? ` for ` : ``} + {showIssue && }. + + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx new file mode 100644 index 00000000000..a4b40ec3106 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx @@ -0,0 +1,41 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { CalendarDays } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityBlockComponent, IssueLink } from "./"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; + +type TIssueTargetDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueTargetDateActivity: FC = observer((props) => { + const { activityId, showIssue = true, ends } = props; + // hooks + const { + activity: { getActivityById }, + } = useIssueDetail(); + + const activity = getActivityById(activityId); + + if (!activity) return <>; + return ( + + ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/activity-list.tsx b/web/components/issues/issue-detail/issue-activity/activity/activity-list.tsx new file mode 100644 index 00000000000..0f5f6876e8e --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/activity-list.tsx @@ -0,0 +1,80 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { + IssueDefaultActivity, + IssueNameActivity, + IssueDescriptionActivity, + IssueStateActivity, + IssueAssigneeActivity, + IssuePriorityActivity, + IssueEstimateActivity, + IssueParentActivity, + IssueRelationActivity, + IssueStartDateActivity, + IssueTargetDateActivity, + IssueCycleActivity, + IssueModuleActivity, + IssueLabelActivity, + IssueLinkActivity, + IssueAttachmentActivity, + IssueArchivedAtActivity, +} from "./actions"; + +type TIssueActivityList = { + activityId: string; + ends: "top" | "bottom" | undefined; +}; + +export const IssueActivityList: FC = observer((props) => { + const { activityId, ends } = props; + // hooks + const { + activity: { getActivityById }, + comment: {}, + } = useIssueDetail(); + + const componentDefaultProps = { activityId, ends }; + + const activityField = getActivityById(activityId)?.field; + switch (activityField) { + case null: // default issue creation + return ; + case "state": + return ; + case "name": + return ; + case "description": + return ; + case "assignees": + return ; + case "priority": + return ; + case "estimate_point": + return ; + case "parent": + return ; + case ["blocking", "blocked_by", "duplicate", "relates_to"].find((field) => field === activityField): + return ; + case "start_date": + return ; + case "target_date": + return ; + case "cycles": + return ; + case "modules": + return ; + case "labels": + return ; + case "link": + return ; + case "attachment": + return ; + case "archived_at": + return ; + default: + return <>; + } +}); diff --git a/web/components/issues/issue-detail/issue-activity/activity/root.tsx b/web/components/issues/issue-detail/issue-activity/activity/root.tsx new file mode 100644 index 00000000000..af44463d5a6 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/activity/root.tsx @@ -0,0 +1,32 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueActivityList } from "./activity-list"; + +type TIssueActivityRoot = { + issueId: string; +}; + +export const IssueActivityRoot: FC = observer((props) => { + const { issueId } = props; + // hooks + const { + activity: { getActivitiesByIssueId }, + } = useIssueDetail(); + + const activityIds = getActivitiesByIssueId(issueId); + + if (!activityIds) return <>; + return ( +
+ {activityIds.map((activityId, index) => ( + + ))} +
+ ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx new file mode 100644 index 00000000000..4dbc36f6ba0 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx @@ -0,0 +1,66 @@ +import { FC, ReactNode } from "react"; +import { MessageCircle } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +// helpers +import { calculateTimeAgo } from "helpers/date-time.helper"; + +type TIssueCommentBlock = { + commentId: string; + ends: "top" | "bottom" | undefined; + quickActions: ReactNode; + children: ReactNode; +}; + +export const IssueCommentBlock: FC = (props) => { + const { commentId, ends, quickActions, children } = props; + // hooks + const { + comment: { getCommentById }, + } = useIssueDetail(); + + const comment = getCommentById(commentId); + + if (!comment) return <>; + return ( +
+
+
+ {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + { + ) : ( + <> + {comment.actor_detail.is_bot + ? comment.actor_detail.first_name.charAt(0) + : comment.actor_detail.display_name.charAt(0)} + + )} +
+ +
+
+
+
+
+
+ {comment.actor_detail.is_bot + ? comment.actor_detail.first_name + " Bot" + : comment.actor_detail.display_name} +
+
commented {calculateTimeAgo(comment.created_at)}
+
+
{children}
+
+
{quickActions}
+
+
+ ); +}; 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 new file mode 100644 index 00000000000..2000721ee18 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx @@ -0,0 +1,175 @@ +import { FC, useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react"; +// hooks +import { useIssueDetail, useMention, useUser, useWorkspace } from "hooks/store"; +// components +import { IssueCommentBlock } from "./comment-block"; +import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor"; +import { IssueCommentReaction } from "../../reactions/issue-comment"; +// ui +import { CustomMenu } from "@plane/ui"; +// services +import { FileService } from "services/file.service"; +// types +import { TIssueComment } from "@plane/types"; +import { TActivityOperations } from "../root"; + +const fileService = new FileService(); + +type TIssueCommentCard = { + workspaceSlug: string; + commentId: string; + activityOperations: TActivityOperations; + ends: "top" | "bottom" | undefined; + showAccessSpecifier?: boolean; +}; + +export const IssueCommentCard: FC = (props) => { + const { workspaceSlug, commentId, activityOperations, ends, showAccessSpecifier = false } = props; + // hooks + const { + comment: { getCommentById }, + } = useIssueDetail(); + const { currentUser } = useUser(); + const { mentionHighlights, mentionSuggestions } = useMention(); + // refs + const editorRef = useRef(null); + const showEditorRef = useRef(null); + // state + const [isEditing, setIsEditing] = useState(false); + + const comment = getCommentById(commentId); + const workspaceStore = useWorkspace(); + const workspaceId = workspaceStore.getWorkspaceBySlug(comment?.workspace_detail?.slug as string)?.id as string; + + const { + formState: { isSubmitting }, + handleSubmit, + setFocus, + watch, + setValue, + } = useForm>({ + defaultValues: { comment_html: comment?.comment_html }, + }); + + const onEnter = (formData: Partial) => { + if (isSubmitting || !comment) return; + setIsEditing(false); + + activityOperations.updateComment(comment.id, formData); + + editorRef.current?.setEditorValue(formData.comment_html); + showEditorRef.current?.setEditorValue(formData.comment_html); + }; + + useEffect(() => { + isEditing && setFocus("comment_html"); + }, [isEditing, setFocus]); + + if (!comment || !currentUser) return <>; + return ( + + {currentUser?.id === comment.actor && ( + + setIsEditing(true)} className="flex items-center gap-1"> + + Edit comment + + {showAccessSpecifier && ( + <> + {comment.access === "INTERNAL" ? ( + activityOperations.updateComment(comment.id, { access: "EXTERNAL" })} + className="flex items-center gap-1" + > + + Switch to public comment + + ) : ( + activityOperations.updateComment(comment.id, { access: "INTERNAL" })} + className="flex items-center gap-1" + > + + Switch to private comment + + )} + + )} + activityOperations.removeComment(comment.id)} + className="flex items-center gap-1" + > + + Delete comment + + + )} + + } + ends={ends} + > + <> +
+
+ setValue("comment_html", comment_html)} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} + /> +
+
+ + +
+
+
+ {showAccessSpecifier && ( +
+ {comment.access === "INTERNAL" ? : } +
+ )} + + + +
+ +
+ ); +}; diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx new file mode 100644 index 00000000000..bb79c981769 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -0,0 +1,126 @@ +import { FC, useRef } from "react"; +import { useForm, Controller } from "react-hook-form"; +// components +import { LiteTextEditorWithRef } from "@plane/lite-text-editor"; +import { Button } from "@plane/ui"; +// services +import { FileService } from "services/file.service"; +// types +import { TActivityOperations } from "../root"; +import { TIssueComment } from "@plane/types"; +// icons +import { Globe2, Lock } from "lucide-react"; +import { useMention, useWorkspace } from "hooks/store"; + +const fileService = new FileService(); + +type TIssueCommentCreate = { + workspaceSlug: string; + activityOperations: TActivityOperations; + showAccessSpecifier?: boolean; +}; + +type commentAccessType = { + icon: any; + key: string; + label: "Private" | "Public"; +}; +const commentAccess: commentAccessType[] = [ + { + icon: Lock, + key: "INTERNAL", + label: "Private", + }, + { + icon: Globe2, + key: "EXTERNAL", + label: "Public", + }, +]; + +export const IssueCommentCreate: FC = (props) => { + const { workspaceSlug, activityOperations, showAccessSpecifier = false } = props; + const workspaceStore = useWorkspace(); + const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; + + const { mentionHighlights, mentionSuggestions } = useMention(); + + // refs + const editorRef = useRef(null); + // react hook form + const { + handleSubmit, + control, + formState: { isSubmitting }, + reset, + } = useForm>({ defaultValues: { comment_html: "

" } }); + + const onSubmit = async (formData: Partial) => { + await activityOperations.createComment(formData).finally(() => { + reset({ comment_html: "" }); + editorRef.current?.clearEditor(); + }); + }; + + return ( +
{ + // if (e.key === "Enter" && !e.shiftKey) { + // e.preventDefault(); + // // handleSubmit(onSubmit)(e); + // } + // }} + > + ( + ( + { + console.log("yo"); + handleSubmit(onSubmit)(e); + }} + cancelUploadImage={fileService.cancelUpload} + uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} + deleteFile={fileService.getDeleteImageFunction(workspaceId)} + restoreFile={fileService.getRestoreImageFunction(workspaceId)} + ref={editorRef} + value={!value ? "

" : value} + customClassName="p-2" + editorContentCustomClassNames="min-h-[35px]" + debouncedUpdatesEnabled={false} + onChange={(comment_json: Object, comment_html: string) => { + onChange(comment_html); + }} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} + commentAccessSpecifier={ + showAccessSpecifier + ? { accessValue: accessValue ?? "INTERNAL", onAccessChange, showAccessSpecifier, commentAccess } + : undefined + } + submitButton={ + + } + /> + )} + /> + )} + /> +
+ ); +}; diff --git a/web/components/issues/issue-detail/issue-activity/comments/root.tsx b/web/components/issues/issue-detail/issue-activity/comments/root.tsx new file mode 100644 index 00000000000..4e2775c4ae5 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/comments/root.tsx @@ -0,0 +1,40 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { IssueCommentCard } from "./comment-card"; +// types +import { TActivityOperations } from "../root"; + +type TIssueCommentRoot = { + workspaceSlug: string; + issueId: string; + activityOperations: TActivityOperations; + showAccessSpecifier?: boolean; +}; + +export const IssueCommentRoot: FC = observer((props) => { + const { workspaceSlug, issueId, activityOperations, showAccessSpecifier } = props; + // hooks + const { + comment: { getCommentsByIssueId }, + } = useIssueDetail(); + + const commentIds = getCommentsByIssueId(issueId); + if (!commentIds) return <>; + + return ( +
+ {commentIds.map((commentId, index) => ( + + ))} +
+ ); +}); diff --git a/web/components/issues/issue-detail/issue-activity/index.ts b/web/components/issues/issue-detail/issue-activity/index.ts new file mode 100644 index 00000000000..5c6634ce583 --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/index.ts @@ -0,0 +1,12 @@ +export * from "./root"; + +export * from "./activity-comment-root"; + +// activity +export * from "./activity/root"; +export * from "./activity/activity-list"; + +// issue comment +export * from "./comments/root"; +export * from "./comments/comment-card"; +export * from "./comments/comment-create"; diff --git a/web/components/issues/issue-detail/issue-activity/root.tsx b/web/components/issues/issue-detail/issue-activity/root.tsx new file mode 100644 index 00000000000..42d856b1e3d --- /dev/null +++ b/web/components/issues/issue-detail/issue-activity/root.tsx @@ -0,0 +1,176 @@ +import { FC, useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { History, LucideIcon, MessageCircle, ListRestart } from "lucide-react"; +// hooks +import { useIssueDetail, useProject } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { IssueActivityCommentRoot, IssueActivityRoot, IssueCommentRoot, IssueCommentCreate } from "./"; +// types +import { TIssueComment } from "@plane/types"; + +type TIssueActivity = { + workspaceSlug: string; + projectId: string; + issueId: string; +}; + +type TActivityTabs = "all" | "activity" | "comments"; + +const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [ + { + key: "all", + title: "All Activity", + icon: History, + }, + { + key: "activity", + title: "Updates", + icon: ListRestart, + }, + { + key: "comments", + title: "Comments", + icon: MessageCircle, + }, +]; + +export type TActivityOperations = { + createComment: (data: Partial) => Promise; + updateComment: (commentId: string, data: Partial) => Promise; + removeComment: (commentId: string) => Promise; +}; + +export const IssueActivity: FC = observer((props) => { + const { workspaceSlug, projectId, issueId } = props; + // hooks + const { createComment, updateComment, removeComment } = useIssueDetail(); + const { setToastAlert } = useToast(); + const { getProjectById } = useProject(); + // state + const [activityTab, setActivityTab] = useState("all"); + + const activityOperations: TActivityOperations = useMemo( + () => ({ + createComment: async (data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await createComment(workspaceSlug, projectId, issueId, data); + setToastAlert({ + title: "Comment created successfully.", + type: "success", + message: "Comment created successfully.", + }); + } catch (error) { + setToastAlert({ + title: "Comment creation failed.", + type: "error", + message: "Comment creation failed. Please try again later.", + }); + } + }, + updateComment: async (commentId: string, data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await updateComment(workspaceSlug, projectId, issueId, commentId, data); + setToastAlert({ + title: "Comment updated successfully.", + type: "success", + message: "Comment updated successfully.", + }); + } catch (error) { + setToastAlert({ + title: "Comment update failed.", + type: "error", + message: "Comment update failed. Please try again later.", + }); + } + }, + removeComment: async (commentId: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await removeComment(workspaceSlug, projectId, issueId, commentId); + setToastAlert({ + title: "Comment removed successfully.", + type: "success", + message: "Comment removed successfully.", + }); + } catch (error) { + setToastAlert({ + title: "Comment remove failed.", + type: "error", + message: "Comment remove failed. Please try again later.", + }); + } + }, + }), + [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment, setToastAlert] + ); + + const project = getProjectById(projectId); + if (!project) return <>; + + return ( +
+ {/* header */} +
Activity
+ + {/* rendering activity */} +
+
+ {activityTabs.map((tab) => ( +
setActivityTab(tab.key)} + > +
+ +
+
{tab.title}
+
+ ))} +
+ +
+ {activityTab === "all" ? ( +
+ + +
+ ) : activityTab === "activity" ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ ); +}); diff --git a/web/components/issues/issue-detail/label/create-label.tsx b/web/components/issues/issue-detail/label/create-label.tsx new file mode 100644 index 00000000000..72bc034f872 --- /dev/null +++ b/web/components/issues/issue-detail/label/create-label.tsx @@ -0,0 +1,160 @@ +import { FC, useState, Fragment, useEffect } from "react"; +import { Plus, X, Loader } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; +import { TwitterPicker } from "react-color"; +import { Popover, Transition } from "@headlessui/react"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// ui +import { Input } from "@plane/ui"; +// types +import { TLabelOperations } from "./root"; +import { IIssueLabel } from "@plane/types"; + +type ILabelCreate = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; + disabled?: boolean; +}; + +const defaultValues: Partial = { + name: "", + color: "#ff0000", +}; + +export const LabelCreate: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props; + // hooks + const { setToastAlert } = useToast(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // state + const [isCreateToggle, setIsCreateToggle] = useState(false); + const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle); + // react hook form + const { + handleSubmit, + formState: { errors, isSubmitting }, + reset, + control, + setFocus, + } = useForm>({ + defaultValues, + }); + + useEffect(() => { + if (!isCreateToggle) return; + + setFocus("name"); + reset(); + }, [isCreateToggle, reset, setFocus]); + + const handleLabel = async (formData: Partial) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + + try { + const issue = getIssueById(issueId); + const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData); + const currentLabels = [...(issue?.label_ids || []), labelResponse.id]; + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels }); + reset(defaultValues); + } catch (error) { + setToastAlert({ + title: "Label creation failed", + type: "error", + message: "Label creation failed. Please try again sometime later.", + }); + } + }; + + return ( + <> +
+
+ {isCreateToggle ? : } +
+
{isCreateToggle ? "Cancel" : "New"}
+
+ + {isCreateToggle && ( +
+
+ ( + + <> + + {value && value?.trim() !== "" && ( + + )} + + + + + onChange(value.hex)} /> + + + + + )} + /> +
+ ( + + )} + /> + + + + )} + + ); +}; diff --git a/web/components/issues/issue-detail/label/index.ts b/web/components/issues/issue-detail/label/index.ts new file mode 100644 index 00000000000..83f1e73bc63 --- /dev/null +++ b/web/components/issues/issue-detail/label/index.ts @@ -0,0 +1,7 @@ +export * from "./root"; + +export * from "./label-list"; +export * from "./label-list-item"; +export * from "./create-label"; +export * from "./select/root"; +export * from "./select/label-select"; diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx new file mode 100644 index 00000000000..3c3164c5a25 --- /dev/null +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -0,0 +1,57 @@ +import { FC } from "react"; +import { X } from "lucide-react"; +// types +import { TLabelOperations } from "./root"; +import { useIssueDetail, useLabel } from "hooks/store"; + +type TLabelListItem = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelId: string; + labelOperations: TLabelOperations; + disabled: boolean; +}; + +export const LabelListItem: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelId, labelOperations, disabled } = props; + // hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getLabelById } = useLabel(); + + const issue = getIssueById(issueId); + const label = getLabelById(labelId); + + const handleLabel = async () => { + if (issue && !disabled) { + const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId); + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels }); + } + }; + + if (!label) return <>; + return ( +
+
+
{label.name}
+ {!disabled && ( +
+ +
+ )} +
+ ); +}; diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx new file mode 100644 index 00000000000..fd714e002b6 --- /dev/null +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -0,0 +1,42 @@ +import { FC } from "react"; +// components +import { LabelListItem } from "./label-list-item"; +// hooks +import { useIssueDetail } from "hooks/store"; +// types +import { TLabelOperations } from "./root"; + +type TLabelList = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; + disabled: boolean; +}; + +export const LabelList: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props; + // hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const issue = getIssueById(issueId); + const issueLabels = issue?.label_ids || undefined; + + if (!issue || !issueLabels) return <>; + return ( + <> + {issueLabels.map((labelId) => ( + + ))} + + ); +}; diff --git a/web/components/issues/issue-detail/label/root.tsx b/web/components/issues/issue-detail/label/root.tsx new file mode 100644 index 00000000000..2ef9bec6e63 --- /dev/null +++ b/web/components/issues/issue-detail/label/root.tsx @@ -0,0 +1,99 @@ +import { FC, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./"; +// hooks +import { useIssueDetail, useLabel } from "hooks/store"; +// types +import { IIssueLabel, TIssue } from "@plane/types"; +import useToast from "hooks/use-toast"; + +export type TIssueLabel = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +export type TLabelOperations = { + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + createLabel: (workspaceSlug: string, projectId: string, data: Partial) => Promise; +}; + +export const IssueLabel: FC = observer((props) => { + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // hooks + const { updateIssue } = useIssueDetail(); + const { createLabel } = useLabel(); + const { setToastAlert } = useToast(); + + const labelOperations: TLabelOperations = useMemo( + () => ({ + updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { + try { + await updateIssue(workspaceSlug, projectId, issueId, data); + setToastAlert({ + title: "Issue updated successfully", + type: "success", + message: "Issue updated successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue update failed", + type: "error", + message: "Issue update failed", + }); + } + }, + createLabel: async (workspaceSlug: string, projectId: string, data: Partial) => { + try { + const labelResponse = await createLabel(workspaceSlug, projectId, data); + setToastAlert({ + title: "Label created successfully", + type: "success", + message: "Label created successfully", + }); + return labelResponse; + } catch (error) { + setToastAlert({ + title: "Label creation failed", + type: "error", + message: "Label creation failed", + }); + return error; + } + }, + }), + [updateIssue, createLabel, setToastAlert] + ); + + return ( +
+ + + {!disabled && ( + + )} + + {!disabled && ( + + )} +
+ ); +}); diff --git a/web/components/issues/issue-detail/label/select/label-select.tsx b/web/components/issues/issue-detail/label/select/label-select.tsx new file mode 100644 index 00000000000..4af089d5ee5 --- /dev/null +++ b/web/components/issues/issue-detail/label/select/label-select.tsx @@ -0,0 +1,159 @@ +import { Fragment, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { usePopper } from "react-popper"; +import { Check, Search, Tag } from "lucide-react"; +// hooks +import { useIssueDetail, useLabel } from "hooks/store"; +// components +import { Combobox } from "@headlessui/react"; + +export interface IIssueLabelSelect { + workspaceSlug: string; + projectId: string; + issueId: string; + onSelect: (_labelIds: string[]) => void; +} + +export const IssueLabelSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, onSelect } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { fetchProjectLabels, getProjectLabels } = useLabel(); + // states + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [query, setQuery] = useState(""); + + const issue = getIssueById(issueId); + const projectLabels = getProjectLabels(projectId); + + const fetchLabels = () => { + setIsLoading(true); + if (!projectLabels && workspaceSlug && projectId) + fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + }; + + const options = (projectLabels ?? []).map((label) => ({ + value: label.id, + query: label.name, + content: ( +
+ +
{label.name}
+
+ ), + })); + + const filteredOptions = + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const issueLabels = issue?.label_ids ?? []; + + const label = ( +
+
+ +
+
Select Label
+
+ ); + + if (!issue) return <>; + + return ( + <> + onSelect(value)} + multiple + > + + + + + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {isLoading ? ( +

Loading...

+ ) : filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ + selected ? "text-custom-text-100" : "text-custom-text-200" + }` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && ( +
+ +
+ )} + + )} +
+ )) + ) : ( + +

No matching results

+
+ )} +
+
+
+
+ + ); +}); diff --git a/web/components/issues/issue-detail/label/select/root.tsx b/web/components/issues/issue-detail/label/select/root.tsx new file mode 100644 index 00000000000..c31e1bc612c --- /dev/null +++ b/web/components/issues/issue-detail/label/select/root.tsx @@ -0,0 +1,24 @@ +import { FC } from "react"; +// components +import { IssueLabelSelect } from "./label-select"; +// types +import { TLabelOperations } from "../root"; + +type TIssueLabelSelectRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + labelOperations: TLabelOperations; +}; + +export const IssueLabelSelectRoot: FC = (props) => { + const { workspaceSlug, projectId, issueId, labelOperations } = props; + + const handleLabel = async (_labelIds: string[]) => { + await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: _labelIds }); + }; + + return ( + + ); +}; diff --git a/web/components/issues/issue-detail/links/create-update-link-modal.tsx b/web/components/issues/issue-detail/links/create-update-link-modal.tsx new file mode 100644 index 00000000000..fc9eb3838fc --- /dev/null +++ b/web/components/issues/issue-detail/links/create-update-link-modal.tsx @@ -0,0 +1,167 @@ +import { FC, useEffect, Fragment } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, Input } from "@plane/ui"; +// types +import type { TIssueLinkEditableFields } from "@plane/types"; +import { TLinkOperations } from "./root"; + +export type TLinkOperationsModal = Exclude; + +export type TIssueLinkCreateFormFieldOptions = TIssueLinkEditableFields & { + id?: string; +}; + +export type TIssueLinkCreateEditModal = { + isModalOpen: boolean; + handleModal: (modalToggle: boolean) => void; + linkOperations: TLinkOperationsModal; + preloadedData?: TIssueLinkCreateFormFieldOptions | null; +}; + +const defaultValues: TIssueLinkCreateFormFieldOptions = { + title: "", + url: "", +}; + +export const IssueLinkCreateUpdateModal: FC = (props) => { + // props + const { isModalOpen, handleModal, linkOperations, preloadedData } = props; + + // react hook form + const { + formState: { errors, isSubmitting }, + handleSubmit, + control, + reset, + } = useForm({ + defaultValues, + }); + + const onClose = () => { + handleModal(false); + const timeout = setTimeout(() => { + reset(preloadedData ? preloadedData : defaultValues); + clearTimeout(timeout); + }, 500); + }; + + const handleFormSubmit = async (formData: TIssueLinkCreateFormFieldOptions) => { + if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: formData.url }); + else await linkOperations.update(formData.id as string, { title: formData.title, url: formData.url }); + onClose(); + }; + + useEffect(() => { + reset({ ...defaultValues, ...preloadedData }); + }, [preloadedData, reset]); + + return ( + + + +
+ + +
+
+ + +
+
+
+ + {preloadedData?.id ? "Update Link" : "Add Link"} + +
+
+ + ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/issues/issue-detail/links/index.ts b/web/components/issues/issue-detail/links/index.ts new file mode 100644 index 00000000000..4a06c89af4c --- /dev/null +++ b/web/components/issues/issue-detail/links/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; + +export * from "./links"; +export * from "./link-detail"; diff --git a/web/components/issues/issue-detail/links/link-detail.tsx b/web/components/issues/issue-detail/links/link-detail.tsx new file mode 100644 index 00000000000..c92c13977f5 --- /dev/null +++ b/web/components/issues/issue-detail/links/link-detail.tsx @@ -0,0 +1,122 @@ +import { FC, useState } from "react"; +// hooks +import useToast from "hooks/use-toast"; +import { useIssueDetail } from "hooks/store"; +// ui +import { ExternalLinkIcon, Tooltip } from "@plane/ui"; +// icons +import { Pencil, Trash2, LinkIcon } from "lucide-react"; +// types +import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal"; +// helpers +import { calculateTimeAgo } from "helpers/date-time.helper"; +import { copyTextToClipboard } from "helpers/string.helper"; + +export type TIssueLinkDetail = { + linkId: string; + linkOperations: TLinkOperationsModal; + isNotAllowed: boolean; +}; + +export const IssueLinkDetail: FC = (props) => { + // props + const { linkId, linkOperations, isNotAllowed } = props; + // hooks + const { + toggleIssueLinkModal: toggleIssueLinkModalStore, + link: { getLinkById }, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + + // state + const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false); + const toggleIssueLinkModal = (modalToggle: boolean) => { + toggleIssueLinkModalStore(modalToggle); + setIsIssueLinkModalOpen(modalToggle); + }; + + const linkDetail = getLinkById(linkId); + if (!linkDetail) return <>; + + return ( +
+ + +
+
{ + copyTextToClipboard(linkDetail.url); + setToastAlert({ + type: "success", + title: "Link copied!", + message: "Link copied to clipboard", + }); + }} + > +
+ + + + + + {linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} + + +
+ + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+ +
+

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

+
+
+
+ ); +}; diff --git a/web/components/issues/issue-detail/links/links.tsx b/web/components/issues/issue-detail/links/links.tsx new file mode 100644 index 00000000000..368bddb9170 --- /dev/null +++ b/web/components/issues/issue-detail/links/links.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// computed +import { IssueLinkDetail } from "./link-detail"; +// hooks +import { useIssueDetail, useUser } from "hooks/store"; +import { TLinkOperations } from "./root"; + +export type TLinkOperationsModal = Exclude; + +export type TIssueLinkList = { + issueId: string; + linkOperations: TLinkOperationsModal; +}; + +export const IssueLinkList: FC = observer((props) => { + // props + const { issueId, linkOperations } = props; + // hooks + const { + link: { getLinksByIssueId }, + } = useIssueDetail(); + const { + membership: { currentProjectRole }, + } = useUser(); + + const issueLinks = getLinksByIssueId(issueId); + + if (!issueLinks) return <>; + + return ( +
+ {issueLinks && + issueLinks.length > 0 && + issueLinks.map((linkId) => ( + + ))} +
+ ); +}); diff --git a/web/components/issues/issue-detail/links/root.tsx b/web/components/issues/issue-detail/links/root.tsx new file mode 100644 index 00000000000..94124085a49 --- /dev/null +++ b/web/components/issues/issue-detail/links/root.tsx @@ -0,0 +1,133 @@ +import { FC, useCallback, useMemo, useState } from "react"; +import { Plus } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { IssueLinkCreateUpdateModal } from "./create-update-link-modal"; +import { IssueLinkList } from "./links"; +// types +import { TIssueLink } from "@plane/types"; + +export type TLinkOperations = { + create: (data: Partial) => Promise; + update: (linkId: string, data: Partial) => Promise; + remove: (linkId: string) => Promise; +}; + +export type TIssueLinkRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled?: boolean; +}; + +export const IssueLinkRoot: FC = (props) => { + // props + const { workspaceSlug, projectId, issueId, disabled = false } = props; + // hooks + const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail(); + // state + const [isIssueLinkModal, setIsIssueLinkModal] = useState(false); + const toggleIssueLinkModal = useCallback( + (modalToggle: boolean) => { + toggleIssueLinkModalStore(modalToggle); + setIsIssueLinkModal(modalToggle); + }, + [toggleIssueLinkModalStore] + ); + + const { setToastAlert } = useToast(); + + const handleLinkOperations: TLinkOperations = useMemo( + () => ({ + create: async (data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await createLink(workspaceSlug, projectId, issueId, data); + setToastAlert({ + message: "The link has been successfully created", + type: "success", + title: "Link created", + }); + toggleIssueLinkModal(false); + } catch (error) { + setToastAlert({ + message: "The link could not be created", + type: "error", + title: "Link not created", + }); + } + }, + update: async (linkId: string, data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await updateLink(workspaceSlug, projectId, issueId, linkId, data); + setToastAlert({ + message: "The link has been successfully updated", + type: "success", + title: "Link updated", + }); + toggleIssueLinkModal(false); + } catch (error) { + setToastAlert({ + message: "The link could not be updated", + type: "error", + title: "Link not updated", + }); + } + }, + remove: async (linkId: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); + await removeLink(workspaceSlug, projectId, issueId, linkId); + setToastAlert({ + message: "The link has been successfully removed", + type: "success", + title: "Link removed", + }); + toggleIssueLinkModal(false); + } catch (error) { + setToastAlert({ + message: "The link could not be removed", + type: "error", + title: "Link not removed", + }); + } + }, + }), + [workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert, toggleIssueLinkModal] + ); + + return ( + <> + + +
+
+

Links

+ {!disabled && ( + + )} +
+ +
+ +
+
+ + ); +}; diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx new file mode 100644 index 00000000000..07552580177 --- /dev/null +++ b/web/components/issues/issue-detail/main-content.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail, useProjectState, useUser } from "hooks/store"; +// components +import { IssueDescriptionForm, IssueAttachmentRoot, IssueUpdateStatus } from "components/issues"; +import { IssueParentDetail } from "./parent"; +import { IssueReaction } from "./reactions"; +import { SubIssuesRoot } from "../sub-issues"; +import { IssueActivity } from "./issue-activity"; +// ui +import { StateGroupIcon } from "@plane/ui"; +// types +import { TIssueOperations } from "./root"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + is_editable: boolean; +}; + +export const IssueMainContent: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, is_editable } = props; + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + // hooks + const { currentUser } = useUser(); + const { projectStates } = useProjectState(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + + const issue = getIssueById(issueId); + if (!issue) return <>; + + const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + + return ( + <> +
+ {issue.parent_id && ( + + )} + +
+ {currentIssueState && ( + + )} + +
+ + setIsSubmitting(value)} + isSubmitting={isSubmitting} + issue={issue} + issueOperations={issueOperations} + disabled={!is_editable} + /> + + {currentUser && ( + + )} + + {currentUser && ( + + )} +
+ + + + + + ); +}); diff --git a/web/components/issues/issue-detail/module-select.tsx b/web/components/issues/issue-detail/module-select.tsx new file mode 100644 index 00000000000..1c4d80168e4 --- /dev/null +++ b/web/components/issues/issue-detail/module-select.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useIssueDetail } from "hooks/store"; +// components +import { ModuleDropdown } from "components/dropdowns"; +// ui +import { Spinner } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import type { TIssueOperations } from "./root"; + +type TIssueModuleSelect = { + className?: string; + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + disabled?: boolean; +}; + +export const IssueModuleSelect: React.FC = observer((props) => { + const { className = "", workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props; + // states + const [isUpdating, setIsUpdating] = useState(false); + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + const disableSelect = disabled || isUpdating; + + const handleIssueModuleChange = async (moduleIds: string[]) => { + if (!issue || !issue.module_ids) return; + + setIsUpdating(true); + + if (moduleIds.length === 0) + await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, issue.module_ids); + else if (moduleIds.length > issue.module_ids.length) { + const newModuleIds = moduleIds.filter((m) => !issue.module_ids?.includes(m)); + await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, newModuleIds); + } else if (moduleIds.length < issue.module_ids.length) { + const removedModuleIds = issue.module_ids.filter((m) => !moduleIds.includes(m)); + await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, removedModuleIds); + } + + setIsUpdating(false); + }; + + return ( +
+ + {isUpdating && } +
+ ); +}); diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx new file mode 100644 index 00000000000..9a1aa48ad87 --- /dev/null +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { Pencil, X } from "lucide-react"; +// hooks +import { useIssueDetail, useProject } from "hooks/store"; +// components +import { ParentIssuesListModal } from "components/issues"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TIssueOperations } from "./root"; + +type TIssueParentSelect = { + className?: string; + disabled?: boolean; + issueId: string; + issueOperations: TIssueOperations; + projectId: string; + workspaceSlug: string; +}; + +export const IssueParentSelect: React.FC = observer((props) => { + const { className = "", disabled = false, issueId, issueOperations, projectId, workspaceSlug } = props; + // store hooks + const { getProjectById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; + const parentIssueProjectDetails = + parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined; + + const handleParentIssue = async (_issueId: string | null = null) => { + try { + await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); + await issueOperations.fetch(workspaceSlug, projectId, issueId); + toggleParentIssueModal(false); + } catch (error) { + console.error("something went wrong while fetching the issue"); + } + }; + + if (!issue) return <>; + + return ( + <> + toggleParentIssueModal(false)} + onChange={(issue: any) => handleParentIssue(issue?.id)} + /> + + + ); +}); diff --git a/web/components/issues/issue-detail/parent/index.ts b/web/components/issues/issue-detail/parent/index.ts new file mode 100644 index 00000000000..1b5a9674958 --- /dev/null +++ b/web/components/issues/issue-detail/parent/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; + +export * from "./siblings"; +export * from "./sibling-item"; diff --git a/web/components/issues/issue-detail/parent/root.tsx b/web/components/issues/issue-detail/parent/root.tsx new file mode 100644 index 00000000000..2176ccecce9 --- /dev/null +++ b/web/components/issues/issue-detail/parent/root.tsx @@ -0,0 +1,72 @@ +import { FC } from "react"; +import Link from "next/link"; +import { MinusCircle } from "lucide-react"; +// component +import { IssueParentSiblings } from "./siblings"; +// ui +import { CustomMenu } from "@plane/ui"; +// hooks +import { useIssueDetail, useIssues, useProject, useProjectState } from "hooks/store"; +// types +import { TIssueOperations } from "../root"; +import { TIssue } from "@plane/types"; + +export type TIssueParentDetail = { + workspaceSlug: string; + projectId: string; + issueId: string; + issue: TIssue; + issueOperations: TIssueOperations; +}; + +export const IssueParentDetail: FC = (props) => { + const { workspaceSlug, projectId, issueId, issue, issueOperations } = props; + // hooks + const { issueMap } = useIssues(); + const { peekIssue } = useIssueDetail(); + const { getProjectById } = useProject(); + const { getProjectStates } = useProjectState(); + + const parentIssue = issueMap?.[issue.parent_id || ""] || undefined; + + const issueParentState = getProjectStates(parentIssue?.project_id)?.find( + (state) => state?.id === parentIssue?.state_id + ); + const stateColor = issueParentState?.color || undefined; + + if (!parentIssue) return <>; + + return ( + <> +
+ +
+
+ + + {getProjectById(parentIssue.project_id)?.identifier}-{parentIssue?.sequence_id} + +
+ {(parentIssue?.name ?? "").substring(0, 50)} +
+ + + +
+ Sibling issues +
+ + + + issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} + className="flex items-center gap-2 py-2 text-red-500" + > + + Remove Parent Issue + +
+
+ + ); +}; diff --git a/web/components/issues/issue-detail/parent/sibling-item.tsx b/web/components/issues/issue-detail/parent/sibling-item.tsx new file mode 100644 index 00000000000..cbcf4741bbb --- /dev/null +++ b/web/components/issues/issue-detail/parent/sibling-item.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +import Link from "next/link"; +// ui +import { CustomMenu, LayersIcon } from "@plane/ui"; +// hooks +import { useIssueDetail, useProject } from "hooks/store"; + +type TIssueParentSiblingItem = { + issueId: string; +}; + +export const IssueParentSiblingItem: FC = (props) => { + const { issueId } = props; + // hooks + const { getProjectById } = useProject(); + const { + peekIssue, + issue: { getIssueById }, + } = useIssueDetail(); + + const issueDetail = (issueId && getIssueById(issueId)) || undefined; + if (!issueDetail) return <>; + + const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined; + + return ( + <> + + + + {projectDetails?.identifier}-{issueDetail.sequence_id} + + + + ); +}; diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx new file mode 100644 index 00000000000..45eca81d454 --- /dev/null +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -0,0 +1,50 @@ +import { FC } from "react"; +import useSWR from "swr"; +// components +import { IssueParentSiblingItem } from "./sibling-item"; +// hooks +import { useIssueDetail } from "hooks/store"; +// types +import { TIssue } from "@plane/types"; + +export type TIssueParentSiblings = { + currentIssue: TIssue; + parentIssue: TIssue; +}; + +export const IssueParentSiblings: FC = (props) => { + const { currentIssue, parentIssue } = props; + // hooks + const { + peekIssue, + fetchSubIssues, + subIssues: { subIssuesByIssueId }, + } = useIssueDetail(); + + const { isLoading } = useSWR( + peekIssue && parentIssue && parentIssue.project_id + ? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` + : null, + peekIssue && parentIssue && parentIssue.project_id + ? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id) + : null + ); + + const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined; + + return ( +
+ {isLoading ? ( +
+ Loading +
+ ) : subIssueIds && subIssueIds.length > 0 ? ( + subIssueIds.map((issueId) => currentIssue.id != issueId && ) + ) : ( +
+ No sibling issues +
+ )} +
+ ); +}; diff --git a/web/components/issues/issue-detail/reactions/index.ts b/web/components/issues/issue-detail/reactions/index.ts new file mode 100644 index 00000000000..8dc6f05bd64 --- /dev/null +++ b/web/components/issues/issue-detail/reactions/index.ts @@ -0,0 +1,4 @@ +export * from "./reaction-selector"; + +export * from "./issue"; +// export * from "./issue-comment"; diff --git a/web/components/issues/issue-detail/reactions/issue-comment.tsx b/web/components/issues/issue-detail/reactions/issue-comment.tsx new file mode 100644 index 00000000000..30a8621e4f4 --- /dev/null +++ b/web/components/issues/issue-detail/reactions/issue-comment.tsx @@ -0,0 +1,118 @@ +import { FC, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { ReactionSelector } from "./reaction-selector"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { IUser } from "@plane/types"; +import { renderEmoji } from "helpers/emoji.helper"; + +export type TIssueCommentReaction = { + workspaceSlug: string; + projectId: string; + commentId: string; + currentUser: IUser; +}; + +export const IssueCommentReaction: FC = observer((props) => { + const { workspaceSlug, projectId, commentId, currentUser } = props; + + // hooks + const { + commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser }, + createCommentReaction, + removeCommentReaction, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + + const reactionIds = getCommentReactionsByCommentId(commentId); + const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction); + + const issueCommentReactionOperations = useMemo( + () => ({ + create: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields"); + await createCommentReaction(workspaceSlug, projectId, commentId, reaction); + setToastAlert({ + title: "Reaction created successfully", + type: "success", + message: "Reaction created successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction creation failed", + type: "error", + message: "Reaction creation failed", + }); + } + }, + remove: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields"); + removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id); + setToastAlert({ + title: "Reaction removed successfully", + type: "success", + message: "Reaction removed successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction remove failed", + type: "error", + message: "Reaction remove failed", + }); + } + }, + react: async (reaction: string) => { + if (userReactions.includes(reaction)) await issueCommentReactionOperations.remove(reaction); + else await issueCommentReactionOperations.create(reaction); + }, + }), + [ + workspaceSlug, + projectId, + commentId, + currentUser, + createCommentReaction, + removeCommentReaction, + setToastAlert, + userReactions, + ] + ); + + return ( +
+ + + {reactionIds && + Object.keys(reactionIds || {}).map( + (reaction) => + reactionIds[reaction]?.length > 0 && ( + <> + + + ) + )} +
+ ); +}); diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx new file mode 100644 index 00000000000..d6b33e36bb8 --- /dev/null +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -0,0 +1,103 @@ +import { FC, useMemo } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { ReactionSelector } from "./reaction-selector"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { IUser } from "@plane/types"; +import { renderEmoji } from "helpers/emoji.helper"; + +export type TIssueReaction = { + workspaceSlug: string; + projectId: string; + issueId: string; + currentUser: IUser; +}; + +export const IssueReaction: FC = observer((props) => { + const { workspaceSlug, projectId, issueId, currentUser } = props; + // hooks + const { + reaction: { getReactionsByIssueId, reactionsByUser }, + createReaction, + removeReaction, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + + const reactionIds = getReactionsByIssueId(issueId); + const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction); + + const issueReactionOperations = useMemo( + () => ({ + create: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await createReaction(workspaceSlug, projectId, issueId, reaction); + setToastAlert({ + title: "Reaction created successfully", + type: "success", + message: "Reaction created successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction creation failed", + type: "error", + message: "Reaction creation failed", + }); + } + }, + remove: async (reaction: string) => { + try { + if (!workspaceSlug || !projectId || !issueId || !currentUser?.id) throw new Error("Missing fields"); + await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id); + setToastAlert({ + title: "Reaction removed successfully", + type: "success", + message: "Reaction removed successfully", + }); + } catch (error) { + setToastAlert({ + title: "Reaction remove failed", + type: "error", + message: "Reaction remove failed", + }); + } + }, + react: async (reaction: string) => { + if (userReactions.includes(reaction)) await issueReactionOperations.remove(reaction); + else await issueReactionOperations.create(reaction); + }, + }), + [workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, setToastAlert, userReactions] + ); + + return ( +
+ + + {reactionIds && + Object.keys(reactionIds || {}).map( + (reaction) => + reactionIds[reaction]?.length > 0 && ( + <> + + + ) + )} +
+ ); +}); diff --git a/web/components/core/reaction-selector.tsx b/web/components/issues/issue-detail/reactions/reaction-selector.tsx similarity index 100% rename from web/components/core/reaction-selector.tsx rename to web/components/issues/issue-detail/reactions/reaction-selector.tsx diff --git a/web/components/issues/issue-detail/relation-select.tsx b/web/components/issues/issue-detail/relation-select.tsx new file mode 100644 index 00000000000..67bba869742 --- /dev/null +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -0,0 +1,174 @@ +import React from "react"; +import Link from "next/link"; +import { observer } from "mobx-react-lite"; +import { CircleDot, CopyPlus, Pencil, X, XCircle } from "lucide-react"; +// hooks +import { useIssueDetail, useIssues, useProject } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { ExistingIssuesListModal } from "components/core"; +// ui +import { RelatedIcon, Tooltip } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TIssueRelationTypes, ISearchIssueResponse } from "@plane/types"; + +export type TRelationObject = { className: string; icon: (size: number) => React.ReactElement; placeholder: string }; + +export const issueRelationObject: Record = { + relates_to: { + className: "bg-custom-background-80 text-custom-text-200", + icon: (size) => , + placeholder: "Add related issues", + }, + blocking: { + className: "bg-yellow-500/20 text-yellow-700", + icon: (size) => , + placeholder: "None", + }, + blocked_by: { + className: "bg-red-500/20 text-red-700", + icon: (size) => , + placeholder: "None", + }, + duplicate: { + className: "bg-custom-background-80 text-custom-text-200", + icon: (size) => , + placeholder: "None", + }, +}; + +type TIssueRelationSelect = { + className?: string; + workspaceSlug: string; + projectId: string; + issueId: string; + relationKey: TIssueRelationTypes; + disabled?: boolean; +}; + +export const IssueRelationSelect: React.FC = observer((props) => { + const { className = "", workspaceSlug, projectId, issueId, relationKey, disabled = false } = props; + // hooks + const { getProjectById } = useProject(); + const { + createRelation, + removeRelation, + relation: { getRelationByIssueIdRelationType }, + isRelationModalOpen, + toggleRelationModal, + } = useIssueDetail(); + const { issueMap } = useIssues(); + // toast alert + const { setToastAlert } = useToast(); + + const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey); + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one issue.", + }); + return; + } + + await createRelation( + workspaceSlug, + projectId, + issueId, + relationKey, + data.map((i) => i.id) + ); + + toggleRelationModal(null); + }; + + if (!relationIssueIds) return null; + + return ( + <> + toggleRelationModal(null)} + searchParams={{ issue_relation: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + workspaceLevelToggle + /> + + + ); +}); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx new file mode 100644 index 00000000000..fda73e94ffe --- /dev/null +++ b/web/components/issues/issue-detail/root.tsx @@ -0,0 +1,274 @@ +import { FC, useMemo } from "react"; +import { useRouter } from "next/router"; +// components +import { IssuePeekOverview } from "components/issues"; +import { IssueMainContent } from "./main-content"; +import { IssueDetailsSidebar } from "./sidebar"; +// ui +import { EmptyState } from "components/common"; +// images +import emptyIssue from "public/empty-state/issue.svg"; +// hooks +import { useIssueDetail, useIssues, useUser } from "hooks/store"; +import useToast from "hooks/use-toast"; +// types +import { TIssue } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { EIssuesStoreType } from "constants/issue"; + +export type TIssueOperations = { + fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + update: ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial, + showToast?: boolean + ) => Promise; + remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; + removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeIssueFromModule?: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueId: string + ) => Promise; + removeModulesFromIssue?: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => Promise; +}; + +export type TIssueDetailRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + is_archived?: boolean; +}; + +export const IssueDetailRoot: FC = (props) => { + const { workspaceSlug, projectId, issueId, is_archived = false } = props; + // router + const router = useRouter(); + // hooks + const { + issue: { getIssueById }, + fetchIssue, + updateIssue, + removeIssue, + addIssueToCycle, + removeIssueFromCycle, + addModulesToIssue, + removeIssueFromModule, + removeModulesFromIssue, + } = useIssueDetail(); + const { + issues: { removeIssue: removeArchivedIssue }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { setToastAlert } = useToast(); + const { + membership: { currentProjectRole }, + } = useUser(); + + const issueOperations: TIssueOperations = useMemo( + () => ({ + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await fetchIssue(workspaceSlug, projectId, issueId); + } catch (error) { + console.error("Error fetching the parent issue"); + } + }, + update: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial, + showToast: boolean = true + ) => { + try { + await updateIssue(workspaceSlug, projectId, issueId, data); + if (showToast) { + setToastAlert({ + title: "Issue updated successfully", + type: "success", + message: "Issue updated successfully", + }); + } + } catch (error) { + setToastAlert({ + title: "Issue update failed", + type: "error", + message: "Issue update failed", + }); + } + }, + remove: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId); + else await removeIssue(workspaceSlug, projectId, issueId); + setToastAlert({ + title: "Issue deleted successfully", + type: "success", + message: "Issue deleted successfully", + }); + } catch (error) { + setToastAlert({ + title: "Issue delete failed", + type: "error", + message: "Issue delete failed", + }); + } + }, + addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { + try { + await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); + setToastAlert({ + title: "Cycle added to issue successfully", + type: "success", + message: "Issue added to issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Cycle add to issue failed", + type: "error", + message: "Cycle add to issue failed", + }); + } + }, + removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { + try { + await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + setToastAlert({ + title: "Cycle removed from issue successfully", + type: "success", + message: "Cycle removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Cycle remove from issue failed", + type: "error", + message: "Cycle remove from issue failed", + }); + } + }, + addModulesToIssue: async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + try { + await addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds); + setToastAlert({ + title: "Module added to issue successfully", + type: "success", + message: "Module added to issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module add to issue failed", + type: "error", + message: "Module add to issue failed", + }); + } + }, + removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { + try { + await removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); + setToastAlert({ + title: "Module removed from issue successfully", + type: "success", + message: "Module removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module remove from issue failed", + type: "error", + message: "Module remove from issue failed", + }); + } + }, + removeModulesFromIssue: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => { + try { + await removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds); + setToastAlert({ + type: "success", + title: "Successful!", + message: "Issue removed from module successfully.", + }); + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be removed from module. Please try again.", + }); + } + }, + }), + [ + is_archived, + fetchIssue, + updateIssue, + removeIssue, + removeArchivedIssue, + addIssueToCycle, + removeIssueFromCycle, + addModulesToIssue, + removeIssueFromModule, + removeModulesFromIssue, + setToastAlert, + ] + ); + + // issue details + const issue = getIssueById(issueId); + // checking if issue is editable, based on user role + const is_editable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + return ( + <> + {!issue ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/issues`), + }} + /> + ) : ( +
+
+ +
+
+ +
+
+ )} + + {/* peek overview */} + + + ); +}; diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx new file mode 100644 index 00000000000..668d3538fd1 --- /dev/null +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -0,0 +1,425 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { + LinkIcon, + Signal, + Tag, + Trash2, + Triangle, + LayoutPanelTop, + XCircle, + CircleDot, + CopyPlus, + CalendarClock, + CalendarCheck2, +} from "lucide-react"; +// hooks +import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { + DeleteIssueModal, + IssueLinkRoot, + IssueRelationSelect, + IssueCycleSelect, + IssueModuleSelect, + IssueParentSelect, + IssueLabel, +} from "components/issues"; +import { IssueSubscription } from "./subscription"; +import { + DateDropdown, + EstimateDropdown, + PriorityDropdown, + ProjectMemberDropdown, + StateDropdown, +} from "components/dropdowns"; +// icons +import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { copyTextToClipboard } from "helpers/string.helper"; +// types +import type { TIssueOperations } from "./root"; + +type Props = { + workspaceSlug: string; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + is_archived: boolean; + is_editable: boolean; +}; + +export const IssueDetailsSidebar: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props; + // router + const router = useRouter(); + const { inboxIssueId } = router.query; + // store hooks + const { getProjectById } = useProject(); + const { currentUser } = useUser(); + const { projectStates } = useProjectState(); + const { areEstimatesEnabledForCurrentProject } = useEstimate(); + const { setToastAlert } = useToast(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // states + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + + const issue = getIssueById(issueId); + if (!issue) return <>; + + const handleCopyText = () => { + const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + + const projectDetails = issue ? getProjectById(issue.project_id) : null; + + const minDate = issue.start_date ? new Date(issue.start_date) : null; + minDate?.setDate(minDate.getDate()); + + const maxDate = issue.target_date ? new Date(issue.target_date) : null; + maxDate?.setDate(maxDate.getDate()); + + const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + + return ( + <> + {workspaceSlug && projectId && issue && ( + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issue} + onSubmit={async () => { + await issueOperations.remove(workspaceSlug, projectId, issueId); + router.push(`/${workspaceSlug}/projects/${projectId}/issues`); + }} + /> + )} + +
+
+
+ {currentIssueState ? ( + + ) : inboxIssueId ? ( + + ) : null} +

+ {projectDetails?.identifier}-{issue?.sequence_id} +

+
+ +
+ {currentUser && !is_archived && ( + + )} + + + + {is_editable && ( + + )} +
+
+ +
+
Properties
+ {/* TODO: render properties using a common component */} +
+
+
+ + State +
+ issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} + projectId={projectId?.toString() ?? ""} + disabled={!is_editable} + buttonVariant="transparent-with-text" + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName="text-sm" + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
+ +
+
+ + Assignees +
+ issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} + disabled={!is_editable} + projectId={projectId?.toString() ?? ""} + placeholder="Add assignees" + multiple + buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"} + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm justify-between ${ + issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400" + }`} + hideIcon={issue.assignee_ids?.length === 0} + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
+ +
+
+ + Priority +
+ issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} + disabled={!is_editable} + buttonVariant="border-with-text" + className="w-3/5 flex-grow rounded px-2 hover:bg-custom-background-80" + buttonContainerClassName="w-full text-left" + buttonClassName="w-min h-auto whitespace-nowrap" + /> +
+ +
+
+ + Start date +
+ + issueOperations.update(workspaceSlug, projectId, issueId, { + start_date: val ? renderFormattedPayloadDate(val) : null, + }) + } + maxDate={maxDate ?? undefined} + disabled={!is_editable} + buttonVariant="transparent-with-text" + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + // TODO: add this logic + // showPlaceholderIcon + /> +
+ +
+
+ + Due date +
+ + issueOperations.update(workspaceSlug, projectId, issueId, { + target_date: val ? renderFormattedPayloadDate(val) : null, + }) + } + minDate={minDate ?? undefined} + disabled={!is_editable} + 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"}`} + hideIcon + clearIconClassName="h-3 w-3 hidden group-hover:inline" + // TODO: add this logic + // showPlaceholderIcon + /> +
+ + {areEstimatesEnabledForCurrentProject && ( +
+
+ + Estimate +
+ issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })} + projectId={projectId} + disabled={!is_editable} + buttonVariant="transparent-with-text" + className="w-3/5 flex-grow group" + buttonContainerClassName="w-full text-left" + buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`} + placeholder="None" + hideIcon + dropdownArrow + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + /> +
+ )} + + {projectDetails?.module_view && ( +
+
+ + Module +
+ +
+ )} + + {projectDetails?.cycle_view && ( +
+
+ + Cycle +
+ +
+ )} + +
+
+ + Parent +
+ +
+ +
+
+ + Relates to +
+ +
+ +
+
+ + Blocking +
+ +
+ +
+
+ + Blocked by +
+ +
+ +
+
+ + Duplicate of +
+ +
+
+ +
+
+ + Labels +
+
+ +
+
+ + +
+
+ + ); +}); diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx new file mode 100644 index 00000000000..b57e75bedae --- /dev/null +++ b/web/components/issues/issue-detail/subscription.tsx @@ -0,0 +1,64 @@ +import { FC, useState } from "react"; +import { Bell } from "lucide-react"; +import { observer } from "mobx-react-lite"; +// UI +import { Button } from "@plane/ui"; +// hooks +import { useIssueDetail } from "hooks/store"; +import useToast from "hooks/use-toast"; + +export type TIssueSubscription = { + workspaceSlug: string; + projectId: string; + issueId: string; +}; + +export const IssueSubscription: FC = observer((props) => { + const { workspaceSlug, projectId, issueId } = props; + // hooks + const { + subscription: { getSubscriptionByIssueId }, + createSubscription, + removeSubscription, + } = useIssueDetail(); + const { setToastAlert } = useToast(); + // state + const [loading, setLoading] = useState(false); + + const subscription = getSubscriptionByIssueId(issueId); + + const handleSubscription = async () => { + setLoading(true); + try { + if (subscription?.subscribed) 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.!`, + }); + setLoading(false); + } catch (error) { + setLoading(false); + setToastAlert({ + type: "error", + title: "Error", + message: "Something went wrong. Please try again later.", + }); + } + }; + + return ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index b080bc838a8..7cb53ad3934 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -3,56 +3,46 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { DragDropContext, DropResult } from "@hello-pangea/dnd"; // components -import { CalendarChart, IssuePeekOverview } from "components/issues"; +import { CalendarChart } from "components/issues"; // hooks import useToast from "hooks/use-toast"; // types -import { IIssue } from "types"; -import { - ICycleIssuesFilterStore, - ICycleIssuesStore, - IModuleIssuesFilterStore, - IModuleIssuesStore, - IProjectIssuesFilterStore, - IProjectIssuesStore, - IViewIssuesFilterStore, - IViewIssuesStore, -} from "store/issues"; +import { TGroupedIssues, TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; import { EIssueActions } from "../types"; -import { IGroupedIssues } from "store/issues/types"; +import { handleDragDrop } from "./utils"; +import { useIssues } from "hooks/store"; +import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; +import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; +import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; +import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; interface IBaseCalendarRoot { - issueStore: IProjectIssuesStore | IModuleIssuesStore | ICycleIssuesStore | IViewIssuesStore; - issuesFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore; + issueStore: IProjectIssues | IModuleIssues | ICycleIssues | IProjectViewIssues; + issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; QuickActions: FC; issueActions: { - [EIssueActions.DELETE]: (issue: IIssue) => Promise; - [EIssueActions.UPDATE]?: (issue: IIssue) => Promise; - [EIssueActions.REMOVE]?: (issue: IIssue) => Promise; + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; viewId?: string; - handleDragDrop: (source: any, destination: any, issues: any, issueWithIds: any) => Promise; } export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { - const { issueStore, issuesFilterStore, QuickActions, issueActions, viewId, handleDragDrop } = props; + const { issueStore, issuesFilterStore, QuickActions, issueActions, viewId } = props; // router const router = useRouter(); - const { workspaceSlug, peekIssueId, peekProjectId } = router.query; + const { workspaceSlug, projectId } = router.query; // hooks const { setToastAlert } = useToast(); + const { issueMap } = useIssues(); const displayFilters = issuesFilterStore.issueFilters?.displayFilters; - const issues = issueStore.getIssues; - const groupedIssueIds = (issueStore.getIssuesIds ?? {}) as IGroupedIssues; + const groupedIssueIds = (issueStore.groupedIssueIds ?? {}) as TGroupedIssues; const onDragEnd = async (result: DropResult) => { if (!result) return; @@ -64,7 +54,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { if (result.destination.droppableId === result.source.droppableId) return; if (handleDragDrop) { - await handleDragDrop(result.source, result.destination, issues, groupedIssueIds).catch((err) => { + await handleDragDrop( + result.source, + result.destination, + workspaceSlug?.toString(), + projectId?.toString(), + issueStore, + issueMap, + groupedIssueIds, + viewId + ).catch((err) => { setToastAlert({ title: "Error", type: "error", @@ -75,7 +74,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { }; const handleIssues = useCallback( - async (date: string, issue: IIssue, action: EIssueActions) => { + async (date: string, issue: TIssue, action: EIssueActions) => { if (issueActions[action]) { await issueActions[action]!(issue); } @@ -89,7 +88,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { { />
- {workspaceSlug && peekIssueId && peekProjectId && ( - - await handleIssues(issueToUpdate.target_date ?? "", issueToUpdate as IIssue, action) - } - /> - )} ); }); diff --git a/web/components/issues/issue-layouts/calendar/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index a2626b0237e..1652aa89bf1 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -1,60 +1,56 @@ import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useUser } from "hooks/store"; // components import { CalendarHeader, CalendarWeekDays, CalendarWeekHeader } from "components/issues"; // ui import { Spinner } from "@plane/ui"; // types import { ICalendarWeek } from "./types"; -import { IIssue } from "types"; -import { IGroupedIssues, IIssueResponse } from "store/issues/types"; -import { - ICycleIssuesFilterStore, - IModuleIssuesFilterStore, - IProjectIssuesFilterStore, - IViewIssuesFilterStore, -} from "store/issues"; +import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { useCalendarView } from "hooks/store/use-calendar-view"; +import { EIssuesStoreType } from "constants/issue"; +import { ICycleIssuesFilter } from "store/issue/cycle"; +import { IModuleIssuesFilter } from "store/issue/module"; +import { IProjectIssuesFilter } from "store/issue/project"; +import { IProjectViewIssuesFilter } from "store/issue/project-views"; type Props = { - issuesFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore; - issues: IIssueResponse | undefined; - groupedIssueIds: IGroupedIssues; + issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issues: TIssueMap | undefined; + groupedIssueIds: TGroupedIssues; layout: "month" | "week" | undefined; showWeekends: boolean; - quickActions: (issue: IIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; }; export const CalendarChart: React.FC = observer((props) => { const { issuesFilterStore, issues, groupedIssueIds, layout, showWeekends, quickActions, quickAddCallback, viewId } = props; - + // store hooks + const { + issues: { viewFlags }, + } = useIssues(EIssuesStoreType.PROJECT); + const issueCalendarView = useCalendarView(); const { - calendar: calendarStore, - projectIssues: issueStore, - user: { currentProjectRole }, - } = useMobxStore(); + membership: { currentProjectRole }, + } = useUser(); - const { enableIssueCreation } = issueStore?.viewFlags || {}; - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const { enableIssueCreation } = viewFlags || {}; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const calendarPayload = calendarStore.calendarPayload; + const calendarPayload = issueCalendarView.calendarPayload; - const allWeeksOfActiveMonth = calendarStore.allWeeksOfActiveMonth; + const allWeeksOfActiveMonth = issueCalendarView.allWeeksOfActiveMonth; if (!calendarPayload) return ( @@ -66,7 +62,7 @@ export const CalendarChart: React.FC = observer((props) => { return ( <>
- +
{layout === "month" && ( @@ -91,7 +87,7 @@ export const CalendarChart: React.FC = observer((props) => { {layout === "week" && ( React.ReactNode; + issues: TIssueMap | undefined; + groupedIssueIds: TGroupedIssues; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; }; @@ -52,7 +45,9 @@ export const CalendarDayTile: React.FC = observer((props) => { const [showAllIssues, setShowAllIssues] = useState(false); const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; - const issueIdList = groupedIssueIds ? groupedIssueIds[renderDateFormat(date.date)] : null; + const formattedDatePayload = renderFormattedPayloadDate(date.date); + if (!formattedDatePayload) return null; + const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null; const totalIssues = issueIdList?.length ?? 0; return ( @@ -78,7 +73,7 @@ export const CalendarDayTile: React.FC = observer((props) => { {/* content */}
- + {(provided, snapshot) => (
= observer((props) => {
{ - const { calendar: calendarStore, issueFilter: issueFilterStore } = useMobxStore(); +interface Props { + issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; +} +export const CalendarMonthsDropdown: React.FC = observer((props: Props) => { + const { issuesFilterStore } = props; - const calendarLayout = issueFilterStore.userDisplayFilters.calendar?.layout ?? "month"; + const issueCalendarView = useCalendarView(); + + const calendarLayout = issuesFilterStore.issueFilters?.displayFilters?.calendar?.layout ?? "month"; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -29,10 +38,10 @@ export const CalendarMonthsDropdown: React.FC = observer(() => { ], }); - const { activeMonthDate } = calendarStore.calendarFilters; + const { activeMonthDate } = issueCalendarView.calendarFilters; const getWeekLayoutHeader = (): string => { - const allDaysOfActiveWeek = calendarStore.allDaysOfActiveWeek; + const allDaysOfActiveWeek = issueCalendarView.allDaysOfActiveWeek; if (!allDaysOfActiveWeek) return "Week view"; @@ -55,7 +64,7 @@ export const CalendarMonthsDropdown: React.FC = observer(() => { }; const handleDateChange = (date: Date) => { - calendarStore.updateCalendarFilters({ + issueCalendarView.updateCalendarFilters({ activeMonthDate: date, }); }; diff --git a/web/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx b/web/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx index c1778b33466..0abe8580de7 100644 --- a/web/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx +++ b/web/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx @@ -3,39 +3,34 @@ import { useRouter } from "next/router"; import { Popover, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; import { usePopper } from "react-popper"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useCalendarView } from "hooks/store"; // ui import { ToggleSwitch } from "@plane/ui"; // icons import { Check, ChevronUp } from "lucide-react"; // types -import { TCalendarLayouts } from "types"; +import { TCalendarLayouts } from "@plane/types"; // constants import { CALENDAR_LAYOUTS } from "constants/calendar"; -import { EFilterType } from "store/issues/types"; -import { - ICycleIssuesFilterStore, - IModuleIssuesFilterStore, - IProjectIssuesFilterStore, - IViewIssuesFilterStore, -} from "store/issues"; +import { EIssueFilterType } from "constants/issue"; +import { ICycleIssuesFilter } from "store/issue/cycle"; +import { IModuleIssuesFilter } from "store/issue/module"; +import { IProjectIssuesFilter } from "store/issue/project"; +import { IProjectViewIssuesFilter } from "store/issue/project-views"; interface ICalendarHeader { - issuesFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore; + issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + viewId?: string; } export const CalendarOptionsDropdown: React.FC = observer((props) => { - const { issuesFilterStore } = props; + const { issuesFilterStore, viewId } = props; const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { calendar: calendarStore } = useMobxStore(); + const issueCalendarView = useCalendarView(); const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -58,15 +53,17 @@ export const CalendarOptionsDropdown: React.FC = observer((prop const handleLayoutChange = (layout: TCalendarLayouts) => { if (!workspaceSlug || !projectId) return; - issuesFilterStore.updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.DISPLAY_FILTERS, { + issuesFilterStore.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { calendar: { ...issuesFilterStore.issueFilters?.displayFilters?.calendar, layout, }, }); - calendarStore.updateCalendarPayload( - layout === "month" ? calendarStore.calendarFilters.activeMonthDate : calendarStore.calendarFilters.activeWeekDate + issueCalendarView.updateCalendarPayload( + layout === "month" + ? issueCalendarView.calendarFilters.activeMonthDate + : issueCalendarView.calendarFilters.activeWeekDate ); }; @@ -75,12 +72,18 @@ export const CalendarOptionsDropdown: React.FC = observer((prop if (!workspaceSlug || !projectId) return; - issuesFilterStore.updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.DISPLAY_FILTERS, { - calendar: { - ...issuesFilterStore.issueFilters?.displayFilters?.calendar, - show_weekends: !showWeekends, + issuesFilterStore.updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { + calendar: { + ...issuesFilterStore.issueFilters?.displayFilters?.calendar, + show_weekends: !showWeekends, + }, }, - }); + viewId + ); }; return ( diff --git a/web/components/issues/issue-layouts/calendar/header.tsx b/web/components/issues/issue-layouts/calendar/header.tsx index 1a2280d05dd..ebbb510fc68 100644 --- a/web/components/issues/issue-layouts/calendar/header.tsx +++ b/web/components/issues/issue-layouts/calendar/header.tsx @@ -1,34 +1,28 @@ import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // components import { CalendarMonthsDropdown, CalendarOptionsDropdown } from "components/issues"; // icons import { ChevronLeft, ChevronRight } from "lucide-react"; -import { - ICycleIssuesFilterStore, - IModuleIssuesFilterStore, - IProjectIssuesFilterStore, - IViewIssuesFilterStore, -} from "store/issues"; +import { useCalendarView } from "hooks/store/use-calendar-view"; +import { ICycleIssuesFilter } from "store/issue/cycle"; +import { IModuleIssuesFilter } from "store/issue/module"; +import { IProjectIssuesFilter } from "store/issue/project"; +import { IProjectViewIssuesFilter } from "store/issue/project-views"; interface ICalendarHeader { - issuesFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore; + issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + viewId?: string; } export const CalendarHeader: React.FC = observer((props) => { - const { issuesFilterStore } = props; + const { issuesFilterStore, viewId } = props; - const { calendar: calendarStore } = useMobxStore(); + const issueCalendarView = useCalendarView(); const calendarLayout = issuesFilterStore.issueFilters?.displayFilters?.calendar?.layout ?? "month"; - const { activeMonthDate, activeWeekDate } = calendarStore.calendarFilters; + const { activeMonthDate, activeWeekDate } = issueCalendarView.calendarFilters; const handlePrevious = () => { if (calendarLayout === "month") { @@ -38,7 +32,7 @@ export const CalendarHeader: React.FC = observer((props) => { const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1); - calendarStore.updateCalendarFilters({ + issueCalendarView.updateCalendarFilters({ activeMonthDate: previousMonthFirstDate, }); } else { @@ -48,7 +42,7 @@ export const CalendarHeader: React.FC = observer((props) => { activeWeekDate.getDate() - 7 ); - calendarStore.updateCalendarFilters({ + issueCalendarView.updateCalendarFilters({ activeWeekDate: previousWeekDate, }); } @@ -62,7 +56,7 @@ export const CalendarHeader: React.FC = observer((props) => { const nextMonthFirstDate = new Date(nextMonthYear, nextMonthMonth, 1); - calendarStore.updateCalendarFilters({ + issueCalendarView.updateCalendarFilters({ activeMonthDate: nextMonthFirstDate, }); } else { @@ -72,7 +66,7 @@ export const CalendarHeader: React.FC = observer((props) => { activeWeekDate.getDate() + 7 ); - calendarStore.updateCalendarFilters({ + issueCalendarView.updateCalendarFilters({ activeWeekDate: nextWeekDate, }); } @@ -82,7 +76,7 @@ export const CalendarHeader: React.FC = observer((props) => { const today = new Date(); const firstDayOfCurrentMonth = new Date(today.getFullYear(), today.getMonth(), 1); - calendarStore.updateCalendarFilters({ + issueCalendarView.updateCalendarFilters({ activeMonthDate: firstDayOfCurrentMonth, activeWeekDate: today, }); @@ -97,7 +91,7 @@ export const CalendarHeader: React.FC = observer((props) => { - +
- +
); diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index f8eead33fbc..f66bf2ec091 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -1,53 +1,44 @@ import { useState, useRef } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Draggable } from "@hello-pangea/dnd"; import { MoreHorizontal } from "lucide-react"; // components -import { Tooltip } from "@plane/ui"; +import { Tooltip, ControlLink } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// helpers +import { cn } from "helpers/common.helper"; // types -import { IIssue } from "types"; -import { IIssueResponse } from "store/issues/types"; -import { useMobxStore } from "lib/mobx/store-provider"; -// constants -import { EUserWorkspaceRoles } from "constants/workspace"; +import { TIssue, TIssueMap } from "@plane/types"; +import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; type Props = { - issues: IIssueResponse | undefined; + issues: TIssueMap | undefined; issueIdList: string[] | null; - quickActions: (issue: IIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; showAllIssues?: boolean; }; export const CalendarIssueBlocks: React.FC = observer((props) => { const { issues, issueIdList, quickActions, showAllIssues = false } = props; - // router - const router = useRouter(); - + // hooks + const { + router: { workspaceSlug, projectId }, + } = useApplication(); + const { getProjectById } = useProject(); + const { getProjectStates } = useProjectState(); + const { peekIssue, setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); - // mobx store - const { - user: { currentProjectRole }, - } = useMobxStore(); - const menuActionRef = useRef(null); - const handleIssuePeekOverview = (issue: IIssue, event: React.MouseEvent) => { - const { query } = router; - if (event.ctrlKey || event.metaKey) { - const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; - window.open(issueUrl, "_blank"); // Open link in a new tab - } else { - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); - } - }; + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); @@ -63,59 +54,76 @@ export const CalendarIssueBlocks: React.FC = observer((props) => {
); - const isEditable = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - return ( <> {issueIdList?.slice(0, showAllIssues ? issueIdList.length : 4).map((issueId, index) => { if (!issues?.[issueId]) return null; const issue = issues?.[issueId]; + + const stateColor = + getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || ""; + return ( - + {(provided, snapshot) => (
handleIssuePeekOverview(issue, e)} > - {issue?.tempId !== undefined && ( -
- )} - -
handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" > -
- -
- {issue.project_detail.identifier}-{issue.sequence_id} + <> + {issue?.tempId !== undefined && ( +
+ )} + +
+
+ +
+ {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} +
+ +
{issue.name}
+
+
+
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + {quickActions(issue, customActionButton)} +
- -
{issue.name}
-
-
-
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - {quickActions(issue, customActionButton)} -
-
+ +
)} diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 85a74a997e5..d486b2f4873 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -2,9 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; // hooks +import { useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; @@ -13,24 +12,24 @@ import { createIssuePayload } from "helpers/issue.helper"; // icons import { PlusIcon } from "lucide-react"; // types -import { IIssue, IProject } from "types"; +import { TIssue } from "@plane/types"; type Props = { - formKey: keyof IIssue; + formKey: keyof TIssue; groupId?: string; subGroupId?: string | null; - prePopulatedData?: Partial; + prePopulatedData?: Partial; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; onOpen?: () => void; }; -const defaultValues: Partial = { +const defaultValues: Partial = { name: "", }; @@ -58,26 +57,22 @@ const Inputs = (props: any) => { }; export const CalendarQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, groupId, prePopulatedData, quickAddCallback, viewId, onOpen } = props; + const { formKey, prePopulatedData, quickAddCallback, viewId, onOpen } = props; // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - - const { workspace: workspaceStore, project: projectStore } = useMobxStore(); - - // ref + const { workspaceSlug, projectId } = router.query; + // store hooks + const { getProjectById } = useProject(); + // refs const ref = useRef(null); - // states const [isOpen, setIsOpen] = useState(false); - + // toast alert const { setToastAlert } = useToast(); // derived values - const workspaceDetail = (workspaceSlug && workspaceStore.getWorkspaceBySlug(workspaceSlug)) || null; - const projectDetail: IProject | null = - (workspaceSlug && projectId && projectStore.getProjectById(workspaceSlug, projectId)) || null; + const projectDetail = projectId ? getProjectById(projectId.toString()) : null; const { reset, @@ -85,7 +80,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { register, setFocus, formState: { errors, isSubmitting }, - } = useForm({ defaultValues }); + } = useForm({ defaultValues }); const handleClose = () => { setIsOpen(false); @@ -102,7 +97,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { if (!errors) return; Object.keys(errors).forEach((key) => { - const error = errors[key as keyof IIssue]; + const error = errors[key as keyof TIssue]; setToastAlert({ type: "error", @@ -112,12 +107,12 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { }); }, [errors, setToastAlert]); - const onSubmitHandler = async (formData: IIssue) => { - if (isSubmitting || !groupId || !workspaceDetail || !projectDetail) return; + const onSubmitHandler = async (formData: TIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail, projectDetail, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); @@ -125,8 +120,8 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { try { quickAddCallback && (await quickAddCallback( - workspaceSlug, - projectId, + workspaceSlug.toString(), + projectId.toString(), { ...payload, }, diff --git a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx index 88025ad6859..585b1a5e127 100644 --- a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx @@ -1,74 +1,50 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +//hooks +import { useIssues } from "hooks/store"; // components import { CycleIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; import { BaseCalendarRoot } from "../base-calendar-root"; +import { EIssuesStoreType } from "constants/issue"; +import { useMemo } from "react"; export const CycleCalendarLayout: React.FC = observer(() => { - const { - cycleIssues: cycleIssueStore, - cycleIssuesFilter: cycleIssueFilterStore, - calendarHelpers: { handleDragDrop: handleCalenderDragDrop }, - cycle: { fetchCycleWithId }, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, cycleId.toString()); - fetchCycleWithId(workspaceSlug.toString(), issue.project, cycleId.toString()); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, cycleId.toString()); - fetchCycleWithId(workspaceSlug.toString(), issue.project, cycleId.toString()); - }, - [EIssueActions.REMOVE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId || !projectId || !issue.bridge_id) return; - await cycleIssueStore.removeIssueFromCycle( - workspaceSlug.toString(), - issue.project, - cycleId.toString(), - issue.id, - issue.bridge_id - ); - fetchCycleWithId(workspaceSlug.toString(), issue.project, cycleId.toString()); - }, - }; - - const handleDragDrop = async (source: any, destination: any, issues: IIssue[], issueWithIds: any) => { - if (workspaceSlug && projectId && cycleId) - await handleCalenderDragDrop( - source, - destination, - workspaceSlug.toString(), - projectId.toString(), - cycleIssueStore, - issues, - issueWithIds, - cycleId.toString() - ); - }; + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, cycleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId || !projectId) return; + await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); + }, + }), + [issues, workspaceSlug, cycleId, projectId] + ); if (!cycleId) return null; return ( ); }); diff --git a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx index 4a7cfbd3f1d..d2b23e17614 100644 --- a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx @@ -1,68 +1,50 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hoks +import { useIssues } from "hooks/store"; // components import { ModuleIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; import { BaseCalendarRoot } from "../base-calendar-root"; +import { EIssuesStoreType } from "constants/issue"; +import { useMemo } from "react"; export const ModuleCalendarLayout: React.FC = observer(() => { - const { - moduleIssues: moduleIssueStore, - moduleIssuesFilter: moduleIssueFilterStore, - calendarHelpers: { handleDragDrop: handleCalenderDragDrop }, - module: { fetchModuleDetails }, - } = useMobxStore(); - + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query as { + const { workspaceSlug, moduleId } = router.query as { workspaceSlug: string; projectId: string; moduleId: string; }; - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue, moduleId); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, moduleId); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - [EIssueActions.REMOVE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId || !issue.bridge_id) return; - await moduleIssueStore.removeIssueFromModule(workspaceSlug, issue.project, moduleId, issue.id, issue.bridge_id); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - }; - - const handleDragDrop = async (source: any, destination: any, issues: IIssue[], issueWithIds: any) => { - await handleCalenderDragDrop( - source, - destination, - workspaceSlug, - projectId, - moduleIssueStore, - issues, - issueWithIds, - moduleId - ); - }; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + await issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue, moduleId); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, moduleId); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id); + }, + }), + [issues, workspaceSlug, moduleId] + ); return ( ); }); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx index e71cc7e3b0c..40f72e7b871 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx @@ -1,56 +1,43 @@ import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useRouter } from "next/router"; +// hooks +import { useIssues } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; import { BaseCalendarRoot } from "../base-calendar-root"; import { EIssueActions } from "../../types"; -import { IIssue } from "types"; -import { useRouter } from "next/router"; +import { TIssue } from "@plane/types"; +import { EIssuesStoreType } from "constants/issue"; +import { useMemo } from "react"; export const CalendarLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; - const { - projectIssues: issueStore, - projectIssuesFilter: projectIssueFiltersStore, - calendarHelpers: { handleDragDrop: handleCalenderDragDrop }, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await issueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await issueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id); - }, - }; - - const handleDragDrop = async (source: any, destination: any, issues: IIssue[], issueWithIds: any) => { - if (workspaceSlug && projectId) - await handleCalenderDragDrop( - source, - destination, - workspaceSlug.toString(), - projectId.toString(), - issueStore, - issues, - issueWithIds - ); - }; + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id); + }, + }), + [issues, workspaceSlug] + ); return ( ); }); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx index 95d746eeca5..573a9cf2049 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx @@ -1,57 +1,39 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; +import { BaseCalendarRoot } from "../base-calendar-root"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; -import { BaseCalendarRoot } from "../base-calendar-root"; - -export const ProjectViewCalendarLayout: React.FC = observer(() => { - const { - viewIssues: projectViewIssuesStore, - viewIssuesFilter: projectIssueViewFiltersStore, - calendarHelpers: { handleDragDrop: handleCalenderDragDrop }, - } = useMobxStore(); - - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; +// constants +import { EIssuesStoreType } from "constants/issue"; - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; - - await projectViewIssuesStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; - - await projectViewIssuesStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id); - }, +export interface IViewCalendarLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; +} - const handleDragDrop = async (source: any, destination: any, issues: IIssue[], issueWithIds: any) => { - if (workspaceSlug && projectId) - await handleCalenderDragDrop( - source, - destination, - workspaceSlug.toString(), - projectId.toString(), - projectViewIssuesStore, - issues, - issueWithIds - ); - }; +export const ProjectViewCalendarLayout: React.FC = observer((props) => { + const { issueActions } = props; + // store + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); + // router + const router = useRouter(); + const { viewId } = router.query; return ( ); }); diff --git a/web/components/issues/issue-layouts/calendar/utils.ts b/web/components/issues/issue-layouts/calendar/utils.ts new file mode 100644 index 00000000000..82d9ce0ce5f --- /dev/null +++ b/web/components/issues/issue-layouts/calendar/utils.ts @@ -0,0 +1,42 @@ +import { DraggableLocation } from "@hello-pangea/dnd"; +import { ICycleIssues } from "store/issue/cycle"; +import { IModuleIssues } from "store/issue/module"; +import { IProjectIssues } from "store/issue/project"; +import { IProjectViewIssues } from "store/issue/project-views"; +import { TGroupedIssues, IIssueMap } from "@plane/types"; + +export const handleDragDrop = async ( + source: DraggableLocation, + destination: DraggableLocation, + workspaceSlug: string | undefined, + projectId: string | undefined, + store: IProjectIssues | IModuleIssues | ICycleIssues | IProjectViewIssues, + issueMap: IIssueMap, + issueWithIds: TGroupedIssues, + viewId: string | null = null // it can be moduleId, cycleId +) => { + if (!issueMap || !issueWithIds || !workspaceSlug || !projectId) return; + + const sourceColumnId = source?.droppableId || null; + const destinationColumnId = destination?.droppableId || null; + + if (!workspaceSlug || !projectId || !sourceColumnId || !destinationColumnId) return; + + if (sourceColumnId === destinationColumnId) return; + + // horizontal + if (sourceColumnId != destinationColumnId) { + const sourceIssues = issueWithIds[sourceColumnId] || []; + + const [removed] = sourceIssues.splice(source.index, 1); + const removedIssueDetail = issueMap[removed]; + + const updateIssue = { + id: removedIssueDetail?.id, + target_date: destinationColumnId, + }; + + if (viewId) return await store?.updateIssue(workspaceSlug, projectId, updateIssue.id, updateIssue, viewId); + else return await store?.updateIssue(workspaceSlug, projectId, updateIssue.id, updateIssue); + } +}; diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 1d7c4de3df7..c34aaef972c 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -2,36 +2,29 @@ import { observer } from "mobx-react-lite"; // components import { CalendarDayTile } from "components/issues"; // helpers -import { renderDateFormat } from "helpers/date-time.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { ICalendarDate, ICalendarWeek } from "./types"; -import { IIssue } from "types"; -import { IGroupedIssues, IIssueResponse } from "store/issues/types"; -import { - ICycleIssuesFilterStore, - IModuleIssuesFilterStore, - IProjectIssuesFilterStore, - IViewIssuesFilterStore, -} from "store/issues"; +import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; +import { ICycleIssuesFilter } from "store/issue/cycle"; +import { IModuleIssuesFilter } from "store/issue/module"; +import { IProjectIssuesFilter } from "store/issue/project"; +import { IProjectViewIssuesFilter } from "store/issue/project-views"; type Props = { - issuesFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore; - issues: IIssueResponse | undefined; - groupedIssueIds: IGroupedIssues; + issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issues: TIssueMap | undefined; + groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; - quickActions: (issue: IIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; }; @@ -65,7 +58,7 @@ export const CalendarWeekDays: React.FC = observer((props) => { return ( void }; + secondaryButton?: { text: string; onClick: () => void }; + size?: "lg" | "sm" | undefined; + disabled?: boolean | undefined; +} + +export const ProjectArchivedEmptyState: React.FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { + membership: { currentProjectRole }, + currentUser, + } = useUser(); + const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); + + const userFilters = issuesFilter?.issueFilters?.filters; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const currentLayoutEmptyStateImagePath = getEmptyStateImagePath("empty-filters", activeLayout ?? "list", isLightMode); + const EmptyStateImagePath = getEmptyStateImagePath("archived", "empty-issues", isLightMode); + + const issueFilterCount = size( + Object.fromEntries( + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) + ) + ); + + const handleClearAllFilters = () => { + if (!workspaceSlug || !projectId) return; + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { + ...newFilters, + }); + }; + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + const emptyStateProps: EmptyStateProps = + issueFilterCount > 0 + ? { + title: "No issues found matching the filters applied", + image: currentLayoutEmptyStateImagePath, + secondaryButton: { + text: "Clear all filters", + onClick: handleClearAllFilters, + }, + } + : { + title: "No archived issues yet", + description: + "Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.", + image: EmptyStateImagePath, + primaryButton: { + text: "Set Automation", + onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`), + }, + size: "sm", + disabled: !isEditingAllowed, + }; + + return ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/empty-states/cycle.tsx b/web/components/issues/issue-layouts/empty-states/cycle.tsx index 52baa2eb87e..f0727c50e55 100644 --- a/web/components/issues/issue-layouts/empty-states/cycle.tsx +++ b/web/components/issues/issue-layouts/empty-states/cycle.tsx @@ -1,9 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // hooks +import { useApplication, useIssueDetail, useIssues, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { EmptyState } from "components/common"; @@ -13,10 +12,10 @@ import { Button } from "@plane/ui"; // assets import emptyIssue from "public/empty-state/issue.svg"; // types -import { ISearchIssueResponse } from "types"; -import { EProjectStore } from "store/command-palette.store"; +import { ISearchIssueResponse } from "@plane/types"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { EIssuesStoreType } from "constants/issue"; type Props = { workspaceSlug: string | undefined; @@ -28,13 +27,16 @@ export const CycleEmptyState: React.FC = observer((props) => { const { workspaceSlug, projectId, cycleId } = props; // states const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); - + // store hooks + const { issues } = useIssues(EIssuesStoreType.CYCLE); + const { updateIssue, fetchIssue } = useIssueDetail(); + const { + commandPalette: { toggleCreateIssueModal }, + eventTracker: { setTrackElement }, + } = useApplication(); const { - cycleIssues: cycleIssueStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - user: { currentProjectRole: userRole }, - } = useMobxStore(); + membership: { currentProjectRole: userRole }, + } = useUser(); const { setToastAlert } = useToast(); @@ -43,20 +45,28 @@ export const CycleEmptyState: React.FC = observer((props) => { const issueIds = data.map((i) => i.id); - await cycleIssueStore.addIssueToCycle(workspaceSlug.toString(), cycleId.toString(), issueIds).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Selected issues could not be added to the cycle. Please try again.", + await issues + .addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds) + .then((res) => { + updateIssue(workspaceSlug, projectId, res.id, res); + fetchIssue(workspaceSlug, projectId, res.id); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Selected issues could not be added to the cycle. Please try again.", + }); }); - }); }; - const isEditingAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!userRole && userRole >= EUserProjectRoles.MEMBER; return ( <> setCycleIssuesListModal(false)} searchParams={{ cycle: true }} @@ -72,7 +82,7 @@ export const CycleEmptyState: React.FC = observer((props) => { icon: , onClick: () => { setTrackElement("CYCLE_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.CYCLE); + toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); }, }} secondaryButton={ diff --git a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx new file mode 100644 index 00000000000..1d2695ff99c --- /dev/null +++ b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx @@ -0,0 +1,89 @@ +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import size from "lodash/size"; +import { useTheme } from "next-themes"; +// hooks +import { useIssues, useUser } from "hooks/store"; +// components +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; +// types +import { IIssueFilterOptions } from "@plane/types"; + +interface EmptyStateProps { + title: string; + image: string; + description?: string; + comicBox?: { title: string; description: string }; + primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void }; + secondaryButton?: { text: string; onClick: () => void }; + size?: "lg" | "sm" | undefined; + disabled?: boolean | undefined; +} + +export const ProjectDraftEmptyState: React.FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { + membership: { currentProjectRole }, + currentUser, + } = useUser(); + const { issuesFilter } = useIssues(EIssuesStoreType.DRAFT); + + const userFilters = issuesFilter?.issueFilters?.filters; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const currentLayoutEmptyStateImagePath = getEmptyStateImagePath("empty-filters", activeLayout ?? "list", isLightMode); + const EmptyStateImagePath = getEmptyStateImagePath("draft", "empty-issues", isLightMode); + + const issueFilterCount = size( + Object.fromEntries( + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) + ) + ); + + const handleClearAllFilters = () => { + if (!workspaceSlug || !projectId) return; + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { + ...newFilters, + }); + }; + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + const emptyStateProps: EmptyStateProps = + issueFilterCount > 0 + ? { + title: "No issues found matching the filters applied", + image: currentLayoutEmptyStateImagePath, + secondaryButton: { + text: "Clear all filters", + onClick: handleClearAllFilters, + }, + } + : { + title: "No draft issues yet", + description: + "Quickly stepping away but want to keep your place? No worries – save a draft now. Your issues will be right here waiting for you.", + image: EmptyStateImagePath, + size: "sm", + disabled: !isEditingAllowed, + }; + + return ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/empty-states/global-view.tsx b/web/components/issues/issue-layouts/empty-states/global-view.tsx index d4348c4bf6a..cd4070186a6 100644 --- a/web/components/issues/issue-layouts/empty-states/global-view.tsx +++ b/web/components/issues/issue-layouts/empty-states/global-view.tsx @@ -1,31 +1,24 @@ -// next -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { Plus, PlusIcon } from "lucide-react"; +// hooks +import { useApplication, useProject } from "hooks/store"; // components import { EmptyState } from "components/common"; // assets import emptyIssue from "public/empty-state/issue.svg"; import emptyProject from "public/empty-state/project.svg"; -// icons -import { Plus, PlusIcon } from "lucide-react"; export const GlobalViewEmptyState: React.FC = observer(() => { - const router = useRouter(); - const { workspaceSlug } = router.query; - + // store hooks const { - commandPalette: commandPaletteStore, - project: projectStore, - trackEvent: { setTrackElement }, - } = useMobxStore(); - - const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : null; + commandPalette: { toggleCreateIssueModal, toggleCreateProjectModal }, + eventTracker: { setTrackElement }, + } = useApplication(); + const { workspaceProjectIds } = useProject(); return (
- {!projects || projects?.length === 0 ? ( + {!workspaceProjectIds || workspaceProjectIds?.length === 0 ? ( { text: "New Project", onClick: () => { setTrackElement("ALL_ISSUES_EMPTY_STATE"); - commandPaletteStore.toggleCreateProjectModal(true); + toggleCreateProjectModal(true); }, }} /> @@ -49,7 +42,7 @@ export const GlobalViewEmptyState: React.FC = observer(() => { icon: , onClick: () => { setTrackElement("ALL_ISSUES_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true); + toggleCreateIssueModal(true); }, }} /> diff --git a/web/components/issues/issue-layouts/empty-states/index.ts b/web/components/issues/issue-layouts/empty-states/index.ts index 0373709d282..1320076e775 100644 --- a/web/components/issues/issue-layouts/empty-states/index.ts +++ b/web/components/issues/issue-layouts/empty-states/index.ts @@ -2,4 +2,6 @@ export * from "./cycle"; export * from "./global-view"; export * from "./module"; export * from "./project-view"; -export * from "./project"; +export * from "./project-issues"; +export * from "./draft-issues"; +export * from "./archived-issues"; diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index ed7f73358f8..109a903a288 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -1,17 +1,21 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; +// hooks +import { useApplication, useIssues, useUser } from "hooks/store"; +import useToast from "hooks/use-toast"; // components import { EmptyState } from "components/common"; +import { ExistingIssuesListModal } from "components/core"; +// ui import { Button } from "@plane/ui"; // assets import emptyIssue from "public/empty-state/issue.svg"; -import { ExistingIssuesListModal } from "components/core"; -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -import { ISearchIssueResponse } from "types"; -import useToast from "hooks/use-toast"; -import { useState } from "react"; +// types +import { ISearchIssueResponse } from "@plane/types"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { EIssuesStoreType } from "constants/issue"; type Props = { workspaceSlug: string | undefined; @@ -23,38 +27,44 @@ export const ModuleEmptyState: React.FC = observer((props) => { const { workspaceSlug, projectId, moduleId } = props; // states const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false); + // store hooks + const { issues } = useIssues(EIssuesStoreType.MODULE); const { - moduleIssues: moduleIssueStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - user: { currentProjectRole: userRole }, - } = useMobxStore(); - + commandPalette: { toggleCreateIssueModal }, + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole: userRole }, + } = useUser(); + // toast alert const { setToastAlert } = useToast(); const handleAddIssuesToModule = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId || !moduleId) return; const issueIds = data.map((i) => i.id); - - await moduleIssueStore.addIssueToModule(workspaceSlug.toString(), moduleId.toString(), issueIds).catch(() => - setToastAlert({ - type: "error", - title: "Error!", - message: "Selected issues could not be added to the module. Please try again.", - }) - ); + await issues + .addIssuesToModule(workspaceSlug.toString(), projectId?.toString(), moduleId.toString(), issueIds) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Selected issues could not be added to the module. Please try again.", + }) + ); }; - const isEditingAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!userRole && userRole >= EUserProjectRoles.MEMBER; return ( <> setModuleIssuesListModal(false)} - searchParams={{ module: true }} + searchParams={{ module: moduleId != undefined ? [moduleId.toString()] : [] }} handleOnSubmit={handleAddIssuesToModule} />
@@ -67,7 +77,7 @@ export const ModuleEmptyState: React.FC = observer((props) => { icon: , onClick: () => { setTrackElement("MODULE_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true); + toggleCreateIssueModal(true, EIssuesStoreType.MODULE); }, }} secondaryButton={ diff --git a/web/components/issues/issue-layouts/empty-states/project-issues.tsx b/web/components/issues/issue-layouts/empty-states/project-issues.tsx new file mode 100644 index 00000000000..b72dfff184d --- /dev/null +++ b/web/components/issues/issue-layouts/empty-states/project-issues.tsx @@ -0,0 +1,106 @@ +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import size from "lodash/size"; +import { useTheme } from "next-themes"; +// hooks +import { useApplication, useIssues, useUser } from "hooks/store"; +// components +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; +// types +import { IIssueFilterOptions } from "@plane/types"; + +interface EmptyStateProps { + title: string; + image: string; + description?: string; + comicBox?: { title: string; description: string }; + primaryButton?: { text: string; icon?: React.ReactNode; onClick: () => void }; + secondaryButton?: { text: string; onClick: () => void }; + size?: "lg" | "sm" | undefined; + disabled?: boolean | undefined; +} + +export const ProjectEmptyState: React.FC = observer(() => { + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // theme + const { resolvedTheme } = useTheme(); + // store hooks + const { + commandPalette: commandPaletteStore, + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole }, + currentUser, + } = useUser(); + const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT); + + const userFilters = issuesFilter?.issueFilters?.filters; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const currentLayoutEmptyStateImagePath = getEmptyStateImagePath("empty-filters", activeLayout ?? "list", isLightMode); + const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "issues", isLightMode); + + const issueFilterCount = size( + Object.fromEntries( + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) + ) + ); + + const handleClearAllFilters = () => { + if (!workspaceSlug || !projectId) return; + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { + ...newFilters, + }); + }; + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + const emptyStateProps: EmptyStateProps = + issueFilterCount > 0 + ? { + title: "No issues found matching the filters applied", + image: currentLayoutEmptyStateImagePath, + secondaryButton: { + text: "Clear all filters", + onClick: handleClearAllFilters, + }, + } + : { + title: "Create an issue and assign it to someone, even yourself", + description: + "Think of issues as jobs, tasks, work, or JTBD. Which we like. An issue and its sub-issues are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes issues to move your project towards its goal.", + image: EmptyStateImagePath, + comicBox: { + title: "Issues are building blocks in Plane.", + description: + "Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.", + }, + primaryButton: { + text: "Create your first issue", + + onClick: () => { + setTrackElement("PROJECT_EMPTY_STATE"); + commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT); + }, + }, + size: "lg", + disabled: !isEditingAllowed, + }; + + return ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/empty-states/project-view.tsx b/web/components/issues/issue-layouts/empty-states/project-view.tsx index 2fd297a90d8..919decd5106 100644 --- a/web/components/issues/issue-layouts/empty-states/project-view.tsx +++ b/web/components/issues/issue-layouts/empty-states/project-view.tsx @@ -1,18 +1,19 @@ import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useApplication } from "hooks/store"; // components import { EmptyState } from "components/common"; // assets import emptyIssue from "public/empty-state/issue.svg"; -import { EProjectStore } from "store/command-palette.store"; +import { EIssuesStoreType } from "constants/issue"; export const ProjectViewEmptyState: React.FC = observer(() => { + // store hooks const { commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - } = useMobxStore(); + eventTracker: { setTrackElement }, + } = useApplication(); return (
@@ -25,7 +26,7 @@ export const ProjectViewEmptyState: React.FC = observer(() => { icon: , onClick: () => { setTrackElement("VIEW_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT_VIEW); + commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW); }, }} /> diff --git a/web/components/issues/issue-layouts/empty-states/project.tsx b/web/components/issues/issue-layouts/empty-states/project.tsx deleted file mode 100644 index 7db04b36a15..00000000000 --- a/web/components/issues/issue-layouts/empty-states/project.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { observer } from "mobx-react-lite"; -import { PlusIcon } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { NewEmptyState } from "components/common/new-empty-state"; -// constants -import { EUserWorkspaceRoles } from "constants/workspace"; -// assets -import emptyIssue from "public/empty-state/empty_issues.webp"; -import { EProjectStore } from "store/command-palette.store"; - -export const ProjectEmptyState: React.FC = observer(() => { - const { - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - user: { currentProjectRole }, - } = useMobxStore(); - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - - return ( -
- , - onClick: () => { - setTrackElement("PROJECT_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT); - }, - } - : null - } - disabled={!isEditingAllowed} - /> -
- ); -}); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx index c1e7b8cec2c..891fd6dddeb 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx @@ -1,9 +1,8 @@ import { observer } from "mobx-react-lite"; - // icons import { X } from "lucide-react"; // helpers -import { renderLongDateFormat } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants import { DATE_FILTER_OPTIONS } from "constants/filters"; @@ -28,7 +27,7 @@ export const AppliedDateFilters: React.FC = observer((props) => { if (dateParts.length === 2) { const [date, time] = dateParts; - dateLabel = `${capitalizeFirstLetter(time)} ${renderLongDateFormat(date)}`; + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; } } 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 7ff8056b9e1..4ca2538e5aa 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,5 +1,7 @@ import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; +import { X } from "lucide-react"; +// hooks +import { useUser } from "hooks/store"; // components import { AppliedDateFilters, @@ -10,40 +12,37 @@ import { AppliedStateFilters, AppliedStateGroupFilters, } from "components/issues"; -// icons -import { X } from "lucide-react"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // types -import { IIssueFilterOptions, IIssueLabel, IProject, IState, IUserLite } from "types"; +import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; type Props = { appliedFilters: IIssueFilterOptions; handleClearAllFilters: () => void; handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void; labels?: IIssueLabel[] | undefined; - members?: IUserLite[] | undefined; - projects?: IProject[] | undefined; states?: IState[] | undefined; + alwaysAllowEditing?: boolean; }; const membersFilters = ["assignees", "mentions", "created_by", "subscriber"]; const dateFilters = ["start_date", "target_date"]; export const AppliedFiltersList: React.FC = observer((props) => { - const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, members, projects, states } = props; - + const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; + // store hooks const { - user: { currentProjectRole }, - } = useMobxStore(); + membership: { currentProjectRole }, + } = useUser(); if (!appliedFilters) return null; if (Object.keys(appliedFilters).length === 0) return null; - const isEditingAllowed = currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER); return (
@@ -63,7 +62,6 @@ export const AppliedFiltersList: React.FC = observer((props) => { handleRemoveFilter(filterKey, val)} - members={members} values={value} /> )} @@ -103,7 +101,6 @@ export const AppliedFiltersList: React.FC = observer((props) => { handleRemoveFilter("project", val)} - projects={projects} values={value} /> )} diff --git a/web/components/issues/issue-layouts/filters/applied-filters/label.tsx b/web/components/issues/issue-layouts/filters/applied-filters/label.tsx index 08e7aee4458..799233d0135 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/label.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/label.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; // icons import { X } from "lucide-react"; // types -import { IIssueLabel } from "types"; +import { IIssueLabel } from "@plane/types"; type Props = { handleRemove: (val: string) => void; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/members.tsx b/web/components/issues/issue-layouts/filters/applied-filters/members.tsx index 1dd61d3390b..ff5034c97d0 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/members.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/members.tsx @@ -3,22 +3,25 @@ import { X } from "lucide-react"; // ui import { Avatar } from "@plane/ui"; // types -import { IUserLite } from "types"; +import { useMember } from "hooks/store"; type Props = { handleRemove: (val: string) => void; - members: IUserLite[] | undefined; values: string[]; editable: boolean | undefined; }; export const AppliedMembersFilters: React.FC = observer((props) => { - const { handleRemove, members, values, editable } = props; + const { handleRemove, values, editable } = props; + + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); return ( <> {values.map((memberId) => { - const memberDetails = members?.find((m) => m.id === memberId); + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; if (!memberDetails) return null; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx b/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx index 88b39dc0033..be3240b5511 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; import { PriorityIcon } from "@plane/ui"; import { X } from "lucide-react"; // types -import { TIssuePriorities } from "types"; +import { TIssuePriorities } from "@plane/types"; type Props = { handleRemove: (val: string) => void; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx index b1e17cfe3dc..4c5affe8d4a 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx @@ -1,25 +1,25 @@ import { observer } from "mobx-react-lite"; - -// icons import { X } from "lucide-react"; -// types -import { IProject } from "types"; +// hooks +import { useProject } from "hooks/store"; +// helpers import { renderEmoji } from "helpers/emoji.helper"; type Props = { handleRemove: (val: string) => void; - projects: IProject[] | undefined; values: string[]; editable: boolean | undefined; }; export const AppliedProjectFilters: React.FC = observer((props) => { - const { handleRemove, projects, values, editable } = props; + const { handleRemove, values, editable } = props; + // store hooks + const { projectMap } = useProject(); return ( <> {values.map((projectId) => { - const projectDetails = projects?.find((p) => p.id === projectId); + const projectDetails = projectMap?.[projectId] ?? null; if (!projectDetails) return null; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx index 2b6571d3b28..227dc025bcd 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx @@ -1,27 +1,26 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useLabel, useProjectState } from "hooks/store"; // components import { AppliedFiltersList, SaveFilterView } from "components/issues"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + // store hooks const { - projectArchivedIssuesFilter: { issueFilters, updateFilters }, - projectLabel: { projectLabels }, - projectMember: { projectMembers }, - projectState: projectStateStore, - } = useMobxStore(); - + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { projectLabels } = useLabel(); + const { projectStates } = useProjectState(); + // derived values const userFilters = issueFilters?.filters; - // filters whose value not null or empty array const appliedFilters: IIssueFilterOptions = {}; Object.entries(userFilters ?? {}).forEach(([key, value]) => { @@ -37,7 +36,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { // remove all values of the key if value is null if (!value) { - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: null, }); return; @@ -47,7 +46,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { let newValues = issueFilters?.filters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues, }); }; @@ -60,7 +59,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters, }); }; @@ -75,8 +74,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} labels={projectLabels ?? []} - members={projectMembers?.map((m) => m.member)} - states={projectStateStore.states?.[projectId?.toString() ?? ""]} + states={projectStates} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx index b7c8b688906..827382da7b1 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx @@ -1,31 +1,30 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useLabel, useProjectState } from "hooks/store"; // components import { AppliedFiltersList, SaveFilterView } from "components/issues"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; export const CycleAppliedFiltersRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query as { workspaceSlug: string; projectId: string; cycleId: string; }; - + // store hooks const { - projectLabel: { projectLabels }, - projectState: projectStateStore, - projectMember: { projectMembers }, - cycleIssuesFilter: { issueFilters, updateFilters }, - } = useMobxStore(); + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + const { projectLabels } = useLabel(); + const { projectStates } = useProjectState(); + // derived values const userFilters = issueFilters?.filters; - // filters whose value not null or empty array const appliedFilters: IIssueFilterOptions = {}; Object.entries(userFilters ?? {}).forEach(([key, value]) => { @@ -35,32 +34,20 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => { }); const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { - if (!workspaceSlug || !projectId) return; + if (!workspaceSlug || !projectId || !cycleId) return; if (!value) { - updateFilters( - workspaceSlug, - projectId, - EFilterType.FILTERS, - { - [key]: null, - }, - cycleId - ); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { + [key]: null, + }); return; } let newValues = issueFilters?.filters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters( - workspaceSlug, - projectId, - EFilterType.FILTERS, - { - [key]: newValues, - }, - cycleId - ); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { + [key]: newValues, + }); }; const handleClearAllFilters = () => { @@ -69,7 +56,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => { Object.keys(userFilters ?? {}).forEach((key) => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { ...newFilters }, cycleId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { ...newFilters }, cycleId); }; // return if no filters are applied @@ -82,8 +69,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => { handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} labels={projectLabels ?? []} - members={projectMembers?.map((m) => m.member)} - states={projectStateStore.states?.[cycleId ?? ""]} + states={projectStates} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx index d3d56266dbe..e9024afeb7b 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/draft-issue.tsx @@ -1,25 +1,24 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useLabel, useProjectState } from "hooks/store"; // components import { AppliedFiltersList, SaveFilterView } from "components/issues"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - + // store hooks const { - projectDraftIssuesFilter: { issueFilters, updateFilters }, - projectLabel: { projectLabels }, - projectMember: { projectMembers }, - projectState: projectStateStore, - } = useMobxStore(); - + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.DRAFT); + const { projectLabels } = useLabel(); + const { projectStates } = useProjectState(); + // derived values const userFilters = issueFilters?.filters; // filters whose value not null or empty array const appliedFilters: IIssueFilterOptions = {}; @@ -34,7 +33,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { // remove all values of the key if value is null if (!value) { - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: null, }); return; @@ -44,7 +43,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { let newValues = issueFilters?.filters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues, }); }; @@ -57,7 +56,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { ...newFilters }); + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters }); }; // return if no filters are applied @@ -70,8 +69,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} labels={projectLabels ?? []} - members={projectMembers?.map((m) => m.member)} - states={projectStateStore.states?.[projectId?.toString() ?? ""]} + states={projectStates} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx index 543d186451d..0dae3c8bd45 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx @@ -1,95 +1,126 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import isEqual from "lodash/isEqual"; +// hooks +import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; +//ui +import { Button } from "@plane/ui"; // components import { AppliedFiltersList } from "components/issues"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; +import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; -export const GlobalViewsAppliedFiltersRoot = observer(() => { - const router = useRouter(); - const { workspaceSlug } = router.query as { workspaceSlug: string; globalViewId: string }; +type Props = { + globalViewId: string; +}; +export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { + const { globalViewId } = props; + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // store hooks + const { + issuesFilter: { filters, updateFilters }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { workspaceLabels } = useLabel(); + const { globalViewMap, updateGlobalView } = useGlobalView(); const { - project: { workspaceProjects }, - workspace: { workspaceLabels }, - workspaceMember: { workspaceMembers }, - workspaceGlobalIssuesFilter: { issueFilters, updateFilters }, - } = useMobxStore(); + membership: { currentWorkspaceRole }, + } = useUser(); - const userFilters = issueFilters?.filters; + // derived values + const userFilters = filters?.[globalViewId]?.filters; + const viewDetails = globalViewMap[globalViewId]; // filters whose value not null or empty array - const appliedFilters: IIssueFilterOptions = {}; + let appliedFilters: IIssueFilterOptions | undefined = undefined; Object.entries(userFilters ?? {}).forEach(([key, value]) => { if (!value) return; if (Array.isArray(value) && value.length === 0) return; + if (!appliedFilters) appliedFilters = {}; appliedFilters[key as keyof IIssueFilterOptions] = value; }); const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { + if (!workspaceSlug || !globalViewId) return; + if (!value) { - updateFilters(workspaceSlug, EFilterType.FILTERS, { [key]: null }); + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + { [key]: null }, + globalViewId.toString() + ); return; } let newValues = userFilters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters(workspaceSlug, EFilterType.FILTERS, { [key]: newValues }); + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + { [key]: newValues }, + globalViewId.toString() + ); }; const handleClearAllFilters = () => { - if (!workspaceSlug) return; + if (!workspaceSlug || !globalViewId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug, EFilterType.FILTERS, { ...newFilters }); + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + { ...newFilters }, + globalViewId.toString() + ); }; - // const handleUpdateView = () => { - // if (!workspaceSlug || !globalViewId || !viewDetails) return; + const handleUpdateView = () => { + if (!workspaceSlug || !globalViewId) return; + + updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), { + filters: { + ...(appliedFilters ?? {}), + }, + }); + }; - // globalViewsStore.updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), { - // query_data: { - // ...viewDetails.query_data, - // filters: { - // ...(storedFilters ?? {}), - // }, - // }, - // }); - // }; + const areFiltersEqual = isEqual(appliedFilters, viewDetails?.filters); - // update stored filters when view details are fetched - // useEffect(() => { - // if (!globalViewId || !viewDetails) return; + const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; - // if (!globalViewFiltersStore.storedFilters[globalViewId.toString()]) - // globalViewFiltersStore.updateStoredFilters(globalViewId.toString(), viewDetails?.query_data?.filters ?? {}); - // }, [globalViewId, globalViewFiltersStore, viewDetails]); + const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => view.key).includes(globalViewId as TStaticViewTypes); // return if no filters are applied - if (Object.keys(appliedFilters).length === 0) return null; + if (!appliedFilters && areFiltersEqual) return null; return (
m.member)} - projects={workspaceProjects ?? undefined} appliedFilters={appliedFilters ?? {}} handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} + alwaysAllowEditing /> - {/* {storedFilters && viewDetails && areFiltersDifferent(storedFilters, viewDetails.query_data.filters ?? {}) && ( - - )} */} + {!isDefaultView && !areFiltersEqual && isAuthorizedUser && ( + <> +
+ + + )}
); }); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx index 62cd4b3d878..b823a4bd15d 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx @@ -1,31 +1,29 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useLabel, useProjectState } from "hooks/store"; // components import { AppliedFiltersList, SaveFilterView } from "components/issues"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; export const ModuleAppliedFiltersRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId, moduleId } = router.query as { workspaceSlug: string; projectId: string; moduleId: string; }; - + // store hooks const { - projectLabel: { projectLabels }, - projectState: projectStateStore, - projectMember: { projectMembers }, - moduleIssuesFilter: { issueFilters, updateFilters }, - } = useMobxStore(); - + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + const { projectLabels } = useLabel(); + const { projectStates } = useProjectState(); + // derived values const userFilters = issueFilters?.filters; - // filters whose value not null or empty array const appliedFilters: IIssueFilterOptions = {}; Object.entries(userFilters ?? {}).forEach(([key, value]) => { @@ -37,30 +35,18 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { if (!workspaceSlug || !projectId) return; if (!value) { - updateFilters( - workspaceSlug, - projectId, - EFilterType.FILTERS, - { - [key]: null, - }, - moduleId - ); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { + [key]: null, + }); return; } let newValues = issueFilters?.filters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters( - workspaceSlug, - projectId, - EFilterType.FILTERS, - { - [key]: newValues, - }, - moduleId - ); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { + [key]: newValues, + }); }; const handleClearAllFilters = () => { @@ -69,7 +55,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { Object.keys(userFilters ?? {}).forEach((key) => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { ...newFilters }, moduleId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { ...newFilters }, moduleId); }; // return if no filters are applied @@ -82,8 +68,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} labels={projectLabels ?? []} - members={projectMembers?.map((m) => m.member)} - states={projectStateStore.states?.[moduleId ?? ""]} + states={projectStates} /> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx index 89870d98a13..7a6c3933605 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx @@ -1,26 +1,27 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; - -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useLabel } from "hooks/store"; // components import { AppliedFiltersList } from "components/issues"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; +import { useWorkspaceIssueProperties } from "hooks/use-workspace-issue-properties"; export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => { + // router const router = useRouter(); - const { workspaceSlug } = router.query as { - workspaceSlug: string; - }; - + const { workspaceSlug, userId } = router.query; + //swr hook for fetching issue properties + useWorkspaceIssueProperties(workspaceSlug); + // store hooks const { - workspace: { workspaceLabels }, - workspaceProfileIssuesFilter: { issueFilters, updateFilters }, - projectMember: { projectMembers }, - } = useMobxStore(); + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROFILE); + const { workspaceLabels } = useLabel(); + // derived values const userFilters = issueFilters?.filters; // filters whose value not null or empty array @@ -32,27 +33,33 @@ export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => { }); const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { - if (!workspaceSlug) return; + if (!workspaceSlug || !userId) return; if (!value) { - updateFilters(workspaceSlug, EFilterType.FILTERS, { [key]: null }); + updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { [key]: null }, userId.toString()); return; } let newValues = issueFilters?.filters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters(workspaceSlug, EFilterType.FILTERS, { - [key]: newValues, - }); + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + { + [key]: newValues, + }, + userId.toString() + ); }; const handleClearAllFilters = () => { - if (!workspaceSlug) return; + if (!workspaceSlug || !userId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug, EFilterType.FILTERS, { ...newFilters }); + updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { ...newFilters }, userId.toString()); }; // return if no filters are applied @@ -65,7 +72,6 @@ export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => { handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} labels={workspaceLabels ?? []} - members={projectMembers?.map((m) => m.member)} states={[]} />
diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx index 31317366c25..68b5e6727cc 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx @@ -1,14 +1,15 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useLabel, useProjectState, useUser } from "hooks/store"; +import { useIssues } from "hooks/store/use-issues"; // components import { AppliedFiltersList, SaveFilterView } from "components/issues"; -// types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; +// types +import { IIssueFilterOptions } from "@plane/types"; export const ProjectAppliedFiltersRoot: React.FC = observer(() => { // router @@ -17,18 +18,18 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { workspaceSlug: string; projectId: string; }; - // mobx stores + // store hooks + const { projectLabels } = useLabel(); const { - projectLabel: { projectLabels }, - projectState: projectStateStore, - projectMember: { projectMembers }, - projectIssuesFilter: { issueFilters, updateFilters }, - user: { currentProjectRole }, - } = useMobxStore(); + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + membership: { currentProjectRole }, + } = useUser(); + const { projectStates } = useProjectState(); // derived values - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const userFilters = issueFilters?.filters; - // filters whose value not null or empty array const appliedFilters: IIssueFilterOptions = {}; Object.entries(userFilters ?? {}).forEach(([key, value]) => { @@ -40,7 +41,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => { if (!workspaceSlug || !projectId) return; if (!value) { - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: null, }); return; @@ -49,7 +50,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { let newValues = issueFilters?.filters?.[key] ?? []; newValues = newValues.filter((val) => val !== value); - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues, }); }; @@ -60,7 +61,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { Object.keys(userFilters ?? {}).forEach((key) => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { ...newFilters }); + updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters }); }; // return if no filters are applied @@ -73,8 +74,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} labels={projectLabels ?? []} - members={projectMembers?.map((m) => m.member)} - states={projectStateStore.states?.[projectId?.toString() ?? ""]} + states={projectStates} /> {isEditingAllowed && ( diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx index 6b037a031d0..0768064ec77 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx @@ -1,41 +1,40 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import isEqual from "lodash/isEqual"; +// hooks +import { useIssues, useLabel, useProjectState, useProjectView } from "hooks/store"; // components import { AppliedFiltersList } from "components/issues"; // ui import { Button } from "@plane/ui"; -// helpers -import { areFiltersDifferent } from "helpers/filter.helper"; // types -import { IIssueFilterOptions } from "types"; -import { EFilterType } from "store/issues/types"; +import { IIssueFilterOptions } from "@plane/types"; +import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId, viewId } = router.query as { workspaceSlug: string; projectId: string; viewId: string; }; - + // store hooks const { - projectLabel: { projectLabels }, - projectState: projectStateStore, - projectMember: { projectMembers }, - projectViews: projectViewsStore, - viewIssuesFilter: { issueFilters, updateFilters }, - } = useMobxStore(); - - const viewDetails = viewId ? projectViewsStore.viewDetails[viewId.toString()] : undefined; - + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { projectLabels } = useLabel(); + const { projectStates } = useProjectState(); + const { viewMap, updateView } = useProjectView(); + // derived values + const viewDetails = viewId ? viewMap[viewId.toString()] : null; const userFilters = issueFilters?.filters; // filters whose value not null or empty array - const appliedFilters: IIssueFilterOptions = {}; + let appliedFilters: IIssueFilterOptions | undefined = undefined; Object.entries(userFilters ?? {}).forEach(([key, value]) => { if (!value) return; if (Array.isArray(value) && value.length === 0) return; + if (!appliedFilters) appliedFilters = {}; appliedFilters[key as keyof IIssueFilterOptions] = value; }); @@ -45,7 +44,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { updateFilters( workspaceSlug, projectId, - EFilterType.FILTERS, + EIssueFilterType.FILTERS, { [key]: null, }, @@ -60,7 +59,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { updateFilters( workspaceSlug, projectId, - EFilterType.FILTERS, + EIssueFilterType.FILTERS, { [key]: newValues, }, @@ -74,18 +73,18 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { Object.keys(userFilters ?? {}).forEach((key) => { newFilters[key as keyof IIssueFilterOptions] = null; }); - updateFilters(workspaceSlug, projectId, EFilterType.FILTERS, { ...newFilters }, viewId); + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { ...newFilters }, viewId); }; + const areFiltersEqual = isEqual(appliedFilters, viewDetails?.filters); // return if no filters are applied - if (Object.keys(appliedFilters).length === 0) return null; + if (!appliedFilters && areFiltersEqual) return null; const handleUpdateView = () => { if (!workspaceSlug || !projectId || !viewId || !viewDetails) return; - projectViewsStore.updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), { - query_data: { - ...viewDetails.query_data, + updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), { + filters: { ...(appliedFilters ?? {}), }, }); @@ -94,23 +93,24 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => { return (
m.member)} - states={projectStateStore.states?.[projectId?.toString() ?? ""]} + states={projectStates} + alwaysAllowEditing /> - {appliedFilters && - viewDetails?.query_data && - areFiltersDifferent(appliedFilters, viewDetails?.query_data ?? {}) && ( + {!areFiltersEqual && ( + <> +
- )} + + )}
); }); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/state-group.tsx b/web/components/issues/issue-layouts/filters/applied-filters/state-group.tsx index 64f95983e46..620a8f78138 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/state-group.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/state-group.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; // icons import { StateGroupIcon } from "@plane/ui"; import { X } from "lucide-react"; -import { TStateGroups } from "types"; +import { TStateGroups } from "@plane/types"; type Props = { handleRemove: (val: string) => void; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/state.tsx b/web/components/issues/issue-layouts/filters/applied-filters/state.tsx index 9cff84d9b7a..59a873162ec 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/state.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/state.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; import { StateGroupIcon } from "@plane/ui"; import { X } from "lucide-react"; // types -import { IState } from "types"; +import { IState } from "@plane/types"; type Props = { handleRemove: (val: string) => void; 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 412e5479449..3c94b4f3fc5 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 "types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; import { ILayoutDisplayFiltersOptions } from "constants/issue"; type Props = { 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 0abe6442a29..3ea1453e824 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 @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // components import { FilterHeader } from "../helpers/filter-header"; // types -import { IIssueDisplayProperties } from "types"; +import { IIssueDisplayProperties } from "@plane/types"; // constants import { ISSUE_DISPLAY_PROPERTIES } from "constants/issue"; diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx index cb75b53f4af..0feb1d89190 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/extra-options.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // components import { FilterOption } from "components/issues"; // types -import { IIssueDisplayFilterOptions, TIssueExtraOptions } from "types"; +import { IIssueDisplayFilterOptions, TIssueExtraOptions } from "@plane/types"; // constants import { ISSUE_EXTRA_OPTIONS } from "constants/issue"; 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 aa057e4174f..659d86d089f 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 @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // components import { FilterHeader, FilterOption } from "components/issues"; // types -import { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "types"; +import { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "@plane/types"; // constants import { ISSUE_GROUP_BY_OPTIONS } from "constants/issue"; diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/issue-type.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/issue-type.tsx index a6fa2bf0612..59c83a2003a 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/issue-type.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/issue-type.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // components import { FilterHeader, FilterOption } from "components/issues"; // types -import { TIssueTypeFilters } from "types"; +import { TIssueTypeFilters } from "@plane/types"; // constants import { ISSUE_FILTER_OPTIONS } from "constants/issue"; diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/order-by.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/order-by.tsx index 004d1b6e94f..e417c650ecd 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/order-by.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/order-by.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // components import { FilterHeader, FilterOption } from "components/issues"; // types -import { TIssueOrderByOptions } from "types"; +import { TIssueOrderByOptions } from "@plane/types"; // constants import { ISSUE_ORDER_BY_OPTIONS } from "constants/issue"; diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/sub-group-by.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/sub-group-by.tsx index f6642242778..3310511619d 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/sub-group-by.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/sub-group-by.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // components import { FilterHeader, FilterOption } from "components/issues"; // types -import { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "types"; +import { IIssueDisplayFilterOptions, TIssueGroupByOptions } from "@plane/types"; // constants import { ISSUE_GROUP_BY_OPTIONS } from "constants/issue"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx b/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx index 0a1ecf3eabe..168e31bc0f3 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx @@ -1,28 +1,31 @@ -import React, { useState } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useMember } from "hooks/store"; // components import { FilterHeader, FilterOption } from "components/issues"; // ui import { Avatar, Loader } from "@plane/ui"; -// types -import { IUserLite } from "types"; type Props = { appliedFilters: string[] | null; handleUpdate: (val: string) => void; - members: IUserLite[] | undefined; + memberIds: string[] | undefined; searchQuery: string; }; -export const FilterAssignees: React.FC = (props) => { - const { appliedFilters, handleUpdate, members, searchQuery } = props; - +export const FilterAssignees: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = members?.filter((member) => - member.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + const filteredOptions = memberIds?.filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) ); const handleViewToggle = () => { @@ -44,15 +47,20 @@ export const FilterAssignees: React.FC = (props) => { {filteredOptions ? ( filteredOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((member) => ( - handleUpdate(member.id)} - icon={} - title={member.display_name} - /> - ))} + {filteredOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} {filteredOptions.length > 5 && (
@@ -109,7 +108,7 @@ export const FilterSelection: React.FC = observer((props) => { handleFiltersUpdate("mentions", val)} - members={members} + memberIds={memberIds} searchQuery={filtersSearchQuery} />
@@ -121,7 +120,7 @@ export const FilterSelection: React.FC = observer((props) => { handleFiltersUpdate("created_by", val)} - members={members} + memberIds={memberIds} searchQuery={filtersSearchQuery} />
@@ -144,7 +143,6 @@ export const FilterSelection: React.FC = observer((props) => {
handleFiltersUpdate("project", val)} searchQuery={filtersSearchQuery} /> diff --git a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx index de6b73596ef..b226f42b3d4 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; - +import { observer } from "mobx-react"; // components import { FilterHeader, FilterOption } from "components/issues"; // ui import { Loader } from "@plane/ui"; // types -import { IIssueLabel } from "types"; +import { IIssueLabel } from "@plane/types"; const LabelIcons = ({ color }: { color: string }) => ( @@ -18,7 +18,7 @@ type Props = { searchQuery: string; }; -export const FilterLabels: React.FC = (props) => { +export const FilterLabels: React.FC = observer((props) => { const { appliedFilters, handleUpdate, labels, searchQuery } = props; const [itemsToRender, setItemsToRender] = useState(5); @@ -80,4 +80,4 @@ export const FilterLabels: React.FC = (props) => { )} ); -}; +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx index 8e2f4b402a6..a6af9833a4e 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx @@ -1,28 +1,31 @@ -import React, { useState } from "react"; +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +// hooks +import { useMember } from "hooks/store"; // components import { FilterHeader, FilterOption } from "components/issues"; // ui import { Loader, Avatar } from "@plane/ui"; -// types -import { IUserLite } from "types"; type Props = { appliedFilters: string[] | null; handleUpdate: (val: string) => void; - members: IUserLite[] | undefined; + memberIds: string[] | undefined; searchQuery: string; }; -export const FilterMentions: React.FC = (props) => { - const { appliedFilters, handleUpdate, members, searchQuery } = props; - +export const FilterMentions: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = members?.filter((member) => - member.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + const filteredOptions = memberIds?.filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) ); const handleViewToggle = () => { @@ -44,15 +47,20 @@ export const FilterMentions: React.FC = (props) => { {filteredOptions ? ( filteredOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((member) => ( - handleUpdate(member.id)} - icon={} - title={member.display_name} - /> - ))} + {filteredOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} {filteredOptions.length > 5 && ( - )} +
+ {isOpen ? ( +
+
+ + +
{`Press 'Enter' to add another issue`}
+
+ ) : ( +
setIsOpen(true)} + > + + New Issue +
+ )} +
); }); 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 b536b1fa87c..eb7005cbd91 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -2,72 +2,49 @@ import { FC, useCallback, useState } from "react"; import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useUser } from "hooks/store"; +import useToast from "hooks/use-toast"; // ui import { Spinner } from "@plane/ui"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../types"; -import { - ICycleIssuesFilterStore, - ICycleIssuesStore, - IModuleIssuesFilterStore, - IModuleIssuesStore, - IProfileIssuesFilterStore, - IProfileIssuesStore, - IProjectDraftIssuesStore, - IProjectIssuesFilterStore, - IProjectIssuesStore, - IViewIssuesFilterStore, - IViewIssuesStore, -} from "store/issues"; import { IQuickActionProps } from "../list/list-view-types"; -import { IIssueKanBanViewStore } from "store/issue"; -// hooks -import useToast from "hooks/use-toast"; -// constants -import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue"; +import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; //components import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; -import { EProjectStore } from "store/command-palette.store"; -import { DeleteIssueModal, IssuePeekOverview } from "components/issues"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { DeleteIssueModal } from "components/issues"; +import { EUserProjectRoles } from "constants/project"; +import { useIssues } from "hooks/store/use-issues"; +import { handleDragDrop } from "./utils"; +import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; +import { IDraftIssues, IDraftIssuesFilter } from "store/issue/draft"; +import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; +import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; +import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; +import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; export interface IBaseKanBanLayout { - issueStore: - | IProjectIssuesStore - | IModuleIssuesStore - | ICycleIssuesStore - | IViewIssuesStore - | IProjectDraftIssuesStore - | IProfileIssuesStore; - issuesFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore - | IProfileIssuesFilterStore; - kanbanViewStore: IIssueKanBanViewStore; + issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; + issuesFilter: + | IProjectIssuesFilter + | IModuleIssuesFilter + | ICycleIssuesFilter + | IDraftIssuesFilter + | IProjectViewIssuesFilter + | IProfileIssuesFilter; QuickActions: FC; issueActions: { - [EIssueActions.DELETE]: (issue: IIssue) => Promise; - [EIssueActions.UPDATE]?: (issue: IIssue) => Promise; - [EIssueActions.REMOVE]?: (issue: IIssue) => Promise; + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; showLoader?: boolean; viewId?: string; - currentStore?: EProjectStore; - handleDragDrop?: ( - source: any, - destination: any, - subGroupBy: string | null, - groupBy: string | null, - issues: any, - issueWithIds: any - ) => Promise; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType?: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; } @@ -79,66 +56,57 @@ type KanbanDragState = { export const BaseKanBanRoot: React.FC = observer((props: IBaseKanBanLayout) => { const { - issueStore, - issuesFilterStore, - kanbanViewStore, + issues, + issuesFilter, QuickActions, issueActions, showLoader, viewId, - currentStore, - handleDragDrop, + storeType, addIssuesToView, canEditPropertiesBasedOnProject, } = props; // router const router = useRouter(); - const { workspaceSlug, peekIssueId, peekProjectId } = router.query; - // mobx store + const { workspaceSlug, projectId } = router.query; + // store hooks const { - project: { workspaceProjects }, - projectLabel: { projectLabels }, - projectMember: { projectMembers }, - projectState: projectStateStore, - user: userStore, - } = useMobxStore(); - - // hooks + membership: { currentProjectRole }, + } = useUser(); + const { issueMap } = useIssues(); + // toast alert const { setToastAlert } = useToast(); - const { currentProjectRole } = userStore; + const issueIds = issues?.groupedIssueIds || []; - const issues = issueStore?.getIssues || {}; - const issueIds = issueStore?.getIssuesIds || []; - - const displayFilters = issuesFilterStore?.issueFilters?.displayFilters; - const displayProperties = issuesFilterStore?.issueFilters?.displayProperties || null; + const displayFilters = issuesFilter?.issueFilters?.displayFilters; + const displayProperties = issuesFilter?.issueFilters?.displayProperties; const sub_group_by: string | null = displayFilters?.sub_group_by || null; - const group_by: string | null = displayFilters?.group_by || null; - const order_by: string | null = displayFilters?.order_by || null; - const userDisplayFilters = displayFilters || null; - const currentKanBanView: "swimlanes" | "default" = sub_group_by ? "swimlanes" : "default"; + const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan; - const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issueStore?.viewFlags || {}; + const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; // states const [isDragStarted, setIsDragStarted] = useState(false); const [dragState, setDragState] = useState({}); const [deleteIssueModal, setDeleteIssueModal] = useState(false); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const canEditProperties = (projectId: string | undefined) => { - const isEditingAllowedBasedOnProject = - canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; + const canEditProperties = useCallback( + (projectId: string | undefined) => { + const isEditingAllowedBasedOnProject = + canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; - return enableInlineEditing && isEditingAllowedBasedOnProject; - }; + return enableInlineEditing && isEditingAllowedBasedOnProject; + }, + [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] + ); const onDragStart = (dragStart: DragStart) => { setDragState({ @@ -171,21 +139,30 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas }); setDeleteIssueModal(true); } else { - await handleDragDrop(result.source, result.destination, sub_group_by, group_by, issues, issueIds).catch( - (err) => { - setToastAlert({ - title: "Error", - type: "error", - message: err.detail ?? "Failed to perform this action", - }); - } - ); + await handleDragDrop( + result.source, + result.destination, + workspaceSlug?.toString(), + projectId?.toString(), + issues, + sub_group_by, + group_by, + issueMap, + issueIds, + viewId + ).catch((err) => { + setToastAlert({ + title: "Error", + type: "error", + message: err.detail ?? "Failed to perform this action", + }); + }); } } }; const handleIssues = useCallback( - async (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => { + async (issue: TIssue, action: EIssueActions) => { if (issueActions[action]) { await issueActions[action]!(issue); } @@ -193,164 +170,120 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas [issueActions] ); + const renderQuickActions = useCallback( + (issue: TIssue, customActionButton?: React.ReactElement) => ( + handleIssues(issue, EIssueActions.DELETE)} + handleUpdate={ + issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined + } + handleRemoveFromView={ + issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined + } + /> + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [issueActions, handleIssues] + ); + const handleDeleteIssue = async () => { if (!handleDragDrop) return; - await handleDragDrop(dragState.source, dragState.destination, sub_group_by, group_by, issues, issueIds).finally( - () => { - setDeleteIssueModal(false); - setDragState({}); - } - ); + await handleDragDrop( + dragState.source, + dragState.destination, + workspaceSlug?.toString(), + projectId?.toString(), + issues, + sub_group_by, + group_by, + issueMap, + issueIds, + viewId + ).finally(() => { + handleIssues(issueMap[dragState.draggedIssueId!], EIssueActions.DELETE); + setDeleteIssueModal(false); + setDragState({}); + }); }; - const handleKanBanToggle = (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => { - kanbanViewStore.handleKanBanToggle(toggle, value); + const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { + if (workspaceSlug && projectId) { + 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, + }); + } }; - const states = projectStateStore?.projectStates || null; - const priorities = ISSUE_PRIORITIES || null; - const stateGroups = ISSUE_STATE_GROUPS || null; + const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] }; return ( <> setDeleteIssueModal(false)} onSubmit={handleDeleteIssue} /> - {showLoader && issueStore?.loader === "init-loader" && ( + {showLoader && issues?.loader === "init-loader" && (
)} -
- -
- - {(provided, snapshot) => ( -
- Drop here to delete the issue. -
- )} -
-
+
+
+ + {/* drag and delete component */} +
+ + {(provided, snapshot) => ( +
+ Drop here to delete the issue. +
+ )} +
+
- {currentKanBanView === "default" ? ( - ( - handleIssues(sub_group_by, group_by, issue, EIssueActions.DELETE)} - handleUpdate={ - issueActions[EIssueActions.UPDATE] - ? async (data) => handleIssues(sub_group_by, group_by, data, EIssueActions.UPDATE) - : undefined - } - handleRemoveFromView={ - issueActions[EIssueActions.REMOVE] - ? async () => handleIssues(sub_group_by, group_by, issue, EIssueActions.REMOVE) - : undefined - } - /> - )} - displayProperties={displayProperties} - kanBanToggle={kanbanViewStore?.kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - states={states} - stateGroups={stateGroups} - priorities={priorities} - labels={projectLabels} - members={projectMembers?.map((m) => m.member) ?? null} - projects={workspaceProjects} + quickActions={renderQuickActions} + handleKanbanFilters={handleKanbanFilters} + kanbanFilters={kanbanFilters} enableQuickIssueCreate={enableQuickAdd} showEmptyGroup={userDisplayFilters?.show_empty_groups || true} - isDragStarted={isDragStarted} - quickAddCallback={issueStore?.quickAddIssue} + quickAddCallback={issues?.quickAddIssue} viewId={viewId} disableIssueCreation={!enableIssueCreation || !isEditingAllowed} canEditProperties={canEditProperties} - currentStore={currentStore} + storeType={storeType} addIssuesToView={addIssuesToView} /> - ) : ( - ( - handleIssues(sub_group_by, group_by, issue, EIssueActions.DELETE)} - handleUpdate={ - issueActions[EIssueActions.UPDATE] - ? async (data) => handleIssues(sub_group_by, group_by, data, EIssueActions.UPDATE) - : undefined - } - handleRemoveFromView={ - issueActions[EIssueActions.REMOVE] - ? async () => handleIssues(sub_group_by, group_by, issue, EIssueActions.REMOVE) - : undefined - } - /> - )} - displayProperties={displayProperties} - kanBanToggle={kanbanViewStore?.kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - states={states} - stateGroups={stateGroups} - priorities={priorities} - labels={projectLabels} - members={projectMembers?.map((m) => m.member) ?? null} - projects={workspaceProjects} - showEmptyGroup={userDisplayFilters?.show_empty_groups || true} - isDragStarted={isDragStarted} - disableIssueCreation={!enableIssueCreation || !isEditingAllowed} - enableQuickIssueCreate={enableQuickAdd} - currentStore={currentStore} - quickAddCallback={issueStore?.quickAddIssue} - addIssuesToView={addIssuesToView} - canEditProperties={canEditProperties} - /> - )} -
+ +
- - {workspaceSlug && peekIssueId && peekProjectId && ( - - await handleIssues(sub_group_by, group_by, issueToUpdate as IIssue, action) - } - /> - )} ); }); diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index b48698fa7f0..68b09135c95 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,171 +1,150 @@ import { memo } from "react"; -import { Draggable, DraggableStateSnapshot } from "@hello-pangea/dnd"; -import isEqual from "lodash/isEqual"; +import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react-lite"; +// hooks +import { useApplication, useIssueDetail, useProject } from "hooks/store"; // components -import { KanBanProperties } from "./properties"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { IssueProperties } from "../properties/all-properties"; // ui -import { Tooltip } from "@plane/ui"; +import { Tooltip, ControlLink } from "@plane/ui"; // types -import { IIssueDisplayProperties, IIssue } from "types"; +import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; -import { useRouter } from "next/router"; +// helper +import { cn } from "helpers/common.helper"; interface IssueBlockProps { - sub_group_id: string; - columnId: string; - index: number; - issue: IIssue; + peekIssueId?: string; + issueId: string; + issuesMap: IIssueMap; + displayProperties: IIssueDisplayProperties | undefined; isDragDisabled: boolean; - showEmptyGroup: boolean; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; + draggableId: string; + index: number; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; } interface IssueDetailsBlockProps { - sub_group_id: string; - columnId: string; - issue: IIssue; - showEmptyGroup: boolean; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; + issue: TIssue; + displayProperties: IIssueDisplayProperties | undefined; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue) => React.ReactNode; isReadOnly: boolean; - snapshot: DraggableStateSnapshot; - isDragDisabled: boolean; } -const KanbanIssueDetailsBlock: React.FC = (props) => { +const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { + const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props; + // hooks + const { getProjectById } = useProject(); const { - sub_group_id, - columnId, - issue, - showEmptyGroup, - handleIssues, - quickActions, - displayProperties, - isReadOnly, - snapshot, - isDragDisabled, - } = props; - - const router = useRouter(); + router: { workspaceSlug, projectId }, + } = useApplication(); + const { setPeekIssue } = useIssueDetail(); - const updateIssue = (sub_group_by: string | null, group_by: string | null, issueToUpdate: IIssue) => { - if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, EIssueActions.UPDATE); + const updateIssue = (issueToUpdate: TIssue) => { + if (issueToUpdate) handleIssues(issueToUpdate, EIssueActions.UPDATE); }; - const handleIssuePeekOverview = (event: React.MouseEvent) => { - const { query } = router; - if (event.ctrlKey || event.metaKey) { - const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; - window.open(issueUrl, "_blank"); // Open link in a new tab - } else { - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); - } - }; + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); return ( -
- {displayProperties && displayProperties?.key && ( -
-
- {issue.project_detail.identifier}-{issue.sequence_id} -
-
- {quickActions( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !columnId && columnId === "null" ? null : columnId, - issue - )} + <> + +
+
+ {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
+
{quickActions(issue)}
- )} - -
{issue.name}
-
-
- -
-
- ); -}; + -const validateMemo = (prevProps: IssueDetailsBlockProps, nextProps: IssueDetailsBlockProps) => { - if (prevProps.issue !== nextProps.issue) return false; - if (!isEqual(prevProps.displayProperties, nextProps.displayProperties)) { - return false; - } - return true; -}; + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + -const KanbanIssueMemoBlock = memo(KanbanIssueDetailsBlock, validateMemo); + + + ); +}); -export const KanbanIssueBlock: React.FC = (props) => { +export const KanbanIssueBlock: React.FC = memo((props) => { const { - sub_group_id, - columnId, - index, - issue, + peekIssueId, + issueId, + issuesMap, + displayProperties, isDragDisabled, - showEmptyGroup, + draggableId, + index, handleIssues, quickActions, - displayProperties, canEditProperties, } = props; - let draggableId = issue.id; - if (columnId) draggableId = `${draggableId}__${columnId}`; - if (sub_group_id) draggableId = `${draggableId}__${sub_group_id}`; + const issue = issuesMap[issueId]; + + if (!issue) return null; - const canEditIssueProperties = canEditProperties(issue.project); + const canEditIssueProperties = canEditProperties(issue.project_id); return ( - <> - - {(provided, snapshot) => ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( +
+ {issue.tempId !== undefined && ( +
+ )}
- {issue.tempId !== undefined && ( -
+ className={cn( + "space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400", + { "hover:cursor-grab": !isDragDisabled }, + { "border-custom-primary-100": snapshot.isDragging }, + { "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id } )} - +
- )} - - +
+ )} + ); -}; +}); + +KanbanIssueBlock.displayName = "KanbanIssueBlock"; diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index fe3f85c3334..15c797833ac 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,38 +1,34 @@ +import { memo } from "react"; +//types +import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; +import { EIssueActions } from "../types"; // components import { KanbanIssueBlock } from "components/issues"; -import { IIssueDisplayProperties, IIssue } from "types"; -import { EIssueActions } from "../types"; -import { IIssueResponse } from "store/issues/types"; interface IssueBlocksListProps { sub_group_id: string; columnId: string; - issues: IIssueResponse; + issuesMap: IIssueMap; + peekIssueId?: string; issueIds: string[]; + displayProperties: IIssueDisplayProperties | undefined; isDragDisabled: boolean; - showEmptyGroup: boolean; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - quickActions: ( - sub_group_by: string | null, - group_by: string | null, - issue: IIssue, - customActionButton?: React.ReactElement - ) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; } -export const KanbanIssueBlocksList: React.FC = (props) => { +const KanbanIssueBlocksListMemo: React.FC = (props) => { const { sub_group_id, columnId, - issues, + issuesMap, + peekIssueId, issueIds, - showEmptyGroup, + displayProperties, isDragDisabled, handleIssues, quickActions, - displayProperties, canEditProperties, } = props; @@ -41,34 +37,32 @@ export const KanbanIssueBlocksList: React.FC = (props) => {issueIds && issueIds.length > 0 ? ( <> {issueIds.map((issueId, index) => { - if (!issues[issueId]) return null; + if (!issueId) return null; - const issue = issues[issueId]; + let draggableId = issueId; + if (columnId) draggableId = `${draggableId}__${columnId}`; + if (sub_group_id) draggableId = `${draggableId}__${sub_group_id}`; return ( ); })} - ) : ( - !isDragDisabled && ( -
- {/*
Drop here
*/} -
- ) - )} + ) : null} ); }; + +export const KanbanIssueBlocksList = memo(KanbanIssueBlocksListMemo); diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 87fb98bf743..de6c1ddae7b 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -1,460 +1,218 @@ -import React from "react"; import { observer } from "mobx-react-lite"; -import { Droppable } from "@hello-pangea/dnd"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssueDetail, useKanbanView, useLabel, useMember, useProject, useProjectState } from "hooks/store"; // components -import { KanBanGroupByHeaderRoot } from "./headers/group-by-root"; -import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "components/issues"; +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { KanbanGroup } from "./kanban-group"; // types -import { IIssueDisplayProperties, IIssue, IState } from "types"; +import { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + TIssue, + IIssueDisplayProperties, + IIssueMap, + TSubGroupedIssues, + TUnGroupedIssues, + TIssueKanbanFilters, +} from "@plane/types"; // constants -import { getValueFromObject } from "constants/issue"; import { EIssueActions } from "../types"; -import { IIssueResponse, IGroupedIssues, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types"; -import { EProjectStore } from "store/command-palette.store"; +import { getGroupByColumns } from "../utils"; +import { TCreateModalStoreTypes } from "constants/issue"; export interface IGroupByKanBan { - issues: IIssueResponse; - issueIds: any; + issuesMap: IIssueMap; + issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; sub_group_by: string | null; group_by: string | null; - order_by: string | null; sub_group_id: string; - list: any; - listKey: string; - states: IState[] | null; isDragDisabled: boolean; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - showEmptyGroup: boolean; - quickActions: ( - sub_group_by: string | null, - group_by: string | null, - issue: IIssue, - customActionButton?: React.ReactElement - ) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; - kanBanToggle: any; - handleKanBanToggle: any; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: any; enableQuickIssueCreate?: boolean; - isDragStarted?: boolean; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType?: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; } const GroupByKanBan: React.FC = observer((props) => { const { - issues, + issuesMap, issueIds, + displayProperties, sub_group_by, group_by, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - order_by, sub_group_id = "null", - list, - listKey, isDragDisabled, handleIssues, - showEmptyGroup, quickActions, - displayProperties, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, enableQuickIssueCreate, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isDragStarted, quickAddCallback, viewId, disableIssueCreation, - currentStore, + storeType, addIssuesToView, canEditProperties, } = props; - const verticalAlignPosition = (_list: any) => - kanBanToggle?.groupByHeaderMinMax.includes(getValueFromObject(_list, listKey) as string); + const member = useMember(); + const project = useProject(); + const label = useLabel(); + const projectState = useProjectState(); + const { peekIssue } = useIssueDetail(); + + const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); + + if (!list) return null; + + const visibilityGroupBy = (_list: IGroupByColumn) => + sub_group_by ? false : kanbanFilters?.group_by.includes(_list.id) ? true : false; + + const isGroupByCreatedBy = group_by === "created_by"; return ( -
+
{list && list.length > 0 && - list.map((_list: any) => ( -
- {sub_group_by === null && ( -
- -
- )} + list.map((_list: IGroupByColumn) => { + const groupByVisibilityToggle = visibilityGroupBy(_list); + return (
- - {(provided: any, snapshot: any) => ( -
- {issues && !verticalAlignPosition(_list) ? ( - - ) : ( - isDragDisabled && ( -
- {/*
Drop here
*/} -
- ) - )} - - {provided.placeholder} -
- )} -
- -
- {enableQuickIssueCreate && !disableIssueCreation && ( - + - )} -
-
- - {/* {isDragStarted && isDragDisabled && ( -
-
- {`This board is ordered by "${replaceUnderscoreIfSnakeCase( - order_by ? (order_by[0] === "-" ? order_by.slice(1) : order_by) : "created_at" - )}"`}
-
- )} */} -
- ))} + )} + + {!groupByVisibilityToggle && ( + + )} +
+ ); + })}
); }); export interface IKanBan { - issues: IIssueResponse; - issueIds: IGroupedIssues | ISubGroupedIssues | TUnGroupedIssues; + issuesMap: IIssueMap; + issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; sub_group_by: string | null; group_by: string | null; - order_by: string | null; sub_group_id?: string; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - quickActions: ( - sub_group_by: string | null, - group_by: string | null, - issue: IIssue, - customActionButton?: React.ReactElement - ) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; - kanBanToggle: any; - handleKanBanToggle: any; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; - states: any; - stateGroups: any; - priorities: any; - labels: any; - members: any; - projects: any; enableQuickIssueCreate?: boolean; - isDragStarted?: boolean; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType?: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; } export const KanBan: React.FC = observer((props) => { const { - issues, + issuesMap, issueIds, + displayProperties, sub_group_by, group_by, - order_by, sub_group_id = "null", handleIssues, quickActions, - displayProperties, - kanBanToggle, - handleKanBanToggle, - showEmptyGroup, - states, - stateGroups, - priorities, - labels, - members, - projects, + kanbanFilters, + handleKanbanFilters, enableQuickIssueCreate, - isDragStarted, quickAddCallback, viewId, disableIssueCreation, - currentStore, + storeType, addIssuesToView, canEditProperties, } = props; - const { issueKanBanView: issueKanBanViewStore } = useMobxStore(); + const issueKanBanView = useKanbanView(); return ( -
- {group_by && group_by === "project" && ( - - )} - - {group_by && group_by === "state" && ( - - )} - - {group_by && group_by === "state_detail.group" && ( - - )} - - {group_by && group_by === "priority" && ( - - )} - - {group_by && group_by === "labels" && ( - - )} - - {group_by && group_by === "assignees" && ( - - )} - - {group_by && group_by === "created_by" && ( - - )} -
+ ); }); diff --git a/web/components/issues/issue-layouts/kanban/headers/assignee.tsx b/web/components/issues/issue-layouts/kanban/headers/assignee.tsx deleted file mode 100644 index e90e292d731..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/assignee.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; -// ui -import { Avatar } from "@plane/ui"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IAssigneesHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const Icon = ({ user }: any) => ; - -export const AssigneesHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const assignee = column_value ?? null; - - return ( - <> - {assignee && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={assignee?.display_name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={assignee?.display_name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{ assignees: [assignee?.id] }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/headers/created_by.tsx b/web/components/issues/issue-layouts/kanban/headers/created_by.tsx deleted file mode 100644 index 840d21b92a6..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/created_by.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; -import { Icon } from "./assignee"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface ICreatedByHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const CreatedByHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const createdBy = column_value ?? null; - - return ( - <> - {createdBy && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={createdBy?.display_name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={createdBy?.display_name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{ created_by: createdBy?.id }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); 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 90c7e302f4c..713a6644a19 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 @@ -2,9 +2,8 @@ import React, { FC } from "react"; import { useRouter } from "next/router"; // components import { CustomMenu } from "@plane/ui"; -import { CreateUpdateIssueModal } from "components/issues/modal"; -import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; import { ExistingIssuesListModal } from "components/core"; +import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; // hooks @@ -12,8 +11,8 @@ import useToast from "hooks/use-toast"; // mobx import { observer } from "mobx-react-lite"; // types -import { IIssue, ISearchIssueResponse } from "types"; -import { EProjectStore } from "store/command-palette.store"; +import { TIssue, ISearchIssueResponse, TIssueKanbanFilters } from "@plane/types"; +import { TCreateModalStoreTypes } from "constants/issue"; interface IHeaderGroupByCard { sub_group_by: string | null; @@ -22,12 +21,12 @@ interface IHeaderGroupByCard { icon?: React.ReactNode; title: string; count: number; - kanBanToggle: any; - handleKanBanToggle: any; - issuePayload: Partial; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: any; + issuePayload: Partial; disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType?: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; } export const HeaderGroupByCard: FC = observer((props) => { @@ -37,14 +36,14 @@ export const HeaderGroupByCard: FC = observer((props) => { icon, title, count, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, issuePayload, disableIssueCreation, - currentStore, + storeType, addIssuesToView, } = props; - const verticalAlignPosition = kanBanToggle?.groupByHeaderMinMax.includes(column_id); + const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id); const [isOpen, setIsOpen] = React.useState(false); const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); @@ -57,21 +56,22 @@ export const HeaderGroupByCard: FC = observer((props) => { const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; - const ExistingIssuesListModalPayload = moduleId ? { module: true } : { cycle: true }; + const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true }; const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; const issues = data.map((i) => i.id); - addIssuesToView && - addIssuesToView(issues)?.catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Selected issues could not be added to the cycle. Please try again.", - }); + try { + addIssuesToView && addIssuesToView(issues); + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Selected issues could not be added to the cycle. Please try again.", }); + } }; return ( @@ -86,13 +86,15 @@ export const HeaderGroupByCard: FC = observer((props) => { ) : ( setIsOpen(false)} - prePopulateData={issuePayload} - currentStore={currentStore} + onClose={() => setIsOpen(false)} + data={issuePayload} + storeType={storeType} /> )} {renderExistingIssueModal && ( setOpenExistingIssueListModal(false)} searchParams={ExistingIssuesListModalPayload} @@ -122,7 +124,7 @@ export const HeaderGroupByCard: FC = observer((props) => { {sub_group_by === null && (
handleKanBanToggle("groupByHeaderMinMax", column_id)} + onClick={() => handleKanbanFilters("group_by", column_id)} > {verticalAlignPosition ? ( @@ -135,7 +137,6 @@ export const HeaderGroupByCard: FC = observer((props) => { {!disableIssueCreation && (renderExistingIssueModal ? ( diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-root.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-root.tsx deleted file mode 100644 index f668b1b7963..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-root.tsx +++ /dev/null @@ -1,149 +0,0 @@ -// components -import { ProjectHeader } from "./project"; -import { StateHeader } from "./state"; -import { StateGroupHeader } from "./state-group"; -import { AssigneesHeader } from "./assignee"; -import { PriorityHeader } from "./priority"; -import { LabelHeader } from "./label"; -import { CreatedByHeader } from "./created_by"; -// mobx -import { observer } from "mobx-react-lite"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IKanBanGroupByHeaderRoot { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const KanBanGroupByHeaderRoot: React.FC = observer( - ({ - column_id, - column_value, - sub_group_by, - group_by, - issues_count, - kanBanToggle, - disableIssueCreation, - handleKanBanToggle, - currentStore, - addIssuesToView, - }) => ( - <> - {group_by && group_by === "project" && ( - - )} - - {group_by && group_by === "state" && ( - - )} - {group_by && group_by === "state_detail.group" && ( - - )} - {group_by && group_by === "priority" && ( - - )} - {group_by && group_by === "labels" && ( - - )} - {group_by && group_by === "assignees" && ( - - )} - {group_by && group_by === "created_by" && ( - - )} - - ) -); diff --git a/web/components/issues/issue-layouts/kanban/headers/label.tsx b/web/components/issues/issue-layouts/kanban/headers/label.tsx deleted file mode 100644 index 0924ad07824..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/label.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface ILabelHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -const Icon = ({ color }: any) => ( -
-); - -export const LabelHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const label = column_value ?? null; - - return ( - <> - {label && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={label?.name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={label?.name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{ labels: [label?.id] }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/headers/priority.tsx b/web/components/issues/issue-layouts/kanban/headers/priority.tsx deleted file mode 100644 index 0dc654a4c1f..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/priority.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; - -// Icons -import { PriorityIcon } from "@plane/ui"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IPriorityHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const PriorityHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const priority = column_value || null; - - return ( - <> - {priority && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={priority?.title || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={priority?.title || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{ priority: priority?.key }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/headers/project.tsx b/web/components/issues/issue-layouts/kanban/headers/project.tsx deleted file mode 100644 index 62bbbd2aeba..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/project.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; -// emoji helper -import { renderEmoji } from "helpers/emoji.helper"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IProjectHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -const Icon = ({ emoji }: any) =>
{renderEmoji(emoji)}
; - -export const ProjectHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const project = column_value ?? null; - - return ( - <> - {project && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={project?.name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={project?.name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{ project: project?.id }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/headers/state-group.tsx b/web/components/issues/issue-layouts/kanban/headers/state-group.tsx deleted file mode 100644 index b192a47579a..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/state-group.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; -import { StateGroupIcon } from "@plane/ui"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IStateGroupHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const Icon = ({ stateGroup, color }: { stateGroup: any; color?: any }) => ( -
- -
-); - -export const StateGroupHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const stateGroup = column_value || null; - - return ( - <> - {stateGroup && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={stateGroup?.key || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={stateGroup?.key || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{}} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/headers/state.tsx b/web/components/issues/issue-layouts/kanban/headers/state.tsx deleted file mode 100644 index 95cff31cb29..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/state.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { HeaderSubGroupByCard } from "./sub-group-by-card"; -import { Icon } from "./state-group"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IStateHeader { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - header_type: "group_by" | "sub_group_by"; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const StateHeader: FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - header_type, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - const state = column_value ?? null; - - return ( - <> - {state && - (sub_group_by && header_type === "sub_group_by" ? ( - } - title={state?.name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - /> - ) : ( - } - title={state?.name || ""} - count={issues_count} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} - issuePayload={{ state: state?.id }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - ))} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx index de5e7abc45b..ea94647802e 100644 --- a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -1,26 +1,26 @@ import React from "react"; -// lucide icons import { Circle, ChevronDown, ChevronUp } from "lucide-react"; // mobx import { observer } from "mobx-react-lite"; +import { TIssueKanbanFilters } from "@plane/types"; interface IHeaderSubGroupByCard { icon?: React.ReactNode; title: string; count: number; column_id: string; - kanBanToggle: any; - handleKanBanToggle: any; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; } export const HeaderSubGroupByCard = observer( - ({ icon, title, count, column_id, kanBanToggle, handleKanBanToggle }: IHeaderSubGroupByCard) => ( + ({ icon, title, count, column_id, kanbanFilters, handleKanbanFilters }: IHeaderSubGroupByCard) => (
handleKanBanToggle("subgroupByIssuesVisibility", column_id)} + onClick={() => handleKanbanFilters("sub_group_by", column_id)} > - {kanBanToggle?.subgroupByIssuesVisibility.includes(column_id) ? ( + {kanbanFilters?.sub_group_by.includes(column_id) ? ( ) : ( diff --git a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-root.tsx b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-root.tsx deleted file mode 100644 index 8cdf1c9ec30..00000000000 --- a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-root.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// mobx -import { observer } from "mobx-react-lite"; -// components -import { StateHeader } from "./state"; -import { StateGroupHeader } from "./state-group"; -import { AssigneesHeader } from "./assignee"; -import { PriorityHeader } from "./priority"; -import { LabelHeader } from "./label"; -import { CreatedByHeader } from "./created_by"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IKanBanSubGroupByHeaderRoot { - column_id: string; - column_value: any; - sub_group_by: string | null; - group_by: string | null; - issues_count: number; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const KanBanSubGroupByHeaderRoot: React.FC = observer((props) => { - const { - column_id, - column_value, - sub_group_by, - group_by, - issues_count, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, - } = props; - - return ( - <> - {sub_group_by && sub_group_by === "state" && ( - - )} - {sub_group_by && sub_group_by === "state_detail.group" && ( - - )} - {sub_group_by && sub_group_by === "priority" && ( - - )} - {sub_group_by && sub_group_by === "labels" && ( - - )} - {sub_group_by && sub_group_by === "assignees" && ( - - )} - {sub_group_by && sub_group_by === "created_by" && ( - - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx new file mode 100644 index 00000000000..1a25c563e78 --- /dev/null +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -0,0 +1,153 @@ +import { Droppable } from "@hello-pangea/dnd"; +// hooks +import { useProjectState } from "hooks/store"; +//components +import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; +//types +import { + TGroupedIssues, + TIssue, + IIssueDisplayProperties, + IIssueMap, + TSubGroupedIssues, + TUnGroupedIssues, +} from "@plane/types"; +import { EIssueActions } from "../types"; + +interface IKanbanGroup { + groupId: string; + issuesMap: IIssueMap; + peekIssueId?: string; + issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + sub_group_by: string | null; + group_by: string | null; + sub_group_id: string; + isDragDisabled: boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + enableQuickIssueCreate?: boolean; + quickAddCallback?: ( + workspaceSlug: string, + projectId: string, + data: TIssue, + viewId?: string + ) => Promise; + viewId?: string; + disableIssueCreation?: boolean; + canEditProperties: (projectId: string | undefined) => boolean; + groupByVisibilityToggle: boolean; +} + +export const KanbanGroup = (props: IKanbanGroup) => { + const { + groupId, + sub_group_id, + group_by, + sub_group_by, + issuesMap, + displayProperties, + issueIds, + peekIssueId, + isDragDisabled, + handleIssues, + quickActions, + canEditProperties, + enableQuickIssueCreate, + disableIssueCreation, + quickAddCallback, + viewId, + } = props; + // hooks + const projectState = useProjectState(); + + const prePopulateQuickAddData = ( + groupByKey: string | null, + subGroupByKey: string | null, + groupValue: string, + subGroupValue: string + ) => { + const defaultState = projectState.projectStates?.find((state) => state.default); + let preloadedData: object = { state_id: defaultState?.id }; + + if (groupByKey) { + if (groupByKey === "state") { + preloadedData = { ...preloadedData, state_id: groupValue }; + } else if (groupByKey === "priority") { + preloadedData = { ...preloadedData, priority: groupValue }; + } else if (groupByKey === "labels" && groupValue != "None") { + preloadedData = { ...preloadedData, label_ids: [groupValue] }; + } else if (groupByKey === "assignees" && groupValue != "None") { + preloadedData = { ...preloadedData, assignee_ids: [groupValue] }; + } else if (groupByKey === "created_by") { + preloadedData = { ...preloadedData }; + } else { + preloadedData = { ...preloadedData, [groupByKey]: groupValue }; + } + } + + if (subGroupByKey) { + if (subGroupByKey === "state") { + preloadedData = { ...preloadedData, state_id: subGroupValue }; + } else if (subGroupByKey === "priority") { + preloadedData = { ...preloadedData, priority: subGroupValue }; + } else if (subGroupByKey === "labels" && subGroupValue != "None") { + preloadedData = { ...preloadedData, label_ids: [subGroupValue] }; + } else if (subGroupByKey === "assignees" && subGroupValue != "None") { + preloadedData = { ...preloadedData, assignee_ids: [subGroupValue] }; + } else if (subGroupByKey === "created_by") { + preloadedData = { ...preloadedData }; + } else { + preloadedData = { ...preloadedData, [subGroupByKey]: subGroupValue }; + } + } + + return preloadedData; + }; + + return ( +
+ + {(provided: any, snapshot: any) => ( +
+ + + {provided.placeholder} + + {enableQuickIssueCreate && !disableIssueCreation && ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/web/components/issues/issue-layouts/kanban/properties.tsx b/web/components/issues/issue-layouts/kanban/properties.tsx deleted file mode 100644 index 5be5a12c5b3..00000000000 --- a/web/components/issues/issue-layouts/kanban/properties.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// mobx -import { observer } from "mobx-react-lite"; -// lucide icons -import { Layers, Link, Paperclip } from "lucide-react"; -// components -import { IssuePropertyState } from "../properties/state"; -import { IssuePropertyPriority } from "../properties/priority"; -import { IssuePropertyLabels } from "../properties/labels"; -import { IssuePropertyAssignee } from "../properties/assignee"; -import { IssuePropertyEstimates } from "../properties/estimates"; -import { IssuePropertyDate } from "../properties/date"; -import { Tooltip } from "@plane/ui"; -import { IIssue, IIssueDisplayProperties, IState, TIssuePriorities } from "types"; - -export interface IKanBanProperties { - sub_group_id: string; - columnId: string; - issue: IIssue; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => void; - displayProperties: IIssueDisplayProperties | null; - showEmptyGroup: boolean; - isReadOnly: boolean; -} - -export const KanBanProperties: React.FC = observer((props) => { - const { sub_group_id, columnId: group_id, issue, handleIssues, displayProperties, isReadOnly } = props; - - const handleState = (state: IState) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, state: state.id } - ); - }; - - const handlePriority = (value: TIssuePriorities) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, priority: value } - ); - }; - - const handleLabel = (ids: string[]) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, labels: ids } - ); - }; - - const handleAssignee = (ids: string[]) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, assignees: ids } - ); - }; - - const handleStartDate = (date: string | null) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, start_date: date } - ); - }; - - const handleTargetDate = (date: string | null) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, target_date: date } - ); - }; - - const handleEstimate = (value: number | null) => { - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, estimate_point: value } - ); - }; - - return ( -
- {/* basic properties */} - {/* state */} - {displayProperties && displayProperties?.state && ( - - )} - - {/* priority */} - {displayProperties && displayProperties?.priority && ( - - )} - - {/* label */} - {displayProperties && displayProperties?.labels && ( - - )} - - {/* start date */} - {displayProperties && displayProperties?.start_date && ( - handleStartDate(date)} - disabled={isReadOnly} - type="start_date" - /> - )} - - {/* target/due date */} - {displayProperties && displayProperties?.due_date && ( - handleTargetDate(date)} - disabled={isReadOnly} - type="target_date" - /> - )} - - {/* assignee */} - {displayProperties && displayProperties?.assignee && ( - - )} - - {/* estimates */} - {displayProperties && displayProperties?.estimate && ( - - )} - - {/* extra render properties */} - {/* sub-issues */} - {displayProperties && displayProperties?.sub_issue_count && !!issue?.sub_issues_count && ( - -
- -
{issue.sub_issues_count}
-
-
- )} - - {/* attachments */} - {displayProperties && displayProperties?.attachment_count && !!issue?.attachment_count && ( - -
- -
{issue.attachment_count}
-
-
- )} - - {/* link */} - {displayProperties && displayProperties?.link && !!issue?.link_count && ( - -
- -
{issue.link_count}
-
-
- )} -
- ); -}); diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 9c4406f7d31..b4610a2e0e9 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -1,18 +1,17 @@ import { useEffect, useState, useRef } from "react"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; -import { PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; +import { PlusIcon } from "lucide-react"; // hooks +import { useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers import { createIssuePayload } from "helpers/issue.helper"; // types -import { IIssue, IProject } from "types"; +import { TIssue } from "@plane/types"; const Inputs = (props: any) => { const { register, setFocus, projectDetail } = props; @@ -37,35 +36,32 @@ const Inputs = (props: any) => { }; interface IKanBanQuickAddIssueForm { - formKey: keyof IIssue; + formKey: keyof TIssue; groupId?: string; subGroupId?: string | null; - prePopulatedData?: Partial; + prePopulatedData?: Partial; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; } -const defaultValues: Partial = { +const defaultValues: Partial = { name: "", }; export const KanBanQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, groupId, prePopulatedData, quickAddCallback, viewId } = props; - + const { formKey, prePopulatedData, quickAddCallback, viewId } = props; // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - - const { workspace: workspaceStore, project: projectStore } = useMobxStore(); + const { workspaceSlug, projectId } = router.query; + // store hooks + const { getProjectById } = useProject(); - const workspaceDetail = (workspaceSlug && workspaceStore.getWorkspaceBySlug(workspaceSlug)) || null; - const projectDetail: IProject | null = - (workspaceSlug && projectId && projectStore.getProjectById(workspaceSlug, projectId)) || null; + const projectDetail = projectId ? getProjectById(projectId.toString()) : null; const ref = useRef(null); @@ -82,18 +78,18 @@ export const KanBanQuickAddIssueForm: React.FC = obser setFocus, register, formState: { isSubmitting }, - } = useForm({ defaultValues }); + } = useForm({ defaultValues }); useEffect(() => { if (!isOpen) reset({ ...defaultValues }); }, [isOpen, reset]); - const onSubmitHandler = async (formData: IIssue) => { - if (isSubmitting || !groupId || !workspaceDetail || !projectDetail) return; + const onSubmitHandler = async (formData: TIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail, projectDetail, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); @@ -101,8 +97,8 @@ export const KanBanQuickAddIssueForm: React.FC = obser try { quickAddCallback && (await quickAddCallback( - workspaceSlug, - projectId, + workspaceSlug.toString(), + projectId.toString(), { ...payload, }, @@ -124,13 +120,13 @@ export const KanBanQuickAddIssueForm: React.FC = obser }; return ( -
+ <> {isOpen ? ( -
+
@@ -145,33 +141,6 @@ export const KanBanQuickAddIssueForm: React.FC = obser New Issue
)} - - {/* {isOpen && ( -
- - - )} - - {isOpen && ( -

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

- )} - - {!isOpen && ( - - )} */} -
+ ); }); 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 b54b18edbdb..0903355ceec 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -1,17 +1,16 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues } from "hooks/store"; // ui import { CycleIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // components import { BaseKanBanRoot } from "../base-kanban-root"; -import { EProjectStore } from "store/command-palette.store"; -import { IGroupedIssues, IIssueResponse, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { EIssuesStoreType } from "constants/issue"; export interface ICycleKanBanLayout {} @@ -20,78 +19,42 @@ export const CycleKanBanLayout: React.FC = observer(() => { const { workspaceSlug, projectId, cycleId } = router.query; // store - const { - cycleIssues: cycleIssueStore, - cycleIssuesFilter: cycleIssueFilterStore, - cycleIssueKanBanView: cycleIssueKanBanViewStore, - kanBanHelpers: kanBanHelperStore, - cycle: { fetchCycleWithId }, - } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; - - await cycleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, cycleId.toString()); - fetchCycleWithId(workspaceSlug.toString(), issue.project, cycleId.toString()); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; - - await cycleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, cycleId.toString()); - fetchCycleWithId(workspaceSlug.toString(), issue.project, cycleId.toString()); - }, - [EIssueActions.REMOVE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId || !issue.bridge_id) return; - - await cycleIssueStore.removeIssueFromCycle( - workspaceSlug.toString(), - issue.project, - cycleId.toString(), - issue.id, - issue.bridge_id - ); - fetchCycleWithId(workspaceSlug.toString(), issue.project, cycleId.toString()); - }, - }; - - const handleDragDrop = async ( - source: any, - destination: any, - subGroupBy: string | null, - groupBy: string | null, - issues: IIssueResponse | undefined, - issueWithIds: IGroupedIssues | ISubGroupedIssues | TUnGroupedIssues | undefined - ) => { - if (workspaceSlug && projectId && cycleId) - return await kanBanHelperStore.handleDragDrop( - source, - destination, - workspaceSlug.toString(), - projectId.toString(), - cycleIssueStore, - subGroupBy, - groupBy, - issues, - issueWithIds, - cycleId.toString() - ); - }; + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, cycleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); + }, + }), + [issues, workspaceSlug, cycleId] + ); return ( - cycleIssueStore.addIssueToCycle(workspaceSlug?.toString() ?? "", cycleId?.toString() ?? "", issues) - } + storeType={EIssuesStoreType.CYCLE} + addIssuesToView={(issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx index 78f5a76eb81..9152dbfe576 100644 --- a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx @@ -1,14 +1,16 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; import { BaseKanBanRoot } from "../base-kanban-root"; +import { EIssuesStoreType } from "constants/issue"; +import { useMemo } from "react"; export interface IKanBanLayout {} @@ -16,31 +18,30 @@ export const DraftKanBanLayout: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug } = router.query; - const { - projectDraftIssues: issueStore, - projectDraftIssuesFilter: projectIssuesFilterStore, - issueKanBanView: issueKanBanViewStore, - } = useMobxStore(); + // store + const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await issueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await issueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id); - }, - }; + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id); + }, + }), + [issues, workspaceSlug] + ); return ( 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 138787b1fe6..c3af69e6eb1 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -1,17 +1,16 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hook +import { useIssues } from "hooks/store"; // components import { ModuleIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; import { BaseKanBanRoot } from "../base-kanban-root"; -import { EProjectStore } from "store/command-palette.store"; -import { IGroupedIssues, IIssueResponse, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { EIssuesStoreType } from "constants/issue"; export interface IModuleKanBanLayout {} @@ -20,77 +19,42 @@ export const ModuleKanBanLayout: React.FC = observer(() => { const { workspaceSlug, projectId, moduleId } = router.query; // store - const { - moduleIssues: moduleIssueStore, - moduleIssuesFilter: moduleIssueFilterStore, - moduleIssueKanBanView: moduleIssueKanBanViewStore, - kanBanHelpers: kanBanHelperStore, - module: { fetchModuleDetails }, - } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; - - await moduleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, moduleId.toString()); - fetchModuleDetails(workspaceSlug.toString(), issue.project, moduleId.toString()); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; - - await moduleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, moduleId.toString()); - fetchModuleDetails(workspaceSlug.toString(), issue.project, moduleId.toString()); - }, - [EIssueActions.REMOVE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId || !issue.bridge_id) return; - - await moduleIssueStore.removeIssueFromModule( - workspaceSlug.toString(), - issue.project, - moduleId.toString(), - issue.id, - issue.bridge_id - ); - fetchModuleDetails(workspaceSlug.toString(), issue.project, moduleId.toString()); - }, - }; + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, moduleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); + }, + }), + [issues, workspaceSlug, moduleId] + ); - const handleDragDrop = async ( - source: any, - destination: any, - subGroupBy: string | null, - groupBy: string | null, - issues: IIssueResponse | undefined, - issueWithIds: IGroupedIssues | ISubGroupedIssues | TUnGroupedIssues | undefined - ) => { - if (workspaceSlug && projectId && moduleId) - return await kanBanHelperStore.handleDragDrop( - source, - destination, - workspaceSlug.toString(), - projectId.toString(), - moduleIssueStore, - subGroupBy, - groupBy, - issues, - issueWithIds, - moduleId.toString() - ); - }; return ( - moduleIssueStore.addIssueToModule(workspaceSlug?.toString() ?? "", moduleId?.toString() ?? "", issues) - } + viewId={moduleId?.toString()} + storeType={EIssuesStoreType.MODULE} + addIssuesToView={(issueIds: string[]) => { + if (!workspaceSlug || !projectId || !moduleId) throw new Error(); + return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); + }} /> ); }); 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 c1466140f64..2e189c9f4fb 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 @@ -1,56 +1,58 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues, useUser } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; import { BaseKanBanRoot } from "../base-kanban-root"; -import { EProjectStore } from "store/command-palette.store"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { EIssuesStoreType } from "constants/issue"; +import { useMemo } from "react"; export const ProfileIssuesKanBanLayout: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, userId } = router.query as { workspaceSlug: string; userId: string }; + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROFILE); + const { - workspaceProfileIssues: profileIssuesStore, - workspaceProfileIssuesFilter: profileIssueFiltersStore, - workspaceMember: { currentWorkspaceUserProjectsRole }, - issueKanBanView: issueKanBanViewStore, - } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !userId) return; - - await profileIssuesStore.updateIssue(workspaceSlug, userId, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !userId) return; - - await profileIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id, userId); - }, - }; + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue, userId); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, + }), + [issues, workspaceSlug, userId] + ); const canEditPropertiesBasedOnProject = (projectId: string) => { - const currentProjectRole = currentWorkspaceUserProjectsRole && currentWorkspaceUserProjectsRole[projectId]; + const currentProjectRole = currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId]; - return !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + return !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; }; return ( ); 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 16ef0f65bd9..89e2ee1872a 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -1,73 +1,49 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useMemo } from "react"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store/use-issues"; // components import { ProjectIssueQuickActions } from "components/issues"; +import { BaseKanBanRoot } from "../base-kanban-root"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; -import { BaseKanBanRoot } from "../base-kanban-root"; -import { EProjectStore } from "store/command-palette.store"; -import { IGroupedIssues, IIssueResponse, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { EIssuesStoreType } from "constants/issue"; export interface IKanBanLayout {} export const KanBanLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug } = router.query as { workspaceSlug: string; projectId: string }; - const { - projectIssues: issueStore, - projectIssuesFilter: issuesFilterStore, - issueKanBanView: issueKanBanViewStore, - kanBanHelpers: kanBanHelperStore, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await issueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + await issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await issueStore.removeIssue(workspaceSlug, issue.project, issue.id); - }, - }; - - const handleDragDrop = async ( - source: any, - destination: any, - subGroupBy: string | null, - groupBy: string | null, - issues: IIssueResponse | undefined, - issueWithIds: IGroupedIssues | ISubGroupedIssues | TUnGroupedIssues | undefined - ) => - await kanBanHelperStore.handleDragDrop( - source, - destination, - workspaceSlug, - projectId, - issueStore, - subGroupBy, - groupBy, - issues, - issueWithIds - ); + await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); + }, + }), + [issues, workspaceSlug] + ); return ( ); }); 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 5edfe10ad54..1cdf71d456e 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 @@ -1,73 +1,42 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues } from "hooks/store"; // constant -import { IIssue } from "types"; +import { EIssuesStoreType } from "constants/issue"; +// types +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; -import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; // components import { BaseKanBanRoot } from "../base-kanban-root"; -import { EProjectStore } from "store/command-palette.store"; -import { IGroupedIssues, IIssueResponse, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; -export interface IViewKanBanLayout {} +export interface IViewKanBanLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + }; +} -export const ProjectViewKanBanLayout: React.FC = observer(() => { +export const ProjectViewKanBanLayout: React.FC = observer((props) => { + const { issueActions } = props; + // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - - const { - viewIssues: projectViewIssuesStore, - viewIssuesFilter: projectIssueViewFiltersStore, - issueKanBanView: projectViewIssueKanBanViewStore, - kanBanHelpers: kanBanHelperStore, - } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; - - await projectViewIssuesStore.updateIssue(workspaceSlug, issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; - - await projectViewIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id); - }, - }; + const { viewId } = router.query; - const handleDragDrop = async ( - source: any, - destination: any, - subGroupBy: string | null, - groupBy: string | null, - issues: IIssueResponse | undefined, - issueWithIds: IGroupedIssues | ISubGroupedIssues | TUnGroupedIssues | undefined - ) => - await kanBanHelperStore.handleDragDrop( - source, - destination, - workspaceSlug, - projectId, - projectViewIssuesStore, - subGroupBy, - groupBy, - issues, - issueWithIds - ); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); return ( ); }); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index f03e3a8d07e..1b9f27828f7 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -1,142 +1,112 @@ -import React from "react"; import { observer } from "mobx-react-lite"; // components -import { KanBanGroupByHeaderRoot } from "./headers/group-by-root"; -import { KanBanSubGroupByHeaderRoot } from "./headers/sub-group-by-root"; import { KanBan } from "./default"; +import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; +import { HeaderGroupByCard } from "./headers/group-by-card"; // types -import { IIssue, IIssueDisplayProperties, IIssueLabel, IProject, IState, IUserLite } from "types"; -import { IIssueResponse, IGroupedIssues, ISubGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + TIssue, + IIssueDisplayProperties, + IIssueMap, + TSubGroupedIssues, + TUnGroupedIssues, + TIssueKanbanFilters, +} from "@plane/types"; // constants -import { getValueFromObject } from "constants/issue"; import { EIssueActions } from "../types"; -import { EProjectStore } from "store/command-palette.store"; +import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { getGroupByColumns } from "../utils"; +import { TCreateModalStoreTypes } from "constants/issue"; interface ISubGroupSwimlaneHeader { - issues: IIssueResponse; - issueIds: any; + issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; sub_group_by: string | null; group_by: string | null; - list: any; - listKey: string; - kanBanToggle: any; - handleKanBanToggle: any; - disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + list: IGroupByColumn[]; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; } const SubGroupSwimlaneHeader: React.FC = ({ issueIds, sub_group_by, group_by, list, - listKey, - kanBanToggle, - handleKanBanToggle, - disableIssueCreation, - currentStore, - addIssuesToView, -}) => { - const calculateIssueCount = (column_id: string) => { - let issueCount = 0; - issueIds && - Object.keys(issueIds)?.forEach((_issueKey: any) => { - issueCount += issueIds?.[_issueKey]?.[column_id]?.length || 0; - }); - return issueCount; - }; - - return ( -
- {list && - list.length > 0 && - list.map((_list: any) => ( -
- -
- ))} -
- ); -}; + kanbanFilters, + handleKanbanFilters, +}) => ( +
+ {list && + list.length > 0 && + list.map((_list: IGroupByColumn) => ( +
+ +
+ ))} +
+); interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { - issues: IIssueResponse; - issueIds: any; - order_by: string | null; + issuesMap: IIssueMap; + issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; showEmptyGroup: boolean; - states: IState[] | null; - stateGroups: any; - priorities: any; - labels: IIssueLabel[] | null; - members: IUserLite[] | null; - projects: IProject[] | null; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - quickActions: ( - sub_group_by: string | null, - group_by: string | null, - issue: IIssue, - customActionButton?: React.ReactElement - ) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; - kanBanToggle: any; - handleKanBanToggle: any; + displayProperties: IIssueDisplayProperties | undefined; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; isDragStarted?: boolean; disableIssueCreation?: boolean; - currentStore?: EProjectStore; + storeType?: TCreateModalStoreTypes; enableQuickIssueCreate: boolean; canEditProperties: (projectId: string | undefined) => boolean; + addIssuesToView?: (issueIds: string[]) => Promise; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; + viewId?: string; } const SubGroupSwimlane: React.FC = observer((props) => { const { - issues, + issuesMap, issueIds, sub_group_by, group_by, - order_by, list, - listKey, handleIssues, quickActions, displayProperties, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, showEmptyGroup, - states, - stateGroups, - priorities, - labels, - members, - projects, - isDragStarted, - disableIssueCreation, enableQuickIssueCreate, canEditProperties, addIssuesToView, quickAddCallback, + viewId, } = props; const calculateIssueCount = (column_id: string) => { let issueCount = 0; - issueIds?.[column_id] && - Object.keys(issueIds?.[column_id])?.forEach((_list: any) => { - issueCount += issueIds?.[column_id]?.[_list]?.length || 0; + const subGroupedIds = issueIds as TSubGroupedIssues; + subGroupedIds?.[column_id] && + Object.keys(subGroupedIds?.[column_id])?.forEach((_list: any) => { + issueCount += subGroupedIds?.[column_id]?.[_list]?.length || 0; }); return issueCount; }; @@ -149,46 +119,37 @@ const SubGroupSwimlane: React.FC = observer((props) => {
-
- {!kanBanToggle?.subgroupByIssuesVisibility.includes(getValueFromObject(_list, listKey) as string) && ( + + {!kanbanFilters?.sub_group_by.includes(_list.id) && (
)} @@ -199,414 +160,95 @@ const SubGroupSwimlane: React.FC = observer((props) => { }); export interface IKanBanSwimLanes { - issues: IIssueResponse; - issueIds: IGroupedIssues | ISubGroupedIssues | TUnGroupedIssues; + issuesMap: IIssueMap; + issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; sub_group_by: string | null; group_by: string | null; - order_by: string | null; - handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void; - quickActions: ( - sub_group_by: string | null, - group_by: string | null, - issue: IIssue, - customActionButton?: React.ReactElement - ) => React.ReactNode; - displayProperties: IIssueDisplayProperties | null; - kanBanToggle: any; - handleKanBanToggle: any; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + kanbanFilters: TIssueKanbanFilters; + handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; showEmptyGroup: boolean; - states: IState[] | null; - stateGroups: any; - priorities: any; - labels: IIssueLabel[] | null; - members: IUserLite[] | null; - projects: IProject[] | null; isDragStarted?: boolean; disableIssueCreation?: boolean; - currentStore?: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType?: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; enableQuickIssueCreate: boolean; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; + viewId?: string; canEditProperties: (projectId: string | undefined) => boolean; } export const KanBanSwimLanes: React.FC = observer((props) => { const { - issues, + issuesMap, issueIds, + displayProperties, sub_group_by, group_by, - order_by, handleIssues, quickActions, - displayProperties, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, showEmptyGroup, - states, - stateGroups, - priorities, - labels, - members, - projects, isDragStarted, disableIssueCreation, enableQuickIssueCreate, canEditProperties, - currentStore, addIssuesToView, quickAddCallback, + viewId, } = props; - return ( -
-
- {group_by && group_by === "project" && ( - - )} - - {group_by && group_by === "state" && ( - - )} - - {group_by && group_by === "state_detail.group" && ( - - )} - - {group_by && group_by === "priority" && ( - - )} - - {group_by && group_by === "labels" && ( - - )} - - {group_by && group_by === "assignees" && ( - - )} - - {group_by && group_by === "created_by" && ( - - )} -
- - {sub_group_by && sub_group_by === "project" && ( - - )} + const member = useMember(); + const project = useProject(); + const label = useLabel(); + const projectState = useProjectState(); - {sub_group_by && sub_group_by === "state" && ( - - )} + const groupByList = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); + const subGroupByList = getGroupByColumns(sub_group_by as GroupByColumnTypes, project, label, projectState, member); - {sub_group_by && sub_group_by === "state" && ( - - )} + if (!groupByList || !subGroupByList) return null; - {sub_group_by && sub_group_by === "state_detail.group" && ( - +
+ - )} - - {sub_group_by && sub_group_by === "priority" && ( - - )} +
- {sub_group_by && sub_group_by === "labels" && ( + {sub_group_by && ( - )} - - {sub_group_by && sub_group_by === "assignees" && ( - - )} - - {sub_group_by && sub_group_by === "created_by" && ( - )}
diff --git a/web/components/issues/issue-layouts/kanban/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts new file mode 100644 index 00000000000..5c5de8c4528 --- /dev/null +++ b/web/components/issues/issue-layouts/kanban/utils.ts @@ -0,0 +1,159 @@ +import { DraggableLocation } from "@hello-pangea/dnd"; +import { ICycleIssues } from "store/issue/cycle"; +import { IDraftIssues } from "store/issue/draft"; +import { IModuleIssues } from "store/issue/module"; +import { IProfileIssues } from "store/issue/profile"; +import { IProjectIssues } from "store/issue/project"; +import { IProjectViewIssues } from "store/issue/project-views"; +import { IWorkspaceIssues } from "store/issue/workspace"; +import { TGroupedIssues, IIssueMap, TSubGroupedIssues, TUnGroupedIssues } from "@plane/types"; + +const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => { + const sortOrderDefaultValue = 65535; + let currentIssueState = {}; + + if (destinationIssues && destinationIssues.length > 0) { + if (destinationIndex === 0) { + const destinationIssueId = destinationIssues[destinationIndex]; + currentIssueState = { + ...currentIssueState, + sort_order: issueMap[destinationIssueId].sort_order - sortOrderDefaultValue, + }; + } else if (destinationIndex === destinationIssues.length) { + const destinationIssueId = destinationIssues[destinationIndex - 1]; + currentIssueState = { + ...currentIssueState, + sort_order: issueMap[destinationIssueId].sort_order + sortOrderDefaultValue, + }; + } else { + const destinationTopIssueId = destinationIssues[destinationIndex - 1]; + const destinationBottomIssueId = destinationIssues[destinationIndex]; + currentIssueState = { + ...currentIssueState, + sort_order: (issueMap[destinationTopIssueId].sort_order + issueMap[destinationBottomIssueId].sort_order) / 2, + }; + } + } else { + currentIssueState = { + ...currentIssueState, + sort_order: sortOrderDefaultValue, + }; + } + + return currentIssueState; +}; + +export const handleDragDrop = async ( + source: DraggableLocation | null | undefined, + destination: DraggableLocation | null | undefined, + workspaceSlug: string | undefined, + projectId: string | undefined, // projectId for all views or user id in profile issues + store: + | IProjectIssues + | ICycleIssues + | IDraftIssues + | IModuleIssues + | IDraftIssues + | IProjectViewIssues + | IProfileIssues + | IWorkspaceIssues, + subGroupBy: string | null, + groupBy: string | null, + issueMap: IIssueMap, + issueWithIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined, + viewId: string | null = null // it can be moduleId, cycleId +) => { + if (!issueMap || !issueWithIds || !source || !destination || !workspaceSlug || !projectId) return; + + let updateIssue: any = {}; + + const sourceColumnId = (source?.droppableId && source?.droppableId.split("__")) || null; + const destinationColumnId = (destination?.droppableId && destination?.droppableId.split("__")) || null; + + if (!sourceColumnId || !destinationColumnId) return; + + const sourceGroupByColumnId = sourceColumnId[0] || null; + const destinationGroupByColumnId = destinationColumnId[0] || null; + + const sourceSubGroupByColumnId = sourceColumnId[1] || null; + const destinationSubGroupByColumnId = destinationColumnId[1] || null; + + if ( + !workspaceSlug || + !projectId || + !groupBy || + !sourceGroupByColumnId || + !destinationGroupByColumnId || + !sourceSubGroupByColumnId || + !destinationSubGroupByColumnId + ) + return; + + if (destinationGroupByColumnId === "issue-trash-box") { + const sourceIssues: string[] = subGroupBy + ? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId] + : (issueWithIds as TGroupedIssues)[sourceGroupByColumnId]; + + const [removed] = sourceIssues.splice(source.index, 1); + + if (removed) { + if (viewId) return await store?.removeIssue(workspaceSlug, projectId, removed); //, viewId); + else return await store?.removeIssue(workspaceSlug, projectId, removed); + } + } else { + const sourceIssues = subGroupBy + ? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][sourceGroupByColumnId] + : (issueWithIds as TGroupedIssues)[sourceGroupByColumnId]; + const destinationIssues = subGroupBy + ? (issueWithIds as TSubGroupedIssues)[sourceSubGroupByColumnId][destinationGroupByColumnId] + : (issueWithIds as TGroupedIssues)[destinationGroupByColumnId]; + + const [removed] = sourceIssues.splice(source.index, 1); + const removedIssueDetail = issueMap[removed]; + + updateIssue = { + id: removedIssueDetail?.id, + project_id: removedIssueDetail?.project_id, + }; + + // for both horizontal and vertical dnd + updateIssue = { + ...updateIssue, + ...handleSortOrder(destinationIssues, destination.index, issueMap), + }; + + if (subGroupBy && sourceSubGroupByColumnId && destinationSubGroupByColumnId) { + if (sourceSubGroupByColumnId === destinationSubGroupByColumnId) { + if (sourceGroupByColumnId != destinationGroupByColumnId) { + if (groupBy === "state") updateIssue = { ...updateIssue, state_id: destinationGroupByColumnId }; + if (groupBy === "priority") updateIssue = { ...updateIssue, priority: destinationGroupByColumnId }; + } + } else { + if (subGroupBy === "state") + updateIssue = { + ...updateIssue, + state_id: destinationSubGroupByColumnId, + priority: destinationGroupByColumnId, + }; + if (subGroupBy === "priority") + updateIssue = { + ...updateIssue, + state_id: destinationGroupByColumnId, + priority: destinationSubGroupByColumnId, + }; + } + } else { + // for horizontal dnd + if (sourceColumnId != destinationColumnId) { + if (groupBy === "state") updateIssue = { ...updateIssue, state_id: destinationGroupByColumnId }; + if (groupBy === "priority") updateIssue = { ...updateIssue, priority: destinationGroupByColumnId }; + } + } + + if (updateIssue && updateIssue?.id) { + if (viewId) + return await store?.updateIssue(workspaceSlug, updateIssue.project_id, updateIssue.id, updateIssue, viewId); + else return await store?.updateIssue(workspaceSlug, updateIssue.project_id, updateIssue.id, updateIssue); + } + } +}; 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 55b2fce55ba..10f3582f14b 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,31 +1,22 @@ import { List } from "./default"; -import { useMobxStore } from "lib/mobx/store-provider"; -import { ISSUE_PRIORITIES, ISSUE_STATE_GROUPS } from "constants/issue"; -import { FC } from "react"; -import { IIssue, IProject } from "types"; -import { IProjectStore } from "store/project"; -import { Spinner } from "@plane/ui"; -import { IQuickActionProps } from "./list-view-types"; -import { - ICycleIssuesFilterStore, - ICycleIssuesStore, - IModuleIssuesFilterStore, - IModuleIssuesStore, - IProfileIssuesFilterStore, - IProfileIssuesStore, - IProjectArchivedIssuesStore, - IProjectDraftIssuesStore, - IProjectIssuesFilterStore, - IProjectIssuesStore, - IViewIssuesFilterStore, - IViewIssuesStore, -} from "store/issues"; +import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; -import { IIssueResponse } from "store/issues/types"; -import { EProjectStore } from "store/command-palette.store"; -import { IssuePeekOverview } from "components/issues"; -import { useRouter } from "next/router"; -import { EUserWorkspaceRoles } from "constants/workspace"; +// types +import { TIssue } from "@plane/types"; +import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; +import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; +import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; +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"; +// components +import { IQuickActionProps } from "./list-view-types"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { TCreateModalStoreTypes } from "constants/issue"; +// hooks +import { useIssues, useUser } from "hooks/store"; enum EIssueActions { UPDATE = "update", @@ -34,145 +25,119 @@ enum EIssueActions { } interface IBaseListRoot { - issueFilterStore: - | IProjectIssuesFilterStore - | IModuleIssuesFilterStore - | ICycleIssuesFilterStore - | IViewIssuesFilterStore - | IProfileIssuesFilterStore; - issueStore: - | IProjectIssuesStore - | IModuleIssuesStore - | ICycleIssuesStore - | IViewIssuesStore - | IProjectArchivedIssuesStore - | IProjectDraftIssuesStore - | IProfileIssuesStore; + issuesFilter: + | IProjectIssuesFilter + | IModuleIssuesFilter + | ICycleIssuesFilter + | IProjectViewIssuesFilter + | IProfileIssuesFilter + | IDraftIssuesFilter + | IArchivedIssuesFilter; + issues: + | IProjectIssues + | ICycleIssues + | IModuleIssues + | IProjectViewIssues + | IProfileIssues + | IDraftIssues + | IArchivedIssues; QuickActions: FC; issueActions: { - [EIssueActions.DELETE]: (group_by: string | null, issue: IIssue) => Promise; - [EIssueActions.UPDATE]?: (group_by: string | null, issue: IIssue) => Promise; - [EIssueActions.REMOVE]?: (group_by: string | null, issue: IIssue) => Promise; + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; - getProjects: (projectStore: IProjectStore) => IProject[] | null; viewId?: string; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; } export const BaseListRoot = observer((props: IBaseListRoot) => { const { - issueFilterStore, - issueStore, + issuesFilter, + issues, QuickActions, issueActions, - getProjects, viewId, - currentStore, + storeType, addIssuesToView, canEditPropertiesBasedOnProject, } = props; - // router - const router = useRouter(); - const { workspaceSlug, peekIssueId, peekProjectId } = router.query; // mobx store const { - project: projectStore, - projectMember: { projectMembers }, - projectState: projectStateStore, - projectLabel: { projectLabels }, - user: userStore, - } = useMobxStore(); + membership: { currentProjectRole }, + } = useUser(); - const { currentProjectRole } = userStore; - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const { issueMap } = useIssues(); - const issueIds = issueStore?.getIssuesIds || []; - const issues = issueStore?.getIssues; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issueStore?.viewFlags || {}; - const canEditProperties = (projectId: string | undefined) => { - const isEditingAllowedBasedOnProject = - canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; + const issueIds = issues?.groupedIssueIds || []; - return enableInlineEditing && isEditingAllowedBasedOnProject; - }; + const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + const canEditProperties = useCallback( + (projectId: string | undefined) => { + const isEditingAllowedBasedOnProject = + canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; + + return enableInlineEditing && isEditingAllowedBasedOnProject; + }, + [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] + ); + + const displayFilters = issuesFilter?.issueFilters?.displayFilters; + const displayProperties = issuesFilter?.issueFilters?.displayProperties; - const displayFilters = issueFilterStore?.issueFilters?.displayFilters; const group_by = displayFilters?.group_by || null; const showEmptyGroup = displayFilters?.show_empty_groups ?? false; - const displayProperties = issueFilterStore?.issueFilters?.displayProperties; + const handleIssues = useCallback( + async (issue: TIssue, action: EIssueActions) => { + if (issueActions[action]) { + await issueActions[action]!(issue); + } + }, + [issueActions] + ); - const states = projectStateStore?.projectStates; - const priorities = ISSUE_PRIORITIES; - const labels = projectLabels; - const stateGroups = ISSUE_STATE_GROUPS; - const projects = getProjects(projectStore); - const members = projectMembers?.map((m) => m.member) ?? null; - const handleIssues = async (issue: IIssue, action: EIssueActions) => { - if (issueActions[action]) { - await issueActions[action]!(group_by, issue); - } - }; + const renderQuickActions = useCallback( + (issue: TIssue) => ( + handleIssues(issue, EIssueActions.DELETE)} + handleUpdate={ + issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined + } + handleRemoveFromView={ + issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined + } + /> + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleIssues] + ); return ( <> - {issueStore?.loader === "init-loader" ? ( -
- -
- ) : ( -
- ( - handleIssues(issue, EIssueActions.DELETE)} - handleUpdate={ - issueActions[EIssueActions.UPDATE] - ? async (data) => handleIssues(data, EIssueActions.UPDATE) - : undefined - } - handleRemoveFromView={ - issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined - } - /> - )} - displayProperties={displayProperties} - states={states} - stateGroups={stateGroups} - priorities={priorities} - labels={labels} - members={members} - projects={projects} - issueIds={issueIds} - showEmptyGroup={showEmptyGroup} - viewId={viewId} - quickAddCallback={issueStore?.quickAddIssue} - enableIssueQuickAdd={!!enableQuickAdd} - canEditProperties={canEditProperties} - disableIssueCreation={!enableIssueCreation || !isEditingAllowed} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> -
- )} - - {workspaceSlug && peekIssueId && peekProjectId && ( - - await handleIssues(issueToUpdate as IIssue, action) - } +
+ - )} +
); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 562f599abc3..b2222a69ea1 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,77 +1,95 @@ -import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; // components -import { ListProperties } from "./properties"; +import { IssueProperties } from "../properties/all-properties"; +// hooks +import { useApplication, useIssueDetail, useProject } from "hooks/store"; // ui -import { Spinner, Tooltip } from "@plane/ui"; +import { Spinner, Tooltip, ControlLink } from "@plane/ui"; +// helper +import { cn } from "helpers/common.helper"; // types -import { IIssue, IIssueDisplayProperties } from "types"; +import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; interface IssueBlockProps { - columnId: string; - - issue: IIssue; - handleIssues: (issue: IIssue, action: EIssueActions) => void; - quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; + issueId: string; + issuesMap: TIssueMap; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; canEditProperties: (projectId: string | undefined) => boolean; } -export const IssueBlock: React.FC = (props) => { - const { columnId, issue, handleIssues, quickActions, displayProperties, canEditProperties } = props; - // router - const router = useRouter(); - const updateIssue = (group_by: string | null, issueToUpdate: IIssue) => { +export const IssueBlock: React.FC = observer((props: IssueBlockProps) => { + const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props; + // hooks + const { + router: { workspaceSlug, projectId }, + } = useApplication(); + const { getProjectById } = useProject(); + const { peekIssue, setPeekIssue } = useIssueDetail(); + + const updateIssue = (issueToUpdate: TIssue) => { handleIssues(issueToUpdate, EIssueActions.UPDATE); }; - const handleIssuePeekOverview = (event: React.MouseEvent) => { - const { query } = router; - if (event.ctrlKey || event.metaKey) { - const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; - window.open(issueUrl, "_blank"); // Open link in a new tab - } else { - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); - } - }; + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); + + const issue = issuesMap[issueId]; - const canEditIssueProperties = canEditProperties(issue.project); + if (!issue) return null; + + const canEditIssueProperties = canEditProperties(issue.project_id); + const projectDetails = getProjectById(issue.project_id); return ( <> - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 7270ae06da6..5e02d638f77 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -2,41 +2,39 @@ import { FC } from "react"; // components import { IssueBlock } from "components/issues"; // types -import { IIssue, IIssueDisplayProperties } from "types"; -import { IIssueResponse, IGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { EIssueActions } from "../types"; interface Props { - columnId: string; - issueIds: IGroupedIssues | TUnGroupedIssues | any; - issues: IIssueResponse; + issueIds: TGroupedIssues | TUnGroupedIssues | any; + issuesMap: TIssueMap; canEditProperties: (projectId: string | undefined) => boolean; - handleIssues: (issue: IIssue, action: EIssueActions) => void; - quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; + handleIssues: (issue: TIssue, action: EIssueActions) => void; + quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; } export const IssueBlocksList: FC = (props) => { - const { columnId, issueIds, issues, handleIssues, quickActions, displayProperties, canEditProperties } = props; + const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props; return ( -
+
{issueIds && issueIds.length > 0 ? ( - issueIds.map( - (issueId: string) => - issueId != undefined && - issues[issueId] && ( - - ) - ) + issueIds.map((issueId: string) => { + if (!issueId) return null; + + return ( + + ); + }) ) : (
No issues
)} diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 24781bb412a..95e31b758c8 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,25 +1,28 @@ -import React from "react"; // components -import { ListGroupByHeaderRoot } from "./headers/group-by-root"; import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; +// hooks +import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; // types -import { IIssue, IIssueDisplayProperties, IIssueLabel, IProject, IState, IUserLite } from "types"; -import { IIssueResponse, IGroupedIssues, TUnGroupedIssues } from "store/issues/types"; +import { + GroupByColumnTypes, + TGroupedIssues, + TIssue, + IIssueDisplayProperties, + TIssueMap, + TUnGroupedIssues, +} from "@plane/types"; import { EIssueActions } from "../types"; // constants -import { getValueFromObject } from "constants/issue"; -import { EProjectStore } from "store/command-palette.store"; +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { getGroupByColumns } from "../utils"; +import { TCreateModalStoreTypes } from "constants/issue"; export interface IGroupByList { - issueIds: IGroupedIssues | TUnGroupedIssues | any; - issues: any; + issueIds: TGroupedIssues | TUnGroupedIssues | any; + issuesMap: TIssueMap; group_by: string | null; - list: any; - listKey: string; - states: IState[] | null; - is_list?: boolean; - handleIssues: (issue: IIssue, action: EIssueActions) => Promise; - quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; enableIssueQuickAdd: boolean; showEmptyGroup?: boolean; @@ -27,24 +30,20 @@ export interface IGroupByList { quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; viewId?: string; } const GroupByList: React.FC = (props) => { const { issueIds, - issues, + issuesMap, group_by, - list, - listKey, - is_list = false, - states, handleIssues, quickActions, displayProperties, @@ -54,54 +53,78 @@ const GroupByList: React.FC = (props) => { quickAddCallback, viewId, disableIssueCreation, - currentStore, + storeType, addIssuesToView, } = props; + // store hooks + const member = useMember(); + const project = useProject(); + const label = useLabel(); + const projectState = useProjectState(); + + const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + + if (!list) return null; const prePopulateQuickAddData = (groupByKey: string | null, value: any) => { - const defaultState = states?.find((state) => state.default); - if (groupByKey === null) return { state: defaultState?.id }; - else { - if (groupByKey === "state") return { state: groupByKey === "state" ? value : defaultState?.id }; - else return { state: defaultState?.id, [groupByKey]: value }; + const defaultState = projectState.projectStates?.find((state) => state.default); + let preloadedData: object = { state_id: defaultState?.id }; + + if (groupByKey === null) { + preloadedData = { ...preloadedData }; + } else { + if (groupByKey === "state") { + preloadedData = { ...preloadedData, state_id: value }; + } else if (groupByKey === "priority") { + preloadedData = { ...preloadedData, priority: value }; + } else if (groupByKey === "labels" && value != "None") { + preloadedData = { ...preloadedData, label_ids: [value] }; + } else if (groupByKey === "assignees" && value != "None") { + preloadedData = { ...preloadedData, assignee_ids: [value] }; + } else if (groupByKey === "created_by") { + preloadedData = { ...preloadedData }; + } else { + preloadedData = { ...preloadedData, [groupByKey]: value }; + } } + + return preloadedData; }; - const validateEmptyIssueGroups = (issues: IIssue[]) => { + const validateEmptyIssueGroups = (issues: TIssue[]) => { const issuesCount = issues?.length || 0; if (!showEmptyGroup && issuesCount <= 0) return false; return true; }; + const is_list = group_by === null ? true : false; + + const isGroupByCreatedBy = group_by === "created_by"; + return (
{list && list.length > 0 && list.map( (_list: any) => - validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[getValueFromObject(_list, listKey) as string]) && ( -
+ validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && ( +
-
- {issues && ( + {issueIds && ( = (props) => { /> )} - {enableIssueQuickAdd && !disableIssueCreation && ( + {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && (
@@ -126,37 +149,31 @@ const GroupByList: React.FC = (props) => { }; export interface IList { - issueIds: IGroupedIssues | TUnGroupedIssues | any; - issues: IIssueResponse | undefined; + issueIds: TGroupedIssues | TUnGroupedIssues | any; + issuesMap: TIssueMap; group_by: string | null; - handleIssues: (issue: IIssue, action: EIssueActions) => Promise; - quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; showEmptyGroup: boolean; enableIssueQuickAdd: boolean; canEditProperties: (projectId: string | undefined) => boolean; - states: IState[] | null; - labels: IIssueLabel[] | null; - members: IUserLite[] | null; - projects: IProject[] | null; - stateGroups: any; - priorities: any; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; } export const List: React.FC = (props) => { const { issueIds, - issues, + issuesMap, group_by, handleIssues, quickActions, @@ -167,194 +184,28 @@ export const List: React.FC = (props) => { enableIssueQuickAdd, canEditProperties, disableIssueCreation, - states, - stateGroups, - priorities, - labels, - members, - projects, - currentStore, + storeType, addIssuesToView, } = props; return (
- {group_by === null && ( - - )} - - {group_by && group_by === "project" && projects && ( - - )} - - {group_by && group_by === "state" && states && ( - - )} - - {group_by && group_by === "state_detail.group" && stateGroups && ( - - )} - - {group_by && group_by === "priority" && priorities && ( - - )} - - {group_by && group_by === "labels" && labels && ( - - )} - - {group_by && group_by === "assignees" && members && ( - - )} - - {group_by && group_by === "created_by" && members && ( - - )} +
); }; diff --git a/web/components/issues/issue-layouts/list/headers/assignee.tsx b/web/components/issues/issue-layouts/list/headers/assignee.tsx deleted file mode 100644 index d129774aa8b..00000000000 --- a/web/components/issues/issue-layouts/list/headers/assignee.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -// ui -import { Avatar } from "@plane/ui"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IAssigneesHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const Icon = ({ user }: any) => ; - -export const AssigneesHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const assignee = column_value ?? null; - - return ( - <> - {assignee && ( - } - title={assignee?.display_name || ""} - count={issues_count} - issuePayload={{ assignees: [assignee?.member?.id] }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/created-by.tsx b/web/components/issues/issue-layouts/list/headers/created-by.tsx deleted file mode 100644 index 77306998b6b..00000000000 --- a/web/components/issues/issue-layouts/list/headers/created-by.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { Icon } from "./assignee"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface ICreatedByHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const CreatedByHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const createdBy = column_value ?? null; - - return ( - <> - {createdBy && ( - } - title={createdBy?.display_name || ""} - count={issues_count} - issuePayload={{ created_by: createdBy?.member?.id }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/empty-group.tsx b/web/components/issues/issue-layouts/list/headers/empty-group.tsx deleted file mode 100644 index c7b16fe2624..00000000000 --- a/web/components/issues/issue-layouts/list/headers/empty-group.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IEmptyHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const EmptyHeader: React.FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - return ( - - ); -}); 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 c703ea66b82..7a7a2d1ab88 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 @@ -1,58 +1,58 @@ -import React from "react"; import { useRouter } from "next/router"; // lucide icons import { CircleDashed, Plus } from "lucide-react"; // components -import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; -import { CreateUpdateIssueModal } from "components/issues/modal"; +import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; import { ExistingIssuesListModal } from "components/core"; import { CustomMenu } from "@plane/ui"; // mobx import { observer } from "mobx-react-lite"; // types -import { IIssue, ISearchIssueResponse } from "types"; -import { EProjectStore } from "store/command-palette.store"; +import { TIssue, ISearchIssueResponse } from "@plane/types"; import useToast from "hooks/use-toast"; +import { useState } from "react"; +import { TCreateModalStoreTypes } from "constants/issue"; interface IHeaderGroupByCard { icon?: React.ReactNode; title: string; count: number; - issuePayload: Partial; + issuePayload: Partial; disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; + storeType: TCreateModalStoreTypes; + addIssuesToView?: (issueIds: string[]) => Promise; } export const HeaderGroupByCard = observer( - ({ icon, title, count, issuePayload, disableIssueCreation, currentStore, addIssuesToView }: IHeaderGroupByCard) => { + ({ icon, title, count, issuePayload, disableIssueCreation, storeType, addIssuesToView }: IHeaderGroupByCard) => { const router = useRouter(); const { workspaceSlug, projectId, moduleId, cycleId } = router.query; - const [isOpen, setIsOpen] = React.useState(false); + const [isOpen, setIsOpen] = useState(false); - const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); + const [openExistingIssueListModal, setOpenExistingIssueListModal] = useState(false); const isDraftIssue = router.pathname.includes("draft-issue"); const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; - const ExistingIssuesListModalPayload = moduleId ? { module: true } : { cycle: true }; + const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true }; const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; const issues = data.map((i) => i.id); - addIssuesToView && - addIssuesToView(issues)?.catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Selected issues could not be added to the cycle. Please try again.", - }); + try { + addIssuesToView && addIssuesToView(issues); + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Selected issues could not be added to the cycle. Please try again.", }); + } }; return ( @@ -70,7 +70,6 @@ export const HeaderGroupByCard = observer( {!disableIssueCreation && (renderExistingIssueModal ? ( @@ -103,14 +102,16 @@ export const HeaderGroupByCard = observer( ) : ( setIsOpen(false)} - currentStore={currentStore} - prePopulateData={issuePayload} + onClose={() => setIsOpen(false)} + data={issuePayload} + storeType={storeType} /> )} {renderExistingIssueModal && ( setOpenExistingIssueListModal(false)} searchParams={ExistingIssuesListModalPayload} diff --git a/web/components/issues/issue-layouts/list/headers/group-by-root.tsx b/web/components/issues/issue-layouts/list/headers/group-by-root.tsx deleted file mode 100644 index 50ed9ad982f..00000000000 --- a/web/components/issues/issue-layouts/list/headers/group-by-root.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// components -import { EmptyHeader } from "./empty-group"; -import { ProjectHeader } from "./project"; -import { StateHeader } from "./state"; -import { StateGroupHeader } from "./state-group"; -import { AssigneesHeader } from "./assignee"; -import { PriorityHeader } from "./priority"; -import { LabelHeader } from "./label"; -import { CreatedByHeader } from "./created-by"; -// mobx -import { observer } from "mobx-react-lite"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IListGroupByHeaderRoot { - column_id: string; - column_value: any; - group_by: string | null; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const ListGroupByHeaderRoot: React.FC = observer((props) => { - const { column_id, column_value, group_by, issues_count, disableIssueCreation, currentStore, addIssuesToView } = - props; - - return ( - <> - {!group_by && group_by === null && ( - - )} - {group_by && group_by === "project" && ( - - )} - - {group_by && group_by === "state" && ( - - )} - {group_by && group_by === "state_detail.group" && ( - - )} - {group_by && group_by === "priority" && ( - - )} - {group_by && group_by === "labels" && ( - - )} - {group_by && group_by === "assignees" && ( - - )} - {group_by && group_by === "created_by" && ( - - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/label.tsx b/web/components/issues/issue-layouts/list/headers/label.tsx deleted file mode 100644 index b4d740e37e0..00000000000 --- a/web/components/issues/issue-layouts/list/headers/label.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface ILabelHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -const Icon = ({ color }: any) => ( -
-); - -export const LabelHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const label = column_value ?? null; - - return ( - <> - {column_value && ( - } - title={column_value?.name || ""} - count={issues_count} - issuePayload={{ labels: [label.id] }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/priority.tsx b/web/components/issues/issue-layouts/list/headers/priority.tsx deleted file mode 100644 index 5eb19fbfd30..00000000000 --- a/web/components/issues/issue-layouts/list/headers/priority.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { AlertCircle, SignalHigh, SignalMedium, SignalLow, Ban } from "lucide-react"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IPriorityHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -const Icon = ({ priority }: any) => ( -
- {priority === "urgent" ? ( -
- -
- ) : priority === "high" ? ( -
- -
- ) : priority === "medium" ? ( -
- -
- ) : priority === "low" ? ( -
- -
- ) : ( -
- -
- )} -
-); - -export const PriorityHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const priority = column_value ?? null; - - return ( - <> - {priority && ( - } - title={priority?.title || ""} - count={issues_count} - issuePayload={{ priority: priority?.key }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/project.tsx b/web/components/issues/issue-layouts/list/headers/project.tsx deleted file mode 100644 index 7578214b245..00000000000 --- a/web/components/issues/issue-layouts/list/headers/project.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -// emoji helper -import { renderEmoji } from "helpers/emoji.helper"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IProjectHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -const Icon = ({ emoji }: any) =>
{renderEmoji(emoji)}
; - -export const ProjectHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const project = column_value ?? null; - - return ( - <> - {project && ( - } - title={project?.name || ""} - count={issues_count} - issuePayload={{ project: project?.id ?? "" }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/state-group.tsx b/web/components/issues/issue-layouts/list/headers/state-group.tsx deleted file mode 100644 index 421a1da8f38..00000000000 --- a/web/components/issues/issue-layouts/list/headers/state-group.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -// ui -import { StateGroupIcon } from "@plane/ui"; -// helpers -import { capitalizeFirstLetter } from "helpers/string.helper"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IStateGroupHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const Icon = ({ stateGroup, color }: { stateGroup: any; color?: any }) => ( -
- -
-); - -export const StateGroupHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const stateGroup = column_value ?? null; - - return ( - <> - {stateGroup && ( - } - title={capitalizeFirstLetter(stateGroup?.key) || ""} - count={issues_count} - issuePayload={{}} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/list/headers/state.tsx b/web/components/issues/issue-layouts/list/headers/state.tsx deleted file mode 100644 index 926743464b9..00000000000 --- a/web/components/issues/issue-layouts/list/headers/state.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { HeaderGroupByCard } from "./group-by-card"; -import { Icon } from "./state-group"; -import { EProjectStore } from "store/command-palette.store"; -import { IIssue } from "types"; - -export interface IStateHeader { - column_id: string; - column_value: any; - issues_count: number; - disableIssueCreation?: boolean; - currentStore: EProjectStore; - addIssuesToView?: (issueIds: string[]) => Promise; -} - -export const StateHeader: FC = observer((props) => { - const { column_value, issues_count, disableIssueCreation, currentStore, addIssuesToView } = props; - - const state = column_value ?? null; - - return ( - <> - {state && ( - } - title={state?.name || ""} - count={issues_count} - issuePayload={{ state: state?.id }} - disableIssueCreation={disableIssueCreation} - currentStore={currentStore} - addIssuesToView={addIssuesToView} - /> - )} - - ); -}); 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 efdd79cfc7f..9e3bb8701f6 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 @@ -1,7 +1,8 @@ export interface IQuickActionProps { - issue: IIssue; + issue: TIssue; handleDelete: () => Promise; - handleUpdate?: (data: IIssue) => Promise; + handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; customActionButton?: React.ReactElement; + portalElement?: HTMLDivElement | null; } diff --git a/web/components/issues/issue-layouts/list/properties.tsx b/web/components/issues/issue-layouts/list/properties.tsx deleted file mode 100644 index 07129910fe7..00000000000 --- a/web/components/issues/issue-layouts/list/properties.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { Layers, Link, Paperclip } from "lucide-react"; -// components -import { IssuePropertyState } from "../properties/state"; -import { IssuePropertyPriority } from "../properties/priority"; -import { IssuePropertyLabels } from "../properties/labels"; -import { IssuePropertyAssignee } from "../properties/assignee"; -import { IssuePropertyEstimates } from "../properties/estimates"; -import { IssuePropertyDate } from "../properties/date"; -// ui -import { Tooltip } from "@plane/ui"; -// types -import { IIssue, IIssueDisplayProperties, IState, TIssuePriorities } from "types"; - -export interface IListProperties { - columnId: string; - issue: IIssue; - handleIssues: (group_by: string | null, issue: IIssue) => void; - displayProperties: IIssueDisplayProperties | undefined; - isReadonly?: boolean; -} - -export const ListProperties: FC = observer((props) => { - const { columnId: group_id, issue, handleIssues, displayProperties, isReadonly } = props; - - const handleState = (state: IState) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, state: state.id }); - }; - - const handlePriority = (value: TIssuePriorities) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, priority: value }); - }; - - const handleLabel = (ids: string[]) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, labels: ids }); - }; - - const handleAssignee = (ids: string[]) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, assignees: ids }); - }; - - const handleStartDate = (date: string | null) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, start_date: date }); - }; - - const handleTargetDate = (date: string | null) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, target_date: date }); - }; - - const handleEstimate = (value: number | null) => { - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, estimate_point: value }); - }; - - return ( -
- {/* basic properties */} - {/* state */} - {displayProperties && displayProperties?.state && ( - - )} - - {/* priority */} - {displayProperties && displayProperties?.priority && ( - - )} - - {/* label */} - {displayProperties && displayProperties?.labels && ( - - )} - - {/* assignee */} - {displayProperties && displayProperties?.assignee && ( - - )} - - {/* start date */} - {displayProperties && displayProperties?.start_date && ( - handleStartDate(date)} - disabled={isReadonly} - type="start_date" - /> - )} - - {/* target/due date */} - {displayProperties && displayProperties?.due_date && ( - handleTargetDate(date)} - disabled={isReadonly} - type="target_date" - /> - )} - - {/* estimates */} - {displayProperties && displayProperties?.estimate && ( - - )} - - {/* extra render properties */} - {/* sub-issues */} - {displayProperties && displayProperties?.sub_issue_count && !!issue?.sub_issues_count && ( - -
- -
{issue.sub_issues_count}
-
-
- )} - - {/* attachments */} - {displayProperties && displayProperties?.attachment_count && !!issue?.attachment_count && ( - -
- -
{issue.attachment_count}
-
-
- )} - - {/* link */} - {displayProperties && displayProperties?.link && !!issue?.link_count && ( - -
- -
{issue.link_count}
-
-
- )} -
- ); -}); diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index 9237d8a1f78..540d4d7f685 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -4,13 +4,12 @@ import { useForm } from "react-hook-form"; import { PlusIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; // hooks +import { useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; // constants -import { IIssue, IProject } from "types"; +import { TIssue, IProject } from "@plane/types"; // types import { createIssuePayload } from "helpers/issue.helper"; @@ -44,31 +43,29 @@ const Inputs: FC = (props) => { }; interface IListQuickAddIssueForm { - prePopulatedData?: Partial; + prePopulatedData?: Partial; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; } -const defaultValues: Partial = { +const defaultValues: Partial = { name: "", }; export const ListQuickAddIssueForm: FC = observer((props) => { const { prePopulatedData, quickAddCallback, viewId } = props; - + // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - - const { workspace: workspaceStore, project: projectStore } = useMobxStore(); + const { workspaceSlug, projectId } = router.query; + // hooks + const { getProjectById } = useProject(); - const workspaceDetail = (workspaceSlug && workspaceStore.getWorkspaceBySlug(workspaceSlug)) || null; - const projectDetail: IProject | null = - (workspaceSlug && projectId && projectStore.getProjectById(workspaceSlug, projectId)) || null; + const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined; const ref = useRef(null); @@ -85,24 +82,25 @@ export const ListQuickAddIssueForm: FC = observer((props setFocus, register, formState: { errors, isSubmitting }, - } = useForm({ defaultValues }); + } = useForm({ defaultValues }); useEffect(() => { if (!isOpen) reset({ ...defaultValues }); }, [isOpen, reset]); - const onSubmitHandler = async (formData: IIssue) => { - if (isSubmitting || !workspaceDetail || !projectDetail) return; + const onSubmitHandler = async (formData: TIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail, projectDetail, { + const payload = createIssuePayload(projectId.toString(), { ...(prePopulatedData ?? {}), ...formData, }); try { - quickAddCallback && (await quickAddCallback(workspaceSlug, projectId, { ...payload }, viewId)); + quickAddCallback && + (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId)); setToastAlert({ type: "success", title: "Success!", @@ -130,7 +128,7 @@ export const ListQuickAddIssueForm: FC = observer((props onSubmit={handleSubmit(onSubmitHandler)} className="flex w-full items-center gap-x-3 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-3" > - +
{`Press 'Enter' to add another issue`}
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 cf4c74063fb..2ba4ea7f5f7 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 @@ -1,46 +1,43 @@ -import { FC } from "react"; +import { FC, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { ArchivedIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // constants import { BaseListRoot } from "../base-list-root"; -import { IProjectStore } from "store/project"; import { EIssueActions } from "../../types"; -import { EProjectStore } from "store/command-palette.store"; +import { EIssuesStoreType } from "constants/issue"; export const ArchivedIssueListLayout: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - const { projectArchivedIssues: archivedIssueStore, projectArchivedIssuesFilter: archivedIssueFiltersStore } = - useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); + const issueActions = useMemo( + () => ({ + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; - const issueActions = { - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await archivedIssueStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; + await issues.removeIssue(workspaceSlug, projectId, issue.id); + }, + }), + [issues, workspaceSlug, projectId] + ); - const getProjects = (projectStore: IProjectStore) => { - if (!workspaceSlug) return null; - return projectStore?.projects[workspaceSlug.toString()] || null; - }; + const canEditPropertiesBasedOnProject = () => false; return ( ); }); 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 de579473bf9..89da8dd54b5 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -1,65 +1,58 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues } from "hooks/store"; // components import { CycleIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // constants import { BaseListRoot } from "../base-list-root"; -import { IProjectStore } from "store/project"; import { EIssueActions } from "../../types"; -import { EProjectStore } from "store/command-palette.store"; +import { EIssuesStoreType } from "constants/issue"; export interface ICycleListLayout {} export const CycleListLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string }; + const { workspaceSlug, projectId, cycleId } = router.query; // store - const { - cycleIssues: cycleIssueStore, - cycleIssuesFilter: cycleIssueFilterStore, - cycle: { fetchCycleWithId }, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue, cycleId); - fetchCycleWithId(workspaceSlug, issue.project, cycleId); - }, - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, cycleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, cycleId); - fetchCycleWithId(workspaceSlug, issue.project, cycleId); - }, - [EIssueActions.REMOVE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !cycleId || !issue.bridge_id) return; + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.removeIssueFromCycle(workspaceSlug, issue.project, cycleId, issue.id, issue.bridge_id); - fetchCycleWithId(workspaceSlug, issue.project, cycleId); - }, - }; - const getProjects = (projectStore: IProjectStore) => { - if (!workspaceSlug) return null; - return projectStore?.projects[workspaceSlug] || null; - }; + await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); + }, + }), + [issues, workspaceSlug, cycleId] + ); return ( cycleIssueStore.addIssueToCycle(workspaceSlug, cycleId, issues)} + 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); + }} /> ); }); diff --git a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx index 6049ec3bc4d..e11971874a9 100644 --- a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx @@ -1,17 +1,16 @@ -import { FC } from "react"; +import { FC, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { IProjectStore } from "store/project"; -import { EProjectStore } from "store/command-palette.store"; +import { EIssuesStoreType } from "constants/issue"; export const DraftIssueListLayout: FC = observer(() => { const router = useRouter(); @@ -20,31 +19,31 @@ export const DraftIssueListLayout: FC = observer(() => { if (!workspaceSlug || !projectId) return null; // store - const { projectDraftIssuesFilter: projectIssuesFilterStore, projectDraftIssues: projectIssuesStore } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await projectIssuesStore.updateIssue(workspaceSlug, projectId, issue.id, issue); - }, - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await projectIssuesStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; + const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.updateIssue(workspaceSlug, projectId, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.removeIssue(workspaceSlug, projectId, issue.id); + }, + }), + [issues, workspaceSlug, projectId] + ); return ( ); }); 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 5d076a0cca4..520a2da32bc 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -1,66 +1,58 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { ModuleIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { IProjectStore } from "store/project"; -import { EProjectStore } from "store/command-palette.store"; +import { EIssuesStoreType } from "constants/issue"; export interface IModuleListLayout {} export const ModuleListLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug, moduleId } = router.query as { workspaceSlug: string; moduleId: string }; + const { workspaceSlug, projectId, moduleId } = router.query; - const { - moduleIssues: moduleIssueStore, - moduleIssuesFilter: moduleIssueFilterStore, - module: { fetchModuleDetails }, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue, moduleId); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; + await issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, moduleId.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, moduleId); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - [EIssueActions.REMOVE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !moduleId || !issue.bridge_id) return; + await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.removeIssueFromModule(workspaceSlug, issue.project, moduleId, issue.id, issue.bridge_id); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - }; - - const getProjects = (projectStore: IProjectStore) => { - if (!workspaceSlug) return null; - return projectStore?.projects[workspaceSlug] || null; - }; + await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); + }, + }), + [issues, workspaceSlug, moduleId] + ); return ( moduleIssueStore.addIssueToModule(workspaceSlug, moduleId, issues)} + viewId={moduleId?.toString()} + storeType={EIssuesStoreType.MODULE} + addIssuesToView={(issueIds: string[]) => { + if (!workspaceSlug || !projectId || !moduleId) throw new Error(); + return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); + }} /> ); }); 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 eedf7ae8165..91e80382a65 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 @@ -1,64 +1,58 @@ -import { FC } from "react"; +import { FC, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues, useUser } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { IProjectStore } from "store/project"; -import { EProjectStore } from "store/command-palette.store"; -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { EIssuesStoreType } from "constants/issue"; export const ProfileIssuesListLayout: FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, userId } = router.query as { workspaceSlug: string; userId: string }; + // store hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROFILE); - // store const { - workspaceProfileIssuesFilter: profileIssueFiltersStore, - workspaceProfileIssues: profileIssuesStore, - workspaceMember: { currentWorkspaceUserProjectsRole }, - } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !userId) return; - - await profileIssuesStore.updateIssue(workspaceSlug, userId, issue.id, issue); - }, - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !userId) return; - - await profileIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id, userId); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue, userId); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, + }), + [issues, workspaceSlug, userId] + ); const canEditPropertiesBasedOnProject = (projectId: string) => { - const currentProjectRole = currentWorkspaceUserProjectsRole && currentWorkspaceUserProjectsRole[projectId]; + const currentProjectRole = currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId]; - console.log( - projectId, - currentWorkspaceUserProjectsRole, - !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER - ); - return !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + return !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; }; return ( ); 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 0d23f765696..f0479b71ffa 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -1,17 +1,16 @@ -import { FC } from "react"; +import { FC, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { ProjectIssueQuickActions } from "components/issues"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { IProjectStore } from "store/project"; -import { EProjectStore } from "store/command-palette.store"; +import { EIssuesStoreType } from "constants/issue"; export const ListLayout: FC = observer(() => { const router = useRouter(); @@ -20,31 +19,32 @@ export const ListLayout: FC = observer(() => { if (!workspaceSlug || !projectId) return null; // store - const { projectIssuesFilter: projectIssuesFilterStore, projectIssues: projectIssuesStore } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await projectIssuesStore.updateIssue(workspaceSlug, projectId, issue.id, issue); - }, - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await projectIssuesStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; + const { issuesFilter, issues } = useIssues(EIssuesStoreType.PROJECT); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.updateIssue(workspaceSlug, projectId, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.removeIssue(workspaceSlug, projectId, issue.id); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [issues] + ); return ( ); }); 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 52fa1a759db..dd384ba93eb 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 @@ -1,53 +1,43 @@ import React from "react"; import { observer } from "mobx-react-lite"; - +import { useRouter } from "next/router"; // store -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; +import { useIssues } from "hooks/store"; // constants -import { useRouter } from "next/router"; +import { EIssuesStoreType } from "constants/issue"; +// types import { EIssueActions } from "../../types"; -import { IProjectStore } from "store/project"; -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; // components import { BaseListRoot } from "../base-list-root"; import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; -import { EProjectStore } from "store/command-palette.store"; -export interface IViewListLayout {} +export interface IViewListLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + }; +} -export const ProjectViewListLayout: React.FC = observer(() => { - const { viewIssues: projectViewIssueStore, viewIssuesFilter: projectViewIssueFilterStore }: RootStore = - useMobxStore(); +export const ProjectViewListLayout: React.FC = observer((props) => { + const { issueActions } = props; + // store + const { issuesFilter, issues } = useIssues(EIssuesStoreType.PROJECT_VIEW); const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId, viewId } = router.query; if (!workspaceSlug || !projectId) return null; - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await projectViewIssueStore.updateIssue(workspaceSlug, projectId, issue.id, issue); - }, - [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - await projectViewIssueStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; - return ( ); }); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx new file mode 100644 index 00000000000..4057c7b932d --- /dev/null +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -0,0 +1,222 @@ +import { observer } from "mobx-react-lite"; +import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; +// hooks +import { useEstimate, useLabel } from "hooks/store"; +// components +import { IssuePropertyLabels } from "../properties/labels"; +import { Tooltip } from "@plane/ui"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { + DateDropdown, + EstimateDropdown, + PriorityDropdown, + ProjectMemberDropdown, + StateDropdown, +} from "components/dropdowns"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; + +export interface IIssueProperties { + issue: TIssue; + handleIssues: (issue: TIssue) => void; + displayProperties: IIssueDisplayProperties | undefined; + isReadOnly: boolean; + className: string; +} + +export const IssueProperties: React.FC = observer((props) => { + const { issue, handleIssues, displayProperties, isReadOnly, className } = props; + const { labelMap } = useLabel(); + const { areEstimatesEnabledForCurrentProject } = useEstimate(); + + const handleState = (stateId: string) => { + handleIssues({ ...issue, state_id: stateId }); + }; + + const handlePriority = (value: TIssuePriorities) => { + handleIssues({ ...issue, priority: value }); + }; + + const handleLabel = (ids: string[]) => { + handleIssues({ ...issue, label_ids: ids }); + }; + + const handleAssignee = (ids: string[]) => { + handleIssues({ ...issue, assignee_ids: ids }); + }; + + const handleStartDate = (date: Date | null) => { + handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }); + }; + + const handleTargetDate = (date: Date | null) => { + handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }); + }; + + const handleEstimate = (value: number | null) => { + handleIssues({ ...issue, estimate_point: value }); + }; + + if (!displayProperties) return null; + + const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; + + const minDate = issue.start_date ? new Date(issue.start_date) : null; + minDate?.setDate(minDate.getDate()); + + const maxDate = issue.target_date ? new Date(issue.target_date) : null; + maxDate?.setDate(maxDate.getDate()); + + return ( +
+ {/* basic properties */} + {/* state */} + +
+ +
+
+ + {/* priority */} + +
+ +
+
+ + {/* label */} + + + + + {/* start date */} + +
+ } + maxDate={maxDate ?? undefined} + placeholder="Start date" + buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} + disabled={isReadOnly} + showTooltip + /> +
+
+ + {/* target/due date */} + +
+ } + minDate={minDate ?? undefined} + placeholder="Due date" + buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + disabled={isReadOnly} + showTooltip + /> +
+
+ + {/* assignee */} + +
+ 0 ? "transparent-without-text" : "border-without-text"} + buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} + /> +
+
+ + {/* estimates */} + {areEstimatesEnabledForCurrentProject && ( + +
+ +
+
+ )} + + {/* extra render properties */} + {/* sub-issues */} + + +
+ +
{issue.sub_issues_count}
+
+
+
+ + {/* attachments */} + + +
+ +
{issue.attachment_count}
+
+
+
+ + {/* link */} + + +
+ +
{issue.link_count}
+
+
+
+
+ ); +}); diff --git a/web/components/issues/issue-layouts/properties/assignee.tsx b/web/components/issues/issue-layouts/properties/assignee.tsx deleted file mode 100644 index 01dec9b8379..00000000000 --- a/web/components/issues/issue-layouts/properties/assignee.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { Fragment, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -import { usePopper } from "react-popper"; -import { Combobox } from "@headlessui/react"; -import { Check, ChevronDown, CircleUser, Search } from "lucide-react"; -// ui -import { Avatar, AvatarGroup, Tooltip } from "@plane/ui"; -// types -import { Placement } from "@popperjs/core"; -import { IProjectMember } from "types"; - -export interface IIssuePropertyAssignee { - projectId: string | null; - value: string[] | string; - defaultOptions?: any; - onChange: (data: string[]) => void; - disabled?: boolean; - hideDropdownArrow?: boolean; - className?: string; - buttonClassName?: string; - optionsClassName?: string; - placement?: Placement; - multiple?: true; - noLabelBorder?: boolean; -} - -export const IssuePropertyAssignee: React.FC = observer((props) => { - const { - projectId, - value, - defaultOptions = [], - onChange, - disabled = false, - hideDropdownArrow = false, - className, - buttonClassName, - optionsClassName, - placement, - multiple = false, - } = props; - // store - const { - workspace: workspaceStore, - projectMember: { members: _members, fetchProjectMembers }, - } = useMobxStore(); - const workspaceSlug = workspaceStore?.workspaceSlug; - // states - const [query, setQuery] = useState(""); - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const getProjectMembers = () => { - setIsLoading(true); - if (workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId).then(() => setIsLoading(false)); - }; - - const updatedDefaultOptions: IProjectMember[] = - defaultOptions.map((member: any) => ({ member: { ...member } })) ?? []; - const projectMembers = projectId && _members[projectId] ? _members[projectId] : updatedDefaultOptions; - - const options = projectMembers?.map((member) => ({ - value: member.member.id, - query: member.member.display_name, - content: ( -
- - {member.member.display_name} -
- ), - })); - - const filteredOptions = - query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); - - const getTooltipContent = (): string => { - if (!value || value.length === 0) return "No Assignee"; - - // if multiple assignees - if (Array.isArray(value)) { - const assignees = projectMembers?.filter((m) => value.includes(m.member.id)); - - if (!assignees || assignees.length === 0) return "No Assignee"; - - // if only one assignee in list - if (assignees.length === 1) { - return "1 assignee"; - } else return `${assignees.length} assignees`; - } - - // if single assignee - const assignee = projectMembers?.find((m) => m.member.id === value)?.member; - - if (!assignee) return "No Assignee"; - - // if assignee not null & not list - return "1 assignee"; - }; - - const label = ( - -
- {value && value.length > 0 && Array.isArray(value) ? ( - - {value.map((assigneeId) => { - const member = projectMembers?.find((m) => m.member.id === assigneeId)?.member; - if (!member) return null; - return ; - })} - - ) : ( - - - - )} -
-
- ); - - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - - const comboboxProps: any = { value, onChange, disabled }; - if (multiple) comboboxProps.multiple = true; - - return ( - - - - - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {isLoading ? ( -

Loading...

- ) : filteredOptions && filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ - active && !selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - onClick={(e) => e.stopPropagation()} - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( - -

No matching results

-
- )} -
-
-
-
- ); -}); diff --git a/web/components/issues/issue-layouts/properties/date.tsx b/web/components/issues/issue-layouts/properties/date.tsx deleted file mode 100644 index d0bb297114a..00000000000 --- a/web/components/issues/issue-layouts/properties/date.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from "react"; -// headless ui -import { Popover } from "@headlessui/react"; -// lucide icons -import { CalendarCheck2, CalendarClock, X } from "lucide-react"; -// react date picker -import DatePicker from "react-datepicker"; -// mobx -import { observer } from "mobx-react-lite"; -// components -import { Tooltip } from "@plane/ui"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; -// helpers -import { renderDateFormat, renderFormattedDate } from "helpers/date-time.helper"; - -export interface IIssuePropertyDate { - value: string | null; - onChange: (date: string | null) => void; - disabled?: boolean; - type: "start_date" | "target_date"; -} - -const DATE_OPTIONS = { - start_date: { - key: "start_date", - placeholder: "Start date", - icon: CalendarClock, - }, - target_date: { - key: "target_date", - placeholder: "Target date", - icon: CalendarCheck2, - }, -}; - -export const IssuePropertyDate: React.FC = observer((props) => { - const { value, onChange, disabled, type } = props; - - const dropdownBtn = React.useRef(null); - const dropdownOptions = React.useRef(null); - - const [isOpen, setIsOpen] = React.useState(false); - - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - - const dateOptionDetails = DATE_OPTIONS[type]; - - return ( - - {({ open }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); - - return ( - <> - e.stopPropagation()} - disabled={disabled} - > - -
-
- - {value && ( - <> -
{value}
-
{ - if (onChange) onChange(null); - }} - > - -
- - )} -
-
-
-
- -
- - {({ close }) => ( - { - e?.stopPropagation(); - if (onChange && val) { - onChange(renderDateFormat(val)); - close(); - } - }} - dateFormat="dd-MM-yyyy" - calendarClassName="h-full" - inline - /> - )} - -
- - ); - }} -
- ); -}); diff --git a/web/components/issues/issue-layouts/properties/estimates.tsx b/web/components/issues/issue-layouts/properties/estimates.tsx deleted file mode 100644 index e3f61795894..00000000000 --- a/web/components/issues/issue-layouts/properties/estimates.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { Fragment, useState } from "react"; -import { usePopper } from "react-popper"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { Check, ChevronDown, Search, Triangle } from "lucide-react"; -// ui -import { Tooltip } from "@plane/ui"; -// types -import { Placement } from "@popperjs/core"; -import { useMobxStore } from "lib/mobx/store-provider"; - -export interface IIssuePropertyEstimates { - view?: "profile" | "workspace" | "project"; - projectId: string | null; - value: number | null; - onChange: (value: number | null) => void; - disabled?: boolean; - hideDropdownArrow?: boolean; - className?: string; - buttonClassName?: string; - optionsClassName?: string; - placement?: Placement; -} - -export const IssuePropertyEstimates: React.FC = observer((props) => { - const { - projectId, - value, - onChange, - disabled, - hideDropdownArrow = false, - className = "", - buttonClassName = "", - optionsClassName = "", - placement, - } = props; - - const [query, setQuery] = useState(""); - - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - - const { - project: { project_details }, - projectEstimates: { projectEstimates }, - } = useMobxStore(); - - const projectDetails = projectId ? project_details[projectId] : null; - const isEstimateEnabled = projectDetails?.estimate !== null; - const estimates = projectEstimates; - const estimatePoints = - projectDetails && isEstimateEnabled ? estimates?.find((e) => e.id === projectDetails.estimate)?.points : null; - - const options: { value: number | null; query: string; content: any }[] | undefined = (estimatePoints ?? []).map( - (estimate) => ({ - value: estimate.key, - query: estimate.value, - content: ( -
- - {estimate.value} -
- ), - }) - ); - options?.unshift({ - value: null, - query: "none", - content: ( -
- - None -
- ), - }); - - const filteredOptions = - query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); - - const selectedEstimate = estimatePoints?.find((e) => e.key === value); - const label = ( - -
- - {selectedEstimate?.value ?? "None"} -
-
- ); - - if (!isEstimateEnabled) return null; - - return ( - onChange(val as number | null)} - disabled={disabled} - > - - - - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - onClick={(e) => e.stopPropagation()} - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
-
-
-
- ); -}); diff --git a/web/components/issues/issue-layouts/properties/index.ts b/web/components/issues/issue-layouts/properties/index.ts new file mode 100644 index 00000000000..95f3ce21fd5 --- /dev/null +++ b/web/components/issues/issue-layouts/properties/index.ts @@ -0,0 +1 @@ +export * from "./labels"; diff --git a/web/components/issues/issue-layouts/properties/index.tsx b/web/components/issues/issue-layouts/properties/index.tsx deleted file mode 100644 index 3e2e2acd67d..00000000000 --- a/web/components/issues/issue-layouts/properties/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./assignee"; -export * from "./date"; -export * from "./estimates"; -export * from "./labels"; -export * from "./priority"; -export * from "./state"; diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index d0045c3d447..0f121bed797 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -1,16 +1,15 @@ import { Fragment, useState } from "react"; import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -// hooks import { usePopper } from "react-popper"; +import { Check, ChevronDown, Search, Tags } from "lucide-react"; +// hooks +import { useApplication, useLabel } from "hooks/store"; // components import { Combobox } from "@headlessui/react"; import { Tooltip } from "@plane/ui"; -import { Check, ChevronDown, Search, Tags } from "lucide-react"; // types import { Placement } from "@popperjs/core"; -import { RootStore } from "store/root"; -import { IIssueLabel } from "types"; +import { IIssueLabel } from "@plane/types"; export interface IIssuePropertyLabels { projectId: string | null; @@ -44,42 +43,56 @@ export const IssuePropertyLabels: React.FC = observer((pro noLabelBorder = false, placeholderText, } = props; - - const { - workspace: workspaceStore, - projectLabel: { fetchProjectLabels, labels }, - }: RootStore = useMobxStore(); - const workspaceSlug = workspaceStore?.workspaceSlug; - + // states const [query, setQuery] = useState(""); - + // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); const [isLoading, setIsLoading] = useState(false); + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { fetchProjectLabels, getProjectLabels } = useLabel(); - const fetchLabels = () => { - setIsLoading(true); - if (workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + const storeLabels = getProjectLabels(projectId); + + const openDropDown = () => { + if (!storeLabels && workspaceSlug && projectId) { + setIsLoading(true); + fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); + } }; + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + if (!value) return null; let projectLabels: IIssueLabel[] = defaultOptions; - const storeLabels = projectId && labels ? labels[projectId] : []; if (storeLabels && storeLabels.length > 0) projectLabels = storeLabels; const options = projectLabels.map((label) => ({ - value: label.id, - query: label.name, + value: label?.id, + query: label?.name, content: (
-
{label.name}
+
{label?.name}
), })); @@ -87,29 +100,17 @@ export const IssuePropertyLabels: React.FC = observer((pro const filteredOptions = query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - const label = (
{value.length > 0 ? ( value.length <= maxRender ? ( <> {projectLabels - ?.filter((l) => value.includes(l.id)) + ?.filter((l) => value.includes(l?.id)) .map((label) => ( - +
= observer((pro backgroundColor: label?.color ?? "#000000", }} /> -
{label.name}
+
{label?.name}
@@ -137,8 +138,8 @@ export const IssuePropertyLabels: React.FC = observer((pro position="top" tooltipHeading="Labels" tooltipContent={projectLabels - ?.filter((l) => value.includes(l.id)) - .map((l) => l.name) + ?.filter((l) => value.includes(l?.id)) + .map((l) => l?.name) .join(", ")} >
@@ -183,10 +184,7 @@ export const IssuePropertyLabels: React.FC = observer((pro ? "cursor-pointer" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} - onClick={(e) => { - e.stopPropagation(); - !storeLabels && fetchLabels(); - }} + onClick={openDropDown} > {label} {!hideDropdownArrow && !disabled &&
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -90,9 +91,7 @@ export const AllIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setCreateUpdateIssueModal(true); }} > @@ -102,9 +101,7 @@ export const AllIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 133cce1f945..2640937786f 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -12,7 +12,7 @@ import { copyUrlToClipboard } from "helpers/string.helper"; import { IQuickActionProps } from "../list/list-view-types"; export const ArchivedIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, customActionButton } = props; + const { issue, handleDelete, customActionButton, portalElement } = props; const router = useRouter(); const { workspaceSlug } = router.query; @@ -43,13 +43,12 @@ export const ArchivedIssueQuickActions: React.FC = (props) => e.stopPropagation()} > { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleCopyIssueLink(); }} > @@ -59,9 +58,7 @@ export const ArchivedIssueQuickActions: React.FC = (props) =>
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index af018a652bd..d215356396f 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -9,21 +9,21 @@ import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; -import { EProjectStore } from "store/command-palette.store"; +// constants +import { EIssuesStoreType } from "constants/issue"; export const CycleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; - - const router = useRouter(); - const { workspaceSlug, cycleId } = router.query; - + const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); - + // router + const router = useRouter(); + const { workspaceSlug, cycleId } = router.query; + // toast alert const { setToastAlert } = useToast(); const handleCopyIssueLink = () => { @@ -36,6 +36,12 @@ export const CycleIssueQuickActions: React.FC = (props) => { ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => { /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EProjectStore.CYCLE} + storeType={EIssuesStoreType.CYCLE} /> e.stopPropagation()} > { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleCopyIssueLink(); }} > @@ -77,9 +80,7 @@ export const CycleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setIssueToEdit({ ...issue, cycle: cycleId?.toString() ?? null, @@ -93,9 +94,7 @@ export const CycleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleRemoveFromView && handleRemoveFromView(); }} > @@ -105,9 +104,7 @@ export const CycleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setCreateUpdateIssueModal(true); }} > @@ -117,9 +114,7 @@ export const CycleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 0ad1f610b17..0decd5555b3 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -9,21 +9,21 @@ import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; -import { EProjectStore } from "store/command-palette.store"; +// constants +import { EIssuesStoreType } from "constants/issue"; export const ModuleIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton } = props; - - const router = useRouter(); - const { workspaceSlug, moduleId } = router.query; - + const { issue, handleDelete, handleUpdate, handleRemoveFromView, customActionButton, portalElement } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); - + // router + const router = useRouter(); + const { workspaceSlug, moduleId } = router.query; + // toast alert const { setToastAlert } = useToast(); const handleCopyIssueLink = () => { @@ -36,6 +36,12 @@ export const ModuleIssueQuickActions: React.FC = (props) => { ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => { /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EProjectStore.MODULE} + storeType={EIssuesStoreType.MODULE} /> - e.stopPropagation()} > { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleCopyIssueLink(); }} > @@ -78,9 +80,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setIssueToEdit({ ...issue, module: moduleId?.toString() ?? null }); setCreateUpdateIssueModal(true); }} @@ -91,9 +91,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleRemoveFromView && handleRemoveFromView(); }} > @@ -103,9 +101,7 @@ export const ModuleIssueQuickActions: React.FC = (props) => {
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setCreateUpdateIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 12438b2a373..044030184c8 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -2,37 +2,35 @@ import { useState } from "react"; import { useRouter } from "next/router"; import { CustomMenu } from "@plane/ui"; import { Copy, Link, Pencil, Trash2 } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // hooks +import { useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { IQuickActionProps } from "../list/list-view-types"; -import { EProjectStore } from "store/command-palette.store"; // constant -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserProjectRoles } from "constants/project"; +import { EIssuesStoreType } from "constants/issue"; export const ProjectIssueQuickActions: React.FC = (props) => { - const { issue, handleDelete, handleUpdate, customActionButton } = props; - + const { issue, handleDelete, handleUpdate, customActionButton, portalElement } = props; + // router const router = useRouter(); const { workspaceSlug } = router.query; - // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); - const [issueToEdit, setIssueToEdit] = useState(null); + const [issueToEdit, setIssueToEdit] = useState(undefined); const [deleteIssueModal, setDeleteIssueModal] = useState(false); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); - const { user: userStore } = useMobxStore(); - - const { currentProjectRole } = userStore; - - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const { setToastAlert } = useToast(); @@ -46,6 +44,12 @@ export const ProjectIssueQuickActions: React.FC = (props) => ); }; + const duplicateIssuePayload = { + ...issue, + name: `${issue.name} (copy)`, + }; + delete duplicateIssuePayload.id; + return ( <> = (props) => /> { + onClose={() => { setCreateUpdateIssueModal(false); - setIssueToEdit(null); + setIssueToEdit(undefined); }} - // pre-populate date only if not editing - prePopulateData={!issueToEdit && createUpdateIssueModal ? { ...issue, name: `${issue.name} (copy)` } : {}} - data={issueToEdit} + data={issueToEdit ?? duplicateIssuePayload} onSubmit={async (data) => { - if (issueToEdit && handleUpdate) handleUpdate({ ...issueToEdit, ...data }); + if (issueToEdit && handleUpdate) await handleUpdate({ ...issueToEdit, ...data }); }} - currentStore={EProjectStore.PROJECT} + storeType={EIssuesStoreType.PROJECT} /> e.stopPropagation()} > { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleCopyIssueLink(); }} > @@ -89,9 +90,7 @@ export const ProjectIssueQuickActions: React.FC = (props) => {isEditingAllowed && ( <> { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setIssueToEdit(issue); setCreateUpdateIssueModal(true); }} @@ -102,9 +101,7 @@ export const ProjectIssueQuickActions: React.FC = (props) =>
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setCreateUpdateIssueModal(true); }} > @@ -114,9 +111,7 @@ export const ProjectIssueQuickActions: React.FC = (props) =>
{ - e.preventDefault(); - e.stopPropagation(); + onClick={() => { setDeleteIssueModal(true); }} > diff --git a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index 7ab5621b8e4..2ba02367415 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -1,91 +1,144 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import isEmpty from "lodash/isEmpty"; +import { useTheme } from "next-themes"; +// hooks +import { useApplication, useGlobalView, useIssues, useProject, useUser } from "hooks/store"; +import { useWorkspaceIssueProperties } from "hooks/use-workspace-issue-properties"; // components -import { GlobalViewsAppliedFiltersRoot } from "components/issues"; +import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "components/issues"; import { SpreadsheetView } from "components/issues/issue-layouts"; import { AllIssueQuickActions } from "components/issues/issue-layouts/quick-action-dropdowns"; +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; // ui import { Spinner } from "@plane/ui"; // types -import { IIssue, IIssueDisplayFilterOptions, TStaticViewTypes } from "types"; -import { IIssueUnGroupedStructure } from "store/issue"; +import { TIssue, IIssueDisplayFilterOptions } from "@plane/types"; import { EIssueActions } from "../types"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { ALL_ISSUES_EMPTY_STATE_DETAILS, EUserWorkspaceRoles } from "constants/workspace"; -import { EFilterType, TUnGroupedIssues } from "store/issues/types"; -import { EUserWorkspaceRoles } from "constants/workspace"; - -type Props = { - type?: TStaticViewTypes | null; -}; - -export const AllIssueLayoutRoot: React.FC = observer((props) => { - const { type = null } = props; - +export const AllIssueLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); - const { workspaceSlug, globalViewId } = router.query as { workspaceSlug: string; globalViewId: string }; - - const currentIssueView = type ?? globalViewId; + const { workspaceSlug, globalViewId } = router.query; + // theme + const { resolvedTheme } = useTheme(); + //swr hook for fetching issue properties + useWorkspaceIssueProperties(workspaceSlug); + // store + const { commandPalette: commandPaletteStore } = useApplication(); + const { + issuesFilter: { filters, fetchFilters, updateFilters }, + issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { dataViewId, issueIds } = groupedIssueIds; const { - workspaceMember: { workspaceMembers }, - workspace: { workspaceLabels }, - globalViews: { fetchAllGlobalViews }, - workspaceGlobalIssues: { loader, getIssues, getIssuesIds, fetchIssues, updateIssue, removeIssue }, - workspaceGlobalIssuesFilter: { currentView, issueFilters, fetchFilters, updateFilters, setCurrentView }, - workspaceMember: { currentWorkspaceUserProjectsRole }, - } = useMobxStore(); + membership: { currentWorkspaceAllProjectsRole, currentWorkspaceRole }, + currentUser, + } = useUser(); + const { fetchAllGlobalViews } = useGlobalView(); + const { workspaceProjectIds } = useProject(); + + const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(groupedIssueIds.dataViewId); + const currentView = isDefaultView ? groupedIssueIds.dataViewId : "custom-view"; + const currentViewDetails = ALL_ISSUES_EMPTY_STATE_DETAILS[currentView as keyof typeof ALL_ISSUES_EMPTY_STATE_DETAILS]; + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("all-issues", currentView, isLightMode); + + // filter init from the query params + + const routerFilterParams = () => { + if ( + workspaceSlug && + globalViewId && + ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString()) + ) { + const routerQueryParams = { ...router.query }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ["workspaceSlug"]: _workspaceSlug, ["globalViewId"]: _globalViewId, ...filters } = routerQueryParams; + + let issueFilters: any = {}; + Object.keys(filters).forEach((key) => { + const filterKey: any = key; + const filterValue = filters[key]?.toString() || undefined; + if ( + ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet.filters.includes(filterKey) && + filterKey && + filterValue + ) + issueFilters = { ...issueFilters, [filterKey]: filterValue.split(",") }; + }); + + if (!isEmpty(filters)) + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + issueFilters, + globalViewId.toString() + ); + } + }; useSWR(workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS${workspaceSlug}` : null, async () => { if (workspaceSlug) { - await fetchAllGlobalViews(workspaceSlug); + await fetchAllGlobalViews(workspaceSlug.toString()); } }); useSWR( - workspaceSlug && currentIssueView ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${currentIssueView}` : null, + workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null, async () => { - if (workspaceSlug && currentIssueView) { - setCurrentView(currentIssueView); - await fetchAllGlobalViews(workspaceSlug); - await fetchFilters(workspaceSlug, currentIssueView); - await fetchIssues(workspaceSlug, currentIssueView, getIssues ? "mutation" : "init-loader"); + if (workspaceSlug && globalViewId) { + await fetchAllGlobalViews(workspaceSlug.toString()); + await fetchFilters(workspaceSlug.toString(), globalViewId.toString()); + await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader"); + routerFilterParams(); } } ); - const canEditProperties = (projectId: string | undefined) => { - if (!projectId) return false; + const canEditProperties = useCallback( + (projectId: string | undefined) => { + if (!projectId) return false; - const currentProjectRole = currentWorkspaceUserProjectsRole && currentWorkspaceUserProjectsRole[projectId]; + const currentProjectRole = currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId]; - return !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - }; + return !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + }, + [currentWorkspaceAllProjectsRole] + ); - const issuesResponse = getIssues; - const issueIds = (getIssuesIds ?? []) as TUnGroupedIssues; - const issues = issueIds?.filter((id) => id && issuesResponse?.[id]).map((id) => issuesResponse?.[id]); + const issueFilters = globalViewId ? filters?.[globalViewId.toString()] : undefined; - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - const projectId = issue.project; - if (!workspaceSlug || !projectId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + const projectId = issue.project_id; + if (!workspaceSlug || !projectId || !globalViewId) return; - await updateIssue(workspaceSlug, projectId, issue.id, issue, currentIssueView); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - const projectId = issue.project; - if (!workspaceSlug || !projectId) return; + await updateIssue(workspaceSlug.toString(), projectId, issue.id, issue, globalViewId.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + const projectId = issue.project_id; + if (!workspaceSlug || !projectId || !globalViewId) return; - await removeIssue(workspaceSlug, projectId, issue.id, currentIssueView); - }, - }; + await removeIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString()); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [updateIssue, removeIssue, workspaceSlug] + ); const handleIssues = useCallback( - async (issue: IIssue, action: EIssueActions) => { + async (issue: TIssue, action: EIssueActions) => { if (action === EIssueActions.UPDATE) await issueActions[action]!(issue); if (action === EIssueActions.DELETE) await issueActions[action]!(issue); }, @@ -97,47 +150,80 @@ export const AllIssueLayoutRoot: React.FC = observer((props) => { (updatedDisplayFilter: Partial) => { if (!workspaceSlug) return; - updateFilters(workspaceSlug, EFilterType.DISPLAY_FILTERS, { ...updatedDisplayFilter }); + updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_FILTERS, { ...updatedDisplayFilter }); }, [updateFilters, workspaceSlug] ); + const renderQuickActions = useCallback( + (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + handleIssues({ ...issue }, EIssueActions.UPDATE)} + handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)} + portalElement={portalElement} + /> + ), + [handleIssues] + ); + + const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + return (
- {currentView != currentIssueView && (loader === "init-loader" || !getIssues) ? ( + {!globalViewId || globalViewId !== dataViewId || loader === "init-loader" || !issueIds ? (
) : ( <> - - - {Object.keys(getIssues ?? {}).length == 0 ? ( - <>{/* */} + + + {(issueIds ?? {}).length == 0 ? ( + 0 ? currentViewDetails.title : "No project"} + description={ + (workspaceProjectIds ?? []).length > 0 + ? currentViewDetails.description + : "To create issues or manage your work, you need to create a project or be a part of one." + } + size="sm" + primaryButton={ + (workspaceProjectIds ?? []).length > 0 + ? currentView !== "custom-view" && currentView !== "subscribed" + ? { + text: "Create new issue", + onClick: () => commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT), + } + : undefined + : { + text: "Start your first project", + onClick: () => commandPaletteStore.toggleCreateProjectModal(true), + } + } + disabled={!isEditingAllowed} + /> ) : (
( - handleIssues({ ...issue }, EIssueActions.UPDATE)} - handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)} - /> - )} - members={workspaceMembers?.map((m) => m.member)} - labels={workspaceLabels || undefined} + issueIds={issueIds} + quickActions={renderQuickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} - viewId={currentIssueView} + viewId={globalViewId} />
)} )} + + {/* peek overview */} +
); }); diff --git a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index 53171f4e563..430383a9f24 100644 --- a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -3,32 +3,64 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components -import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot } from "components/issues"; +import { + ArchivedIssueListLayout, + ArchivedIssueAppliedFiltersRoot, + ProjectArchivedEmptyState, + IssuePeekOverview, +} from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; +// ui +import { Spinner } from "@plane/ui"; export const ArchivedIssueLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); - const { - projectArchivedIssues: { getIssues, fetchIssues }, - projectArchivedIssuesFilter: { fetchFilters }, - } = useMobxStore(); - - useSWR(workspaceSlug && projectId ? `ARCHIVED_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => { - if (workspaceSlug && projectId) { - await fetchFilters(workspaceSlug.toString(), projectId.toString()); - await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader"); + useSWR( + workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, + async () => { + if (workspaceSlug && projectId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); + } } - }); + ); + if (!workspaceSlug || !projectId) return <>; return (
-
- -
+ + {issues?.loader === "init-loader" ? ( +
+ +
+ ) : ( + <> + {!issues?.groupedIssueIds ? ( + + ) : ( + <> +
+ +
+ + {/* peek overview */} + + + )} + + )}
); }); diff --git a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx index f77dfbed4c1..3e07d16fc42 100644 --- a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useCycle, useIssues } from "hooks/store"; // components import { CycleAppliedFiltersRoot, @@ -13,43 +13,46 @@ import { CycleKanBanLayout, CycleListLayout, CycleSpreadsheetLayout, + IssuePeekOverview, } from "components/issues"; import { TransferIssues, TransferIssuesModal } from "components/cycles"; // ui import { Spinner } from "@plane/ui"; +// constants +import { EIssuesStoreType } from "constants/issue"; export const CycleLayoutRoot: React.FC = observer(() => { - const [transferIssuesModal, setTransferIssuesModal] = useState(false); - const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; - - const { - cycle: cycleStore, - cycleIssues: { loader, getIssues, fetchIssues }, - cycleIssuesFilter: { issueFilters, fetchFilters }, - } = useMobxStore(); + // store hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { getCycleById } = useCycle(); + // state + const [transferIssuesModal, setTransferIssuesModal] = useState(false); useSWR( - workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_V3_${workspaceSlug}_${projectId}_${cycleId}` : null, + workspaceSlug && projectId && cycleId + ? `CYCLE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${cycleId.toString()}` + : null, async () => { if (workspaceSlug && projectId && cycleId) { - await fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); - await fetchIssues( + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); + await issues?.fetchIssues( workspaceSlug.toString(), projectId.toString(), - getIssues ? "mutation" : "init-loader", + issues?.groupedIssueIds ? "mutation" : "init-loader", cycleId.toString() ); } } ); - const activeLayout = issueFilters?.displayFilters?.layout; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; - const cycleDetails = cycleId ? cycleStore.cycle_details[cycleId.toString()] : undefined; - const cycleStatus = cycleDetails?.status.toLocaleLowerCase() ?? "draft"; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const cycleStatus = cycleDetails?.status?.toLocaleLowerCase() ?? "draft"; + if (!workspaceSlug || !projectId || !cycleId) return <>; return ( <> setTransferIssuesModal(false)} isOpen={transferIssuesModal} /> @@ -58,32 +61,36 @@ export const CycleLayoutRoot: React.FC = observer(() => { {cycleStatus === "completed" && setTransferIssuesModal(true)} />} - {loader === "init-loader" || !getIssues ? ( + {issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
) : ( <> - {Object.keys(getIssues ?? {}).length == 0 ? ( + {issues?.groupedIssueIds?.length === 0 ? ( ) : ( -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
+ <> +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ {/* peek overview */} + + )} )} diff --git a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx index d09d477147d..075a16aa277 100644 --- a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx @@ -2,48 +2,63 @@ import React from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import { useIssues } from "hooks/store"; +// components import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue"; import { DraftIssueListLayout } from "../list/roots/draft-issue-root"; +import { ProjectDraftEmptyState } from "../empty-states"; +// ui import { Spinner } from "@plane/ui"; import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root"; +// constants +import { EIssuesStoreType } from "constants/issue"; export const DraftIssueLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); - const { - projectDraftIssuesFilter: { issueFilters, fetchFilters }, - projectDraftIssues: { loader, getIssues, fetchIssues }, - } = useMobxStore(); - - useSWR(workspaceSlug && projectId ? `DRAFT_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => { - if (workspaceSlug && projectId) { - await fetchFilters(workspaceSlug.toString(), projectId.toString()); - await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader"); + useSWR( + workspaceSlug && projectId ? `DRAFT_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, + async () => { + if (workspaceSlug && projectId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); + } } - }); + ); - const activeLayout = issueFilters?.displayFilters?.layout || undefined; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined; + if (!workspaceSlug || !projectId) return <>; return (
- {loader === "init-loader" ? ( + {issues?.loader === "init-loader" ? (
) : ( <> -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : null} -
+ {!issues?.groupedIssueIds ? ( + + ) : ( +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : null} +
+ )} )}
diff --git a/web/components/issues/issue-layouts/roots/index.ts b/web/components/issues/issue-layouts/roots/index.ts index 72f71aae26f..727e3e39351 100644 --- a/web/components/issues/issue-layouts/roots/index.ts +++ b/web/components/issues/issue-layouts/roots/index.ts @@ -1,6 +1,7 @@ -export * from "./cycle-layout-root"; -export * from "./all-issue-layout-root"; -export * from "./module-layout-root"; export * from "./project-layout-root"; +export * from "./module-layout-root"; +export * from "./cycle-layout-root"; export * from "./project-view-layout-root"; export * from "./archived-issue-layout-root"; +export * from "./draft-issue-layout-root"; +export * from "./all-issue-layout-root"; diff --git a/web/components/issues/issue-layouts/roots/module-layout-root.tsx b/web/components/issues/issue-layouts/roots/module-layout-root.tsx index 21f52564e10..10db13e49ce 100644 --- a/web/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -2,11 +2,11 @@ import React from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; - // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { + IssuePeekOverview, ModuleAppliedFiltersRoot, ModuleCalendarLayout, ModuleEmptyState, @@ -17,65 +17,71 @@ import { } from "components/issues"; // ui import { Spinner } from "@plane/ui"; +// constants +import { EIssuesStoreType } from "constants/issue"; export const ModuleLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId, moduleId } = router.query; - - const { - moduleIssues: { loader, getIssues, fetchIssues }, - moduleIssuesFilter: { issueFilters, fetchFilters }, - } = useMobxStore(); + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); useSWR( - workspaceSlug && projectId && moduleId ? `MODULE_ISSUES_V3_${workspaceSlug}_${projectId}_${moduleId}` : null, + workspaceSlug && projectId && moduleId + ? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}` + : null, async () => { if (workspaceSlug && projectId && moduleId) { - await fetchFilters(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); - await fetchIssues( + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); + await issues?.fetchIssues( workspaceSlug.toString(), projectId.toString(), - getIssues ? "mutation" : "init-loader", + issues?.groupedIssueIds ? "mutation" : "init-loader", moduleId.toString() ); } } ); - const activeLayout = issueFilters?.displayFilters?.layout || undefined; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined; + if (!workspaceSlug || !projectId || !moduleId) return <>; return (
- {loader === "init-loader" || !getIssues ? ( + {issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
) : ( <> - {Object.keys(getIssues ?? {}).length == 0 ? ( + {issues?.groupedIssueIds?.length === 0 ? ( ) : ( -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
+ <> +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ {/* peek overview */} + + )} - {/* */} )}
diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index db30e4b7c9f..ddc1e991795 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -1,9 +1,7 @@ -import React from "react"; +import { FC } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; // components import { ListLayout, @@ -13,54 +11,76 @@ import { ProjectAppliedFiltersRoot, ProjectSpreadsheetLayout, ProjectEmptyState, + IssuePeekOverview, } from "components/issues"; +// ui import { Spinner } from "@plane/ui"; +// hooks +import { useIssues } from "hooks/store"; +// constants +import { EIssuesStoreType } from "constants/issue"; -export const ProjectLayoutRoot: React.FC = observer(() => { +export const ProjectLayoutRoot: FC = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const { - projectIssues: { loader, getIssues, fetchIssues }, - projectIssuesFilter: { issueFilters, fetchFilters }, - } = useMobxStore(); - - useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_V3_${workspaceSlug}_${projectId}` : null, async () => { + useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, async () => { if (workspaceSlug && projectId) { - await fetchFilters(workspaceSlug.toString(), projectId.toString()); - await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader"); + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader" + ); } }); - const activeLayout = issueFilters?.displayFilters?.layout; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + if (!workspaceSlug || !projectId) return <>; return (
- {loader === "init-loader" || !getIssues ? ( + {issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
) : ( <> - {Object.keys(getIssues ?? {}).length == 0 ? ( - - ) : ( -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} + {issues?.groupedIssueIds?.length === 0 ? ( +
+
+ ) : ( + <> +
+ {/* mutation loader */} + {issues?.loader === "mutation" && ( +
+ +
+ )} + + {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ + {/* peek overview */} + + )} )} diff --git a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx index b6623f72c33..75ac2bd9ec1 100644 --- a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx @@ -1,62 +1,100 @@ -import React from "react"; +import React, { useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; - // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { + IssuePeekOverview, ProjectViewAppliedFiltersRoot, ProjectViewCalendarLayout, + ProjectViewEmptyState, ProjectViewGanttLayout, ProjectViewKanBanLayout, ProjectViewListLayout, ProjectViewSpreadsheetLayout, } from "components/issues"; import { Spinner } from "@plane/ui"; +// constants +import { EIssuesStoreType } from "constants/issue"; +// types +import { TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; export const ProjectViewLayoutRoot: React.FC = observer(() => { + // router const router = useRouter(); const { workspaceSlug, projectId, viewId } = router.query; + // hooks + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); - const { - viewIssues: { loader, getIssues, fetchIssues }, - viewIssuesFilter: { issueFilters, fetchFilters }, - } = useMobxStore(); - - useSWR(workspaceSlug && projectId && viewId ? `PROJECT_ISSUES_V3_${workspaceSlug}_${projectId}` : null, async () => { - if (workspaceSlug && projectId && viewId) { - await fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString()); - await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader"); + useSWR( + workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null, + async () => { + if (workspaceSlug && projectId && viewId) { + await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString()); + await issues?.fetchIssues( + workspaceSlug.toString(), + projectId.toString(), + issues?.groupedIssueIds ? "mutation" : "init-loader", + viewId.toString() + ); + } } - }); + ); + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; - const activeLayout = issueFilters?.displayFilters?.layout; + await issues.updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, issue, viewId?.toString()); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + await issues.removeIssue(workspaceSlug.toString(), projectId.toString(), issue.id, viewId?.toString()); + }, + }), + [issues, workspaceSlug, projectId, viewId] + ); + + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + + if (!workspaceSlug || !projectId || !viewId) return <>; return (
- {loader === "init-loader" ? ( + {issues?.loader === "init-loader" || !issues?.groupedIssueIds ? (
) : ( <> -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
+ {issues?.groupedIssueIds?.length === 0 ? ( + + ) : ( + <> +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ + {/* peek overview */} + + + )} )}
diff --git a/web/components/issues/issue-layouts/save-filter-view.tsx b/web/components/issues/issue-layouts/save-filter-view.tsx index 42fac26ef2a..8bf2cb21159 100644 --- a/web/components/issues/issue-layouts/save-filter-view.tsx +++ b/web/components/issues/issue-layouts/save-filter-view.tsx @@ -20,7 +20,7 @@ export const SaveFilterView: FC = (props) => { setViewModal(false)} /> diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 42c40ceeeef..54df6ca2446 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -1,74 +1,63 @@ -import { IIssueUnGroupedStructure } from "store/issue"; -import { SpreadsheetView } from "./spreadsheet-view"; import { FC, useCallback } from "react"; -import { IIssue, IIssueDisplayFilterOptions } from "types"; import { useRouter } from "next/router"; -import { useMobxStore } from "lib/mobx/store-provider"; -import { - ICycleIssuesFilterStore, - ICycleIssuesStore, - IModuleIssuesFilterStore, - IModuleIssuesStore, - IProjectIssuesFilterStore, - IProjectIssuesStore, - IViewIssuesFilterStore, - IViewIssuesStore, -} from "store/issues"; import { observer } from "mobx-react-lite"; -import { EFilterType, TUnGroupedIssues } from "store/issues/types"; +// hooks +import { useUser } from "hooks/store"; +// views +import { SpreadsheetView } from "./spreadsheet-view"; +// types +import { TIssue, IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; import { EIssueActions } from "../types"; import { IQuickActionProps } from "../list/list-view-types"; -import { EUserWorkspaceRoles } from "constants/workspace"; +// constants +import { EUserProjectRoles } from "constants/project"; +import { ICycleIssuesFilter, ICycleIssues } from "store/issue/cycle"; +import { IModuleIssuesFilter, IModuleIssues } from "store/issue/module"; +import { IProjectIssuesFilter, IProjectIssues } from "store/issue/project"; +import { IProjectViewIssuesFilter, IProjectViewIssues } from "store/issue/project-views"; +import { EIssueFilterType } from "constants/issue"; interface IBaseSpreadsheetRoot { - issueFiltersStore: - | IViewIssuesFilterStore - | ICycleIssuesFilterStore - | IModuleIssuesFilterStore - | IProjectIssuesFilterStore; - issueStore: IProjectIssuesStore | IModuleIssuesStore | ICycleIssuesStore | IViewIssuesStore; + issueFiltersStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issueStore: IProjectIssues | ICycleIssues | IModuleIssues | IProjectViewIssues; viewId?: string; QuickActions: FC; issueActions: { - [EIssueActions.DELETE]: (issue: IIssue) => void; - [EIssueActions.UPDATE]?: (issue: IIssue) => void; - [EIssueActions.REMOVE]?: (issue: IIssue) => void; + [EIssueActions.DELETE]: (issue: TIssue) => void; + [EIssueActions.UPDATE]?: (issue: TIssue) => void; + [EIssueActions.REMOVE]?: (issue: TIssue) => void; }; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; } export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { const { issueFiltersStore, issueStore, viewId, QuickActions, issueActions, canEditPropertiesBasedOnProject } = props; - + // router const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - + // store hooks const { - projectMember: { projectMembers }, - projectState: projectStateStore, - projectLabel: { projectLabels }, - user: userStore, - } = useMobxStore(); - + membership: { currentProjectRole }, + } = useUser(); + // derived values const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issueStore?.viewFlags || {}; + // user role validation + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - const { currentProjectRole } = userStore; - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - - const canEditProperties = (projectId: string | undefined) => { - const isEditingAllowedBasedOnProject = - canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; + const canEditProperties = useCallback( + (projectId: string | undefined) => { + const isEditingAllowedBasedOnProject = + canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; - return enableInlineEditing && isEditingAllowedBasedOnProject; - }; - - const issuesResponse = issueStore.getIssues; - const issueIds = (issueStore.getIssuesIds ?? []) as TUnGroupedIssues; + return enableInlineEditing && isEditingAllowedBasedOnProject; + }, + [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] + ); - const issues = issueIds?.filter((id) => id && issuesResponse?.[id]).map((id) => issuesResponse?.[id]); + const issueIds = (issueStore.groupedIssueIds ?? []) as TUnGroupedIssues; const handleIssues = useCallback( - async (issue: IIssue, action: EIssueActions) => { + async (issue: TIssue, action: EIssueActions) => { if (issueActions[action]) { issueActions[action]!(issue); } @@ -83,7 +72,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { issueFiltersStore.updateFilters( workspaceSlug, projectId, - EFilterType.DISPLAY_FILTERS, + EIssueFilterType.DISPLAY_FILTERS, { ...updatedDisplayFilter, }, @@ -93,28 +82,32 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [issueFiltersStore, projectId, workspaceSlug, viewId] ); + const renderQuickActions = useCallback( + (issue: TIssue, customActionButton?: React.ReactElement, portalElement?: HTMLDivElement | null) => ( + handleIssues(issue, EIssueActions.DELETE)} + handleUpdate={ + issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined + } + handleRemoveFromView={ + issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined + } + portalElement={portalElement} + /> + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleIssues] + ); + return ( ( - handleIssues(issue, EIssueActions.DELETE)} - handleUpdate={ - issueActions[EIssueActions.UPDATE] ? async (data) => handleIssues(data, EIssueActions.UPDATE) : undefined - } - handleRemoveFromView={ - issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined - } - /> - )} - members={projectMembers?.map((m) => m.member)} - labels={projectLabels || undefined} - states={projectId ? projectStateStore.states?.[projectId.toString()] : undefined} + issueIds={issueIds} + quickActions={renderQuickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} quickAddCallback={issueStore.quickAddIssue} diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index 3d2e9649908..2656143ac14 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -1,61 +1,34 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components -import { IssuePropertyAssignee } from "../../properties"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { ProjectMemberDropdown } from "components/dropdowns"; // types -import { IIssue, IUserLite } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - members: IUserLite[] | undefined; - onChange: (issue: IIssue, data: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetAssigneeColumn: React.FC = ({ issue, members, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - { - onChange(issue, { assignees: data }); - if (issue.parent) { - mutateSubIssues(issue, { assignees: data }); - } - }} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="!shadow-none !border-0 h-full w-full px-2.5 py-1 " - noLabelBorder - hideDropdownArrow +
+ onChange(issue, { assignee_ids: data })} + projectId={issue?.project_id} disabled={disabled} multiple + placeholder="Assignees" + buttonVariant={ + issue?.assignee_ids && issue.assignee_ids.length > 0 ? "transparent-without-text" : "transparent-with-text" + } + buttonClassName="text-left" + buttonContainerClassName="w-full" /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue) => ( -
- -
- ))} - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx index d8d4964a8b8..c17a433b846 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/attachment-column.tsx @@ -1,36 +1,18 @@ import React from "react"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { observer } from "mobx-react-lite"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetAttachmentColumn: React.FC = (props) => { - const { issue, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetAttachmentColumn: React.FC = observer((props) => { + const { issue } = props; return ( - <> -
- {issue.attachment_count} {issue.attachment_count === 1 ? "attachment" : "attachments"} -
- - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
+ {issue?.attachment_count} {issue?.attachment_count === 1 ? "attachment" : "attachments"} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx deleted file mode 100644 index c71343cfcca..00000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/columns-list.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { SpreadsheetColumn } from "components/issues"; -// types -import { IIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState, IUserLite } from "types"; - -type Props = { - displayFilters: IIssueDisplayFilterOptions; - displayProperties: IIssueDisplayProperties; - canEditProperties: (projectId: string | undefined) => boolean; - expandedIssues: string[]; - handleDisplayFilterUpdate: (data: Partial) => void; - handleUpdateIssue: (issue: IIssue, data: Partial) => void; - issues: IIssue[] | undefined; - members?: IUserLite[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; -}; - -export const SpreadsheetColumnsList: React.FC = observer((props) => { - const { - canEditProperties, - displayFilters, - displayProperties, - expandedIssues, - handleDisplayFilterUpdate, - handleUpdateIssue, - issues, - members, - labels, - states, - } = props; - - const { - project: { currentProjectDetails }, - } = useMobxStore(); - - const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - - return ( - <> - {displayProperties.state && ( - - )} - {displayProperties.priority && ( - - )} - {displayProperties.assignee && ( - - )} - {displayProperties.labels && ( - - )}{" "} - {displayProperties.start_date && ( - - )} - {displayProperties.due_date && ( - - )} - {displayProperties.estimate && isEstimateEnabled && ( - - )} - {displayProperties.created_on && ( - - )} - {displayProperties.updated_on && ( - - )} - {displayProperties.link && ( - - )} - {displayProperties.attachment_count && ( - - )} - {displayProperties.sub_issue_count && ( - - )} - - ); -}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx index dfe0e551325..8d373efb4b7 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/created-on-column.tsx @@ -1,37 +1,19 @@ import React from "react"; - -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { observer } from "mobx-react-lite"; // helpers -import { renderLongDetailDateFormat } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetCreatedOnColumn: React.FC = ({ issue, expandedIssues }) => { - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading } = useSubIssue(issue.project, issue.id, isExpanded); - +export const SpreadsheetCreatedOnColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
- {renderLongDetailDateFormat(issue.created_at)} -
- - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
+ {renderFormattedDate(issue.created_at)} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 73d761cf980..dbc27a3db72 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -1,54 +1,32 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components -import { ViewDueDateSelect } from "components/issues"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { DateDropdown } from "components/dropdowns"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - onChange: (issue: IIssue, data: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetDueDateColumn: React.FC = ({ issue, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - { - onChange(issue, { target_date: val }); - if (issue.parent) { - mutateSubIssues(issue, { target_date: val }); - } - }} - className="flex !h-11 !w-full max-w-full items-center px-2.5 py-1 border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - noBorder +
+ onChange(issue, { target_date: data ? renderFormattedPayloadDate(data) : null })} disabled={disabled} + placeholder="Due date" + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index f902c82dfbc..50878cccea3 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -1,56 +1,29 @@ // components -import { IssuePropertyEstimates } from "../../properties"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { EstimateDropdown } from "components/dropdowns"; +import { observer } from "mobx-react-lite"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - onChange: (issue: IIssue, formData: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetEstimateColumn: React.FC = (props) => { - const { issue, onChange, expandedIssues, disabled } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetEstimateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - + { - onChange(issue, { estimate_point: data }); - if (issue.parent) { - mutateSubIssues(issue, { estimate_point: data }); - } - }} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="h-full w-full px-2.5 py-1 !shadow-none !border-0" - hideDropdownArrow + onChange={(data) => onChange(issue, { estimate_point: data })} + projectId={issue.project_id} disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx new file mode 100644 index 00000000000..dc9f8c7c69a --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -0,0 +1,122 @@ +//ui +import { CustomMenu } from "@plane/ui"; +import { + ArrowDownWideNarrow, + ArrowUpNarrowWide, + CheckIcon, + ChevronDownIcon, + Eraser, + ListFilter, + MoveRight, +} from "lucide-react"; +//hooks +import useLocalStorage from "hooks/use-local-storage"; +//types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueOrderByOptions } from "@plane/types"; +//constants +import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; + +interface Props { + property: keyof IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; +} + +export const SpreadsheetHeaderColumn = (props: Props) => { + const { displayFilters, handleDisplayFilterUpdate, property } = props; + + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( + "spreadsheetViewActiveSortingProperty", + "" + ); + const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; + + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + handleDisplayFilterUpdate({ order_by: order }); + + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + return ( + +
+ {} + {propertyDetails.title} +
+
+ {activeSortingProperty === property && ( +
+ +
+ )} +
+
+ } + placement="bottom-end" + > + handleOrderBy(propertyDetails.ascendingOrderKey, property)}> +
+
+ + {propertyDetails.ascendingOrderTitle} + + {propertyDetails.descendingOrderTitle} +
+ + {selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` && } +
+
+ handleOrderBy(propertyDetails.descendingOrderKey, property)}> +
+
+ + {propertyDetails.descendingOrderTitle} + + {propertyDetails.ascendingOrderTitle} +
+ + {selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` && ( + + )} +
+
+ {selectedMenuItem && + selectedMenuItem !== "" && + displayFilters?.order_by !== "-created_at" && + selectedMenuItem.includes(property) && ( + handleOrderBy("-created_at", property)} + > +
+ + Clear sorting +
+
+ )} + + ); +}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts index a6c4979b31d..acfd02fc58a 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/columns/index.ts @@ -1,7 +1,5 @@ -export * from "./issue"; export * from "./assignee-column"; export * from "./attachment-column"; -export * from "./columns-list"; export * from "./created-on-column"; export * from "./due-date-column"; export * from "./estimate-column"; @@ -11,4 +9,4 @@ export * from "./priority-column"; export * from "./start-date-column"; export * from "./state-column"; export * from "./sub-issue-column"; -export * from "./updated-on-column"; +export * from "./updated-on-column"; \ No newline at end of file diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts b/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts deleted file mode 100644 index b8d09d1df1d..00000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./spreadsheet-issue-column"; -export * from "./issue-column"; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx deleted file mode 100644 index c2f3eddc8f5..00000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/issue-column.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useRef, useState } from "react"; -import { useRouter } from "next/router"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; -// components -import { Tooltip } from "@plane/ui"; -// hooks -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// types -import { IIssue, IIssueDisplayProperties } from "types"; - -type Props = { - issue: IIssue; - expanded: boolean; - handleToggleExpand: (issueId: string) => void; - properties: IIssueDisplayProperties; - quickActions: (issue: IIssue, customActionButton?: React.ReactElement) => React.ReactNode; - canEditProperties: (projectId: string | undefined) => boolean; - nestingLevel: number; -}; - -export const IssueColumn: React.FC = ({ - issue, - expanded, - handleToggleExpand, - properties, - quickActions, - canEditProperties, - nestingLevel, -}) => { - // router - const router = useRouter(); - // states - const [isMenuActive, setIsMenuActive] = useState(false); - - const menuActionRef = useRef(null); - - const handleIssuePeekOverview = (issue: IIssue, event: React.MouseEvent) => { - const { query } = router; - if (event.ctrlKey || event.metaKey) { - const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`; - window.open(issueUrl, "_blank"); // Open link in a new tab - } else { - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); - } - }; - - const paddingLeft = `${nestingLevel * 54}px`; - - useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); - - const customActionButton = ( -
setIsMenuActive(!isMenuActive)} - > - -
- ); - - return ( - <> -
- {properties.key && ( -
-
- - {issue.project_detail?.identifier}-{issue.sequence_id} - - - {canEditProperties(issue.project) && ( - - )} -
- - {issue.sub_issues_count > 0 && ( -
- -
- )} -
- )} -
- -
handleIssuePeekOverview(issue, e)} - > - {issue.name} -
-
-
-
- - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx deleted file mode 100644 index 738880d6535..00000000000 --- a/web/components/issues/issue-layouts/spreadsheet/columns/issue/spreadsheet-issue-column.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from "react"; - -// components -import { IssueColumn } from "components/issues"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; -// types -import { IIssue, IIssueDisplayProperties } from "types"; - -type Props = { - issue: IIssue; - expandedIssues: string[]; - setExpandedIssues: React.Dispatch>; - properties: IIssueDisplayProperties; - quickActions: (issue: IIssue, customActionButton?: React.ReactElement) => React.ReactNode; - canEditProperties: (projectId: string | undefined) => boolean; - nestingLevel?: number; -}; - -export const SpreadsheetIssuesColumn: React.FC = ({ - issue, - expandedIssues, - setExpandedIssues, - properties, - quickActions, - canEditProperties, - nestingLevel = 0, -}) => { - const handleToggleExpand = (issueId: string) => { - setExpandedIssues((prevState) => { - const newArray = [...prevState]; - const index = newArray.indexOf(issueId); - - if (index > -1) newArray.splice(index, 1); - else newArray.push(issueId); - - return newArray; - }); - }; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); - - return ( - <> - - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue) => ( - - ))} - - ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index b034afd9f04..82015056e39 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -1,63 +1,39 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components import { IssuePropertyLabels } from "../../properties"; // hooks -import useSubIssue from "hooks/use-sub-issue"; +import { useLabel } from "hooks/store"; // types -import { IIssue, IIssueLabel } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - onChange: (issue: IIssue, formData: Partial) => void; - labels: IIssueLabel[] | undefined; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetLabelColumn: React.FC = (props) => { - const { issue, onChange, labels, expandedIssues, disabled } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; +export const SpreadsheetLabelColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; + // hooks + const { labelMap } = useLabel(); - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); + const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; return ( - <> - { - onChange(issue, { labels: data }); - if (issue.parent) { - mutateSubIssues(issue, { assignees: data }); - } - }} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="px-2.5 h-full" - hideDropdownArrow - maxRender={1} - disabled={disabled} - placeholderText="Select labels" - /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - + { + onChange(issue, { label_ids: data }); + }} + className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" + buttonClassName="px-2.5 h-full" + hideDropdownArrow + maxRender={1} + disabled={disabled} + placeholderText="Select labels" + /> ); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx index 13713f63016..2d3e7b670f6 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/link-column.tsx @@ -1,36 +1,18 @@ import React from "react"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { observer } from "mobx-react-lite"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetLinkColumn: React.FC = (props) => { - const { issue, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetLinkColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
- {issue.link_count} {issue.link_count === 1 ? "link" : "links"} -
- - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
+ {issue?.link_count} {issue?.link_count === 1 ? "link" : "links"} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 116579a704e..0a83217403f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -1,57 +1,29 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components -import { PrioritySelect } from "components/project"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { PriorityDropdown } from "components/dropdowns"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - onChange: (issue: IIssue, data: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetPriorityColumn: React.FC = ({ issue, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - + { - onChange(issue, { priority: data }); - if (issue.parent) { - mutateSubIssues(issue, { priority: data }); - } - }} - className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - buttonClassName="!shadow-none !border-0 h-full w-full px-2.5 py-1" - showTitle - highlightUrgentPriority={false} - hideDropdownArrow + onChange={(data) => onChange(issue, { priority: data })} disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index 3233baadbd5..778f9cdac3d 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -1,54 +1,32 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components -import { ViewStartDateSelect } from "components/issues"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { DateDropdown } from "components/dropdowns"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - onChange: (issue: IIssue, formData: Partial) => void; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetStartDateColumn: React.FC = ({ issue, onChange, expandedIssues, disabled }) => { - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetStartDateColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; return ( - <> - { - onChange(issue, { start_date: val }); - if (issue.parent) { - mutateSubIssues(issue, { start_date: val }); - } - }} - className="flex !h-11 !w-full max-w-full items-center px-2.5 py-1 border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" - noBorder +
+ onChange(issue, { start_date: data ? renderFormattedPayloadDate(data) : null })} disabled={disabled} + placeholder="Start date" + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 5e41d680f8c..0050c8acfd3 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -1,61 +1,30 @@ import React from "react"; - +import { observer } from "mobx-react-lite"; // components -import { IssuePropertyState } from "../../properties"; -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { StateDropdown } from "components/dropdowns"; // types -import { IIssue, IState } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - onChange: (issue: IIssue, data: Partial) => void; - states: IState[] | undefined; - expandedIssues: string[]; + issue: TIssue; + onChange: (issue: TIssue, data: Partial) => void; disabled: boolean; }; -export const SpreadsheetStateColumn: React.FC = (props) => { - const { issue, onChange, states, expandedIssues, disabled } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetStateColumn: React.FC = observer((props) => { + const { issue, onChange, disabled } = props; return ( - <> - { - onChange(issue, { state: data.id, state_detail: data }); - if (issue.parent) { - mutateSubIssues(issue, { state: data.id, state_detail: data }); - } - }} - className="w-full !h-11 border-b-[0.5px] border-custom-border-200" - buttonClassName="!shadow-none !border-0 h-full w-full" - hideDropdownArrow +
+ onChange(issue, { state_id: data })} disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="rounded-none text-left" + buttonContainerClassName="w-full" /> - - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue) => ( -
- -
- ))} - +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx index 5f4b8c23428..c0e41d2c01c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/sub-issue-column.tsx @@ -1,36 +1,18 @@ import React from "react"; +import { observer } from "mobx-react-lite"; // hooks -import useSubIssue from "hooks/use-sub-issue"; -// types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetSubIssueColumn: React.FC = (props) => { - const { issue, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); +export const SpreadsheetSubIssueColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
- {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
+ {issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx index 97cc0fcff08..f84989192fc 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/updated-on-column.tsx @@ -1,39 +1,19 @@ import React from "react"; - -// hooks -import useSubIssue from "hooks/use-sub-issue"; +import { observer } from "mobx-react-lite"; // helpers -import { renderLongDetailDateFormat } from "helpers/date-time.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; type Props = { - issue: IIssue; - expandedIssues: string[]; + issue: TIssue; }; -export const SpreadsheetUpdatedOnColumn: React.FC = (props) => { - const { issue, expandedIssues } = props; - - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded); - +export const SpreadsheetUpdatedOnColumn: React.FC = observer((props: Props) => { + const { issue } = props; return ( - <> -
- {renderLongDetailDateFormat(issue.updated_at)} -
- - {isExpanded && - !isLoading && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssue: IIssue) => ( -
- -
- ))} - +
+ {renderFormattedDate(issue.updated_at)} +
); -}; +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/index.ts b/web/components/issues/issue-layouts/spreadsheet/index.ts index 10fc2621934..8f7c4a7fdb3 100644 --- a/web/components/issues/issue-layouts/spreadsheet/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/index.ts @@ -1,5 +1,4 @@ export * from "./columns"; export * from "./roots"; -export * from "./spreadsheet-column"; export * from "./spreadsheet-view"; export * from "./quick-add-issue-form"; diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx new file mode 100644 index 00000000000..579b8863ccf --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -0,0 +1,206 @@ +import { useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// icons +import { ChevronRight, MoreHorizontal } from "lucide-react"; +// constants +import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +// ui +import { ControlLink, Tooltip } from "@plane/ui"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useIssueDetail, useProject } from "hooks/store"; +// helper +import { cn } from "helpers/common.helper"; +// types +import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; + +interface Props { + displayProperties: IIssueDisplayProperties; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + canEditProperties: (projectId: string | undefined) => boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + portalElement: React.MutableRefObject; + nestingLevel: number; + issueId: string; +} + +export const SpreadsheetIssueRow = observer((props: Props) => { + const { + displayProperties, + issueId, + isEstimateEnabled, + nestingLevel, + portalElement, + handleIssues, + quickActions, + canEditProperties, + } = props; + + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + //hooks + const { getProjectById } = useProject(); + const { peekIssue, setPeekIssue } = useIssueDetail(); + // states + const [isMenuActive, setIsMenuActive] = useState(false); + const [isExpanded, setExpanded] = useState(false); + + const menuActionRef = useRef(null); + + const handleIssuePeekOverview = (issue: TIssue) => { + if (workspaceSlug && issue && issue.project_id && issue.id) + setPeekIssue({ workspaceSlug: workspaceSlug.toString(), projectId: issue.project_id, issueId: issue.id }); + }; + + const { subIssues: subIssuesStore, issue } = useIssueDetail(); + + const issueDetail = issue.getIssueById(issueId); + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + + const paddingLeft = `${nestingLevel * 54}px`; + + useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); + + const handleToggleExpand = () => { + setExpanded((prevState) => { + if (!prevState && workspaceSlug && issueDetail) + subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issueDetail.project_id, issueDetail.id); + return !prevState; + }); + }; + + const customActionButton = ( +
setIsMenuActive(!isMenuActive)} + > + +
+ ); + + if (!issueDetail) return null; + + const disableUserActions = !canEditProperties(issueDetail.project_id); + + return ( + <> + + {/* first column/ issue name and key column */} + + +
+
+ + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} + + + {canEditProperties(issueDetail.project_id) && ( + + )} +
+ + {issueDetail.sub_issues_count > 0 && ( +
+ +
+ )} +
+
+ handleIssuePeekOverview(issueDetail)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > +
+ +
+ {issueDetail.name} +
+
+
+
+ + {/* Rest of the columns */} + {SPREADSHEET_PROPERTY_LIST.map((property) => { + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + ) => + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE) + } + disabled={disableUserActions} + /> + + + ); + })} + + + {isExpanded && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssueId: string) => ( + + ))} + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index 04fbeacca2c..605e8bea142 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -1,34 +1,32 @@ import { useEffect, useState, useRef } from "react"; -import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; // hooks +import { useProject, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// store -import { useMobxStore } from "lib/mobx/store-provider"; // helpers import { createIssuePayload } from "helpers/issue.helper"; // types -import { IIssue, IProject } from "types"; +import { TIssue } from "@plane/types"; type Props = { - formKey: keyof IIssue; + formKey: keyof TIssue; groupId?: string; subGroupId?: string | null; - prePopulatedData?: Partial; + prePopulatedData?: Partial; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; }; -const defaultValues: Partial = { +const defaultValues: Partial = { name: "", }; @@ -57,21 +55,17 @@ const Inputs = (props: any) => { export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => { const { formKey, prePopulatedData, quickAddCallback, viewId } = props; - - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; - - // store - const { workspace: workspaceStore, project: projectStore } = useMobxStore(); - + // store hooks + const { currentWorkspace } = useWorkspace(); + const { currentProjectDetails } = useProject(); + // form info const { reset, handleSubmit, setFocus, register, formState: { errors, isSubmitting }, - } = useForm({ defaultValues }); + } = useForm({ defaultValues }); // ref const ref = useRef(null); @@ -86,11 +80,6 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => useOutsideClickDetector(ref, handleClose); const { setToastAlert } = useToast(); - // derived values - const workspaceDetail = (workspaceSlug && workspaceStore.getWorkspaceBySlug(workspaceSlug)) || null; - const projectDetail: IProject | null = - (workspaceSlug && projectId && projectStore.getProjectById(workspaceSlug, projectId)) || null; - useEffect(() => { setFocus("name"); }, [setFocus, isOpen]); @@ -103,7 +92,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => if (!errors) return; Object.keys(errors).forEach((key) => { - const error = errors[key as keyof IIssue]; + const error = errors[key as keyof TIssue]; setToastAlert({ type: "error", @@ -113,7 +102,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => }); }, [errors, setToastAlert]); - // const onSubmitHandler = async (formData: IIssue) => { + // const onSubmitHandler = async (formData: TIssue) => { // if (isSubmitting || !workspaceSlug || !projectId) return; // // resetting the form so that user can add another issue quickly @@ -154,18 +143,19 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => // } // }; - const onSubmitHandler = async (formData: IIssue) => { - if (isSubmitting || !workspaceDetail || !projectDetail) return; + const onSubmitHandler = async (formData: TIssue) => { + if (isSubmitting || !currentWorkspace || !currentProjectDetails) return; reset({ ...defaultValues }); - const payload = createIssuePayload(workspaceDetail, projectDetail, { + const payload = createIssuePayload(currentProjectDetails.id, { ...(prePopulatedData ?? {}), ...formData, }); try { - quickAddCallback && (await quickAddCallback(workspaceSlug, projectId, { ...payload } as IIssue, viewId)); + quickAddCallback && + (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId)); setToastAlert({ type: "success", title: "Success!", @@ -190,7 +180,12 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => onSubmit={handleSubmit(onSubmitHandler)} className="z-10 flex items-center gap-x-5 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-4 shadow-custom-shadow-sm" > - +
)} diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx index f61b14eb1ca..40b93355792 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx @@ -1,47 +1,44 @@ -import React from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; -import { useRouter } from "next/router"; import { EIssueActions } from "../../types"; -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; import { CycleIssueQuickActions } from "../../quick-action-dropdowns"; +import { EIssuesStoreType } from "constants/issue"; export const CycleSpreadsheetLayout: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string }; - const { - cycleIssues: cycleIssueStore, - cycleIssuesFilter: cycleIssueFilterStore, - cycle: { fetchCycleWithId }, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue, cycleId); - fetchCycleWithId(workspaceSlug, issue.project, cycleId); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId) return; - await cycleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, cycleId); - fetchCycleWithId(workspaceSlug, issue.project, cycleId); - }, - [EIssueActions.REMOVE]: async (issue: IIssue) => { - if (!workspaceSlug || !cycleId || !issue.bridge_id) return; - await cycleIssueStore.removeIssueFromCycle(workspaceSlug, issue.project, cycleId, issue.id, issue.bridge_id); - fetchCycleWithId(workspaceSlug, issue.project, cycleId); - }, - }; + issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue, cycleId); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + issues.removeIssue(workspaceSlug, issue.project_id, issue.id, cycleId); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + issues.removeIssueFromCycle(workspaceSlug, issue.project_id, cycleId, issue.id); + }, + }), + [issues, workspaceSlug, cycleId] + ); return ( { const router = useRouter(); const { workspaceSlug, moduleId } = router.query as { workspaceSlug: string; moduleId: string }; - const { - moduleIssues: moduleIssueStore, - moduleIssuesFilter: moduleIssueFilterStore, - module: { fetchModuleDetails }, - } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, moduleId); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, moduleId); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - [EIssueActions.REMOVE]: async (issue: IIssue) => { - if (!workspaceSlug || !moduleId || !issue.bridge_id) return; - await moduleIssueStore.removeIssueFromModule(workspaceSlug, issue.project, moduleId, issue.id, issue.bridge_id); - fetchModuleDetails(workspaceSlug, issue.project, moduleId); - }, - }; + issues.updateIssue(workspaceSlug.toString(), issue.project_id, issue.id, issue, moduleId); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + issues.removeIssue(workspaceSlug, issue.project_id, issue.id, moduleId); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id); + }, + }), + [issues, workspaceSlug, moduleId] + ); return ( { const router = useRouter(); const { workspaceSlug } = router.query as { workspaceSlug: string }; - const { projectIssues: projectIssuesStore, projectIssuesFilter: projectIssueFiltersStore } = useMobxStore(); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await projectIssuesStore.updateIssue(workspaceSlug, issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; + await issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug) return; - await projectIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id); - }, - }; + await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); + }, + }), + [issues, workspaceSlug] + ); return ( diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx index c3fe9f0b7ba..28b766cd12e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx @@ -1,39 +1,40 @@ import React from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { useIssues } from "hooks/store"; // components import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; -import { EIssueActions } from "../../types"; -import { IIssue } from "types"; -import { useRouter } from "next/router"; import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; +// types +import { EIssueActions } from "../../types"; +import { TIssue } from "@plane/types"; +// constants +import { EIssuesStoreType } from "constants/issue"; -export const ProjectViewSpreadsheetLayout: React.FC = observer(() => { - const router = useRouter(); - const { workspaceSlug } = router.query as { workspaceSlug: string }; - - const { viewIssues: projectViewIssuesStore, viewIssuesFilter: projectViewIssueFiltersStore } = useMobxStore(); - - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug) return; +export interface IViewSpreadsheetLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + }; +} - await projectViewIssuesStore.updateIssue(workspaceSlug, issue.project, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - if (!workspaceSlug) return; +export const ProjectViewSpreadsheetLayout: React.FC = observer((props) => { + const { issueActions } = props; + // router + const router = useRouter(); + const { viewId } = router.query; - await projectViewIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id); - }, - }; + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); return ( ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx deleted file mode 100644 index 7e8cad64a0d..00000000000 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-column.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { - ArrowDownWideNarrow, - ArrowUpNarrowWide, - CheckIcon, - ChevronDownIcon, - Eraser, - ListFilter, - MoveRight, -} from "lucide-react"; -// hooks -import useLocalStorage from "hooks/use-local-storage"; -// components -import { - SpreadsheetAssigneeColumn, - SpreadsheetAttachmentColumn, - SpreadsheetCreatedOnColumn, - SpreadsheetDueDateColumn, - SpreadsheetEstimateColumn, - SpreadsheetLabelColumn, - SpreadsheetLinkColumn, - SpreadsheetPriorityColumn, - SpreadsheetStartDateColumn, - SpreadsheetStateColumn, - SpreadsheetSubIssueColumn, - SpreadsheetUpdatedOnColumn, -} from "components/issues"; -// ui -import { CustomMenu } from "@plane/ui"; -// types -import { IIssue, IIssueDisplayFilterOptions, IIssueLabel, IState, IUserLite, TIssueOrderByOptions } from "types"; -// constants -import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; - -type Props = { - canEditProperties: (projectId: string | undefined) => boolean; - displayFilters: IIssueDisplayFilterOptions; - expandedIssues: string[]; - handleDisplayFilterUpdate: (data: Partial) => void; - handleUpdateIssue: (issue: IIssue, data: Partial) => void; - issues: IIssue[] | undefined; - property: string; - members?: IUserLite[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; -}; - -export const SpreadsheetColumn: React.FC = (props) => { - const { - canEditProperties, - displayFilters, - expandedIssues, - handleDisplayFilterUpdate, - handleUpdateIssue, - issues, - property, - members, - labels, - states, - } = props; - - const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( - "spreadsheetViewSorting", - "" - ); - const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( - "spreadsheetViewActiveSortingProperty", - "" - ); - - const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { - handleDisplayFilterUpdate({ order_by: order }); - - setSelectedMenuItem(`${order}_${itemKey}`); - setActiveSortingProperty(order === "-created_at" ? "" : itemKey); - }; - - const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; - - return ( -
-
- -
- {} - {propertyDetails.title} -
-
- {activeSortingProperty === property && ( -
- -
- )} -
-
- } - width="xl" - placement="bottom-end" - > - handleOrderBy(propertyDetails.ascendingOrderKey, property)}> -
-
- - {propertyDetails.ascendingOrderTitle} - - {propertyDetails.descendingOrderTitle} -
- - {selectedMenuItem === `${propertyDetails.ascendingOrderKey}_${property}` && ( - - )} -
-
- handleOrderBy(propertyDetails.descendingOrderKey, property)}> -
-
- - {propertyDetails.descendingOrderTitle} - - {propertyDetails.ascendingOrderTitle} -
- - {selectedMenuItem === `${propertyDetails.descendingOrderKey}_${property}` && ( - - )} -
-
- {selectedMenuItem && - selectedMenuItem !== "" && - displayFilters?.order_by !== "-created_at" && - selectedMenuItem.includes(property) && ( - handleOrderBy("-created_at", property)} - > -
- - Clear sorting -
-
- )} - -
- -
- {issues?.map((issue) => { - const disableUserActions = !canEditProperties(issue.project); - return ( -
- {property === "state" ? ( - ) => handleUpdateIssue(issue, data)} - states={states} - /> - ) : property === "priority" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "estimate" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "assignee" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "labels" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "start_date" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "due_date" ? ( - ) => handleUpdateIssue(issue, data)} - /> - ) : property === "created_on" ? ( - - ) : property === "updated_on" ? ( - - ) : property === "link" ? ( - - ) : property === "attachment_count" ? ( - - ) : property === "sub_issue_count" ? ( - - ) : null} -
- ); - })} -
-
- ); -}; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx new file mode 100644 index 00000000000..704c9f9047c --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -0,0 +1,59 @@ +// ui +import { LayersIcon } from "@plane/ui"; +// types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +// constants +import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { SpreadsheetHeaderColumn } from "./columns/header-column"; + + +interface Props { + displayProperties: IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; + isEstimateEnabled: boolean; +} + +export const SpreadsheetHeader = (props: Props) => { + const { displayProperties, displayFilters, handleDisplayFilterUpdate, isEstimateEnabled } = props; + + return ( + + + + + + #ID + + + + + Issue + + + + {SPREADSHEET_PROPERTY_LIST.map((property) => { + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + + + + ); + })} + + + ); +}; diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx new file mode 100644 index 00000000000..369e6633cdc --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -0,0 +1,63 @@ +import { observer } from "mobx-react-lite"; +//types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; +//components +import { SpreadsheetIssueRow } from "./issue-row"; +import { SpreadsheetHeader } from "./spreadsheet-header"; + +type Props = { + displayProperties: IIssueDisplayProperties; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; + issueIds: string[]; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + canEditProperties: (projectId: string | undefined) => boolean; + portalElement: React.MutableRefObject; +}; + +export const SpreadsheetTable = observer((props: Props) => { + const { + displayProperties, + displayFilters, + handleDisplayFilterUpdate, + issueIds, + isEstimateEnabled, + portalElement, + quickActions, + handleIssues, + canEditProperties, + } = props; + + return ( + + + + {issueIds.map((id) => ( + + ))} + +
+ ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 4b7ffe6dae6..e99b1785008 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,35 +1,33 @@ -import React, { useEffect, useRef, useState } from "react"; -import { useRouter } from "next/router"; +import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; // components -import { - IssuePeekOverview, - SpreadsheetColumnsList, - SpreadsheetIssuesColumn, - SpreadsheetQuickAddIssueForm, -} from "components/issues"; -import { Spinner, LayersIcon } from "@plane/ui"; +import { Spinner } from "@plane/ui"; +import { SpreadsheetQuickAddIssueForm } from "components/issues"; +import { SpreadsheetTable } from "./spreadsheet-table"; // types -import { IIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueLabel, IState, IUserLite } from "types"; +import { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; import { EIssueActions } from "../types"; +//hooks +import { useProject } from "hooks/store"; type Props = { displayProperties: IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; - issues: IIssue[] | undefined; - members?: IUserLite[] | undefined; - labels?: IIssueLabel[] | undefined; - states?: IState[] | undefined; - quickActions: (issue: IIssue, customActionButton: any) => React.ReactNode; // TODO: replace any with type - handleIssues: (issue: IIssue, action: EIssueActions) => Promise; + issueIds: string[] | undefined; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; openIssuesListModal?: (() => void) | null; quickAddCallback?: ( workspaceSlug: string, projectId: string, - data: IIssue, + data: TIssue, viewId?: string - ) => Promise; + ) => Promise; viewId?: string; canEditProperties: (projectId: string | undefined) => boolean; enableQuickCreateIssue?: boolean; @@ -41,10 +39,7 @@ export const SpreadsheetView: React.FC = observer((props) => { displayProperties, displayFilters, handleDisplayFilterUpdate, - issues, - members, - labels, - states, + issueIds, quickActions, handleIssues, quickAddCallback, @@ -54,19 +49,36 @@ export const SpreadsheetView: React.FC = observer((props) => { disableIssueCreation, } = props; // states - const [expandedIssues, setExpandedIssues] = useState([]); - const [isScrolled, setIsScrolled] = useState(false); + const isScrolled = useRef(false); // refs - const containerRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug, peekIssueId, peekProjectId } = router.query; + const containerRef = useRef(null); + const portalRef = useRef(null); + + const { currentProjectDetails } = useProject(); + + const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; const handleScroll = () => { if (!containerRef.current) return; - const scrollLeft = containerRef.current.scrollLeft; - setIsScrolled(scrollLeft > 0); + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firtColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firtColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firtColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } }; useEffect(() => { @@ -79,7 +91,7 @@ export const SpreadsheetView: React.FC = observer((props) => { }; }, []); - if (!issues || issues.length === 0) + if (!issueIds || issueIds.length === 0) return (
@@ -87,116 +99,28 @@ export const SpreadsheetView: React.FC = observer((props) => { ); return ( -
-
-
- {issues && issues.length > 0 && ( - <> -
-
-
- {displayProperties.key && ( - - #ID - - )} - - - Issue - -
- - {issues.map((issue, index) => - issue ? ( - - ) : null - )} -
-
- - handleIssues({ ...issue, ...data }, EIssueActions.UPDATE)} - issues={issues} - members={members} - labels={labels} - states={states} - /> - +
+
+
+ +
+
+
+ {enableQuickCreateIssue && !disableIssueCreation && ( + )} -
{/* empty div to show right most border */} -
- -
-
- {enableQuickCreateIssue && !disableIssueCreation && ( - - )} -
- - {/* {!disableUserActions && - !isInlineCreateIssueFormOpen && - (type === "issue" ? ( - - ) : ( - - - New Issue - - } - optionsClassName="left-5 !w-36" - noBorder - > - setIsInlineCreateIssueFormOpen(true)}> - Create new - - {openIssuesListModal && ( - Add an existing issue - )} - - ))} */}
- {workspaceSlug && peekIssueId && peekProjectId && ( - await handleIssues(issueToUpdate, action)} - /> - )}
); }); diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx new file mode 100644 index 00000000000..83ec363b9da --- /dev/null +++ b/web/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,157 @@ +import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui"; +import { ISSUE_PRIORITIES } from "constants/issue"; +import { renderEmoji } from "helpers/emoji.helper"; +import { IMemberRootStore } from "store/member"; +import { IProjectStore } from "store/project/project.store"; +import { IStateStore } from "store/state.store"; +import { GroupByColumnTypes, IGroupByColumn } from "@plane/types"; +import { STATE_GROUPS } from "constants/state"; +import { ILabelStore } from "store/label.store"; + +export const getGroupByColumns = ( + groupBy: GroupByColumnTypes | null, + project: IProjectStore, + label: ILabelStore, + projectState: IStateStore, + member: IMemberRootStore, + includeNone?: boolean +): IGroupByColumn[] | undefined => { + switch (groupBy) { + case "project": + return getProjectColumns(project); + case "state": + return getStateColumns(projectState); + case "state_detail.group": + return getStateGroupColumns(); + case "priority": + return getPriorityColumns(); + case "labels": + return getLabelsColumns(label) as any; + case "assignees": + return getAssigneeColumns(member) as any; + case "created_by": + return getCreatedByColumns(member) as any; + default: + if (includeNone) return [{ id: `null`, name: `All Issues`, payload: {}, icon: undefined }]; + } +}; + +const getProjectColumns = (project: IProjectStore): IGroupByColumn[] | undefined => { + const { workspaceProjectIds: projectIds, projectMap } = project; + + if (!projectIds) return; + + return projectIds + .filter((projectId) => !!projectMap[projectId]) + .map((projectId) => { + const project = projectMap[projectId]; + + return { + id: project.id, + name: project.name, + icon:
{renderEmoji(project.emoji || "")}
, + payload: { project_id: project.id }, + }; + }) as any; +}; + +const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => { + const { projectStates } = projectState; + if (!projectStates) return; + + return projectStates.map((state) => ({ + id: state.id, + name: state.name, + icon: ( +
+ +
+ ), + payload: { state_id: state.id }, + })) as any; +}; + +const getStateGroupColumns = () => { + const stateGroups = STATE_GROUPS; + + return Object.values(stateGroups).map((stateGroup) => ({ + id: stateGroup.key, + name: stateGroup.label, + icon: ( +
+ +
+ ), + payload: {}, + })); +}; + +const getPriorityColumns = () => { + const priorities = ISSUE_PRIORITIES; + + return priorities.map((priority) => ({ + id: priority.key, + name: priority.title, + icon: , + payload: { priority: priority.key }, + })); +}; + +const getLabelsColumns = (label: ILabelStore) => { + const { projectLabels } = label; + + if (!projectLabels) return; + + const labels = [...projectLabels, { id: "None", name: "None", color: "#666" }]; + + return labels.map((label) => ({ + id: label.id, + name: label.name, + icon: ( +
+ ), + payload: label?.id === "None" ? {} : { label_ids: [label.id] }, + })); +}; + +const getAssigneeColumns = (member: IMemberRootStore) => { + const { + project: { projectMemberIds }, + getUserDetails, + } = member; + + if (!projectMemberIds) return; + + const assigneeColumns: any = projectMemberIds.map((memberId) => { + const member = getUserDetails(memberId); + return { + id: memberId, + name: member?.display_name || "", + icon: , + payload: { assignee_ids: [memberId] }, + }; + }); + + assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); + + return assigneeColumns; +}; + +const getCreatedByColumns = (member: IMemberRootStore) => { + const { + project: { projectMemberIds }, + getUserDetails, + } = member; + + if (!projectMemberIds) return; + + return projectMemberIds.map((memberId) => { + const member = getUserDetails(memberId); + return { + id: memberId, + name: member?.display_name || "", + icon: , + payload: {}, + }; + }); +}; diff --git a/web/components/issues/issue-modal/draft-issue-layout.tsx b/web/components/issues/issue-modal/draft-issue-layout.tsx new file mode 100644 index 00000000000..274df29818b --- /dev/null +++ b/web/components/issues/issue-modal/draft-issue-layout.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// hooks +import useToast from "hooks/use-toast"; +// services +import { IssueDraftService } from "services/issue"; +// components +import { IssueFormRoot } from "components/issues/issue-modal/form"; +import { ConfirmIssueDiscard } from "components/issues"; +// types +import type { TIssue } from "@plane/types"; + +export interface DraftIssueProps { + changesMade: Partial | null; + data?: Partial; + isCreateMoreToggleEnabled: boolean; + onCreateMoreToggleChange: (value: boolean) => void; + onChange: (formData: Partial | null) => void; + onClose: (saveDraftIssueInLocalStorage?: boolean) => void; + onSubmit: (formData: Partial) => Promise; + projectId: string; +} + +const issueDraftService = new IssueDraftService(); + +export const DraftIssueLayout: React.FC = observer((props) => { + const { + changesMade, + data, + onChange, + onClose, + onSubmit, + projectId, + isCreateMoreToggleEnabled, + onCreateMoreToggleChange, + } = props; + // states + const [issueDiscardModal, setIssueDiscardModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + // toast alert + const { setToastAlert } = useToast(); + + const handleClose = () => { + if (changesMade) setIssueDiscardModal(true); + else onClose(false); + }; + + const handleCreateDraftIssue = async () => { + if (!changesMade || !workspaceSlug || !projectId) return; + + const payload = { ...changesMade }; + + await issueDraftService + .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Draft Issue created successfully.", + }); + + onChange(null); + setIssueDiscardModal(false); + onClose(false); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }) + ); + }; + + return ( + <> + setIssueDiscardModal(false)} + onConfirm={handleCreateDraftIssue} + onDiscard={() => { + onChange(null); + setIssueDiscardModal(false); + onClose(false); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx new file mode 100644 index 00000000000..31cb9dd669f --- /dev/null +++ b/web/components/issues/issue-modal/form.tsx @@ -0,0 +1,681 @@ +import React, { FC, useState, useRef, useEffect } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import { Controller, useForm } from "react-hook-form"; +import { LayoutPanelTop, Sparkle, X } from "lucide-react"; +// editor +import { RichTextEditorWithRef } from "@plane/rich-text-editor"; +// hooks +import { useApplication, useEstimate, useIssueDetail, useMention, useProject, useWorkspace } from "hooks/store"; +import useToast from "hooks/use-toast"; +// 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 { CreateLabelModal } from "components/labels"; +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 { TIssue, ISearchIssueResponse } from "@plane/types"; + +const defaultValues: Partial = { + project_id: "", + name: "", + description_html: "", + estimate_point: null, + state_id: "", + parent_id: null, + priority: "none", + assignee_ids: [], + label_ids: [], + cycle_id: null, + module_ids: null, + start_date: null, + target_date: null, +}; + +export interface IssueFormProps { + data?: Partial; + isCreateMoreToggleEnabled: boolean; + onCreateMoreToggleChange: (value: boolean) => void; + onChange?: (formData: Partial | null) => void; + onClose: () => void; + onSubmit: (values: Partial) => Promise; + projectId: string; +} + +// services +const aiService = new AIService(); +const fileService = new FileService(); + +export const IssueFormRoot: FC = observer((props) => { + const { + data, + onChange, + onClose, + onSubmit, + projectId: defaultProjectId, + isCreateMoreToggleEnabled, + onCreateMoreToggleChange, + } = props; + // states + 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); + + // 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 hooks + const { + config: { envConfig }, + } = useApplication(); + const { getProjectById } = useProject(); + const { areEstimatesEnabledForProject } = useEstimate(); + const { mentionHighlights, mentionSuggestions } = useMention(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // toast alert + const { setToastAlert } = useToast(); + // form info + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + watch, + control, + getValues, + setValue, + } = useForm({ + defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data }, + reValidateMode: "onChange", + }); + + const projectId = watch("project_id"); + + //reset few fields on projectId change + useEffect(() => { + if (isDirty) { + const formData = getValues(); + + reset({ + ...defaultValues, + project_id: projectId, + name: formData.name, + description_html: formData.description_html, + priority: formData.priority, + start_date: formData.start_date, + target_date: formData.target_date, + parent_id: formData.parent_id, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const issueName = watch("name"); + + const handleFormSubmit = async (formData: Partial) => { + await onSubmit(formData); + + setGptAssistantModal(false); + + reset({ + ...defaultValues, + project_id: getValues("project_id"), + }); + editorRef?.current?.clearEditor(); + }; + + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + + 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.toString(), projectId, { + 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)); + }; + + const handleFormChange = () => { + if (!onChange) return; + + if (isDirty && (watch("name") || watch("description_html"))) onChange(watch()); + else onChange(null); + }; + + 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); + + // executing this useEffect when the parent_id coming from the component prop + useEffect(() => { + const parentId = watch("parent_id") || undefined; + if (!parentId) return; + if (parentId === selectedParentIssue?.id || selectedParentIssue) return; + + const issue = getIssueById(parentId); + if (!issue) return; + + const projectDetails = getProjectById(issue.project_id); + if (!projectDetails) return; + + setSelectedParentIssue({ + id: issue.id, + name: issue.name, + project_id: issue.project_id, + project__identifier: projectDetails.identifier, + project__name: projectDetails.name, + sequence_id: issue.sequence_id, + } as ISearchIssueResponse); + }, [watch, getIssueById, getProjectById, selectedParentIssue]); + + return ( + <> + {projectId && ( + setLabelModal(false)} + projectId={projectId} + onSuccess={(response) => { + setValue("label_ids", [...watch("label_ids"), response.id]); + handleFormChange(); + }} + /> + )} +
+
+
+ {/* Don't show project selection if editing an issue */} + {!data?.id && ( + ( +
+ { + onChange(projectId); + handleFormChange(); + }} + buttonVariant="border-with-text" + // TODO: update tabIndex logic + tabIndex={19} + /> +
+ )} + /> + )} +

+ {data?.id ? "Update" : "Create"} issue +

+
+ {watch("parent_id") && selectedParentIssue && ( +
+
+ + + {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} + + {selectedParentIssue.name.substring(0, 50)} + { + setValue("parent_id", null); + handleFormChange(); + setSelectedParentIssue(null); + }} + tabIndex={20} + /> +
+
+ )} +
+
+ ( + { + onChange(e.target.value); + handleFormChange(); + }} + ref={ref} + hasError={Boolean(errors.name)} + placeholder="Issue Title" + className="resize-none text-xl w-full" + tabIndex={1} + /> + )} + /> +
+
+ {issueName && issueName.trim() !== "" && envConfig?.has_openai_configured && ( + + )} + {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); + }} + placement="top-end" + button={ + + } + /> + )} +
+ ( + { + onChange(description_html); + handleFormChange(); + }} + mentionHighlights={mentionHighlights} + mentionSuggestions={mentionSuggestions} + // tabIndex={2} + /> + )} + /> +
+
+ ( +
+ { + onChange(stateId); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + tabIndex={6} + /> +
+ )} + /> + ( +
+ { + onChange(priority); + handleFormChange(); + }} + buttonVariant="border-with-text" + tabIndex={7} + /> +
+ )} + /> + ( +
+ { + onChange(assigneeIds); + handleFormChange(); + }} + buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} + buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} + placeholder="Assignees" + multiple + tabIndex={8} + /> +
+ )} + /> + ( +
+ { + onChange(labelIds); + handleFormChange(); + }} + projectId={projectId} + tabIndex={9} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder="Start date" + maxDate={maxDate ?? undefined} + tabIndex={10} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + placeholder="Due date" + minDate={minDate ?? undefined} + tabIndex={11} + /> +
+ )} + /> + {projectDetails?.cycle_view && ( + ( +
+ { + onChange(cycleId); + handleFormChange(); + }} + value={value} + buttonVariant="border-with-text" + tabIndex={12} + /> +
+ )} + /> + )} + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ { + onChange(moduleIds); + handleFormChange(); + }} + buttonVariant="border-with-text" + tabIndex={13} + multiple + showCount + /> +
+ )} + /> + )} + {areEstimatesEnabledForProject(projectId) && ( + ( +
+ { + onChange(estimatePoint); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + tabIndex={14} + /> +
+ )} + /> + )} + + {watch("parent_id") ? ( +
+ + + {selectedParentIssue && + `${selectedParentIssue.project__identifier}- + ${selectedParentIssue.sequence_id}`} + +
+ ) : ( +
+ + Add parent +
+ )} + + } + placement="bottom-start" + tabIndex={15} + > + {watch("parent_id") ? ( + <> + setParentIssueListModalOpen(true)}> + Change parent issue + + { + setValue("parent_id", null); + handleFormChange(); + }} + > + Remove parent issue + + + ) : ( + setParentIssueListModalOpen(true)}> + Select parent Issue + + )} +
+ ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + handleFormChange(); + setSelectedParentIssue(issue); + }} + projectId={projectId} + /> + )} + /> +
+
+
+
+
+
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} + onKeyDown={(e) => { + if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); + }} + tabIndex={16} + > +
+ {}} size="sm" /> +
+ Create more +
+
+ + +
+
+
+ + ); +}); diff --git a/web/components/issues/issue-modal/index.ts b/web/components/issues/issue-modal/index.ts new file mode 100644 index 00000000000..feac885d4c9 --- /dev/null +++ b/web/components/issues/issue-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./draft-issue-layout"; +export * from "./form"; +export * from "./modal"; diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx new file mode 100644 index 00000000000..da13e635378 --- /dev/null +++ b/web/components/issues/issue-modal/modal.tsx @@ -0,0 +1,311 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Dialog, Transition } from "@headlessui/react"; +// hooks +import { useApplication, useCycle, useIssues, useModule, useProject, useUser, useWorkspace } from "hooks/store"; +import useToast from "hooks/use-toast"; +import useLocalStorage from "hooks/use-local-storage"; +// components +import { DraftIssueLayout } from "./draft-issue-layout"; +import { IssueFormRoot } from "./form"; +// types +import type { TIssue } from "@plane/types"; +// constants +import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue"; + +export interface IssuesModalProps { + data?: Partial; + isOpen: boolean; + onClose: () => void; + onSubmit?: (res: TIssue) => Promise; + withDraftIssueWrapper?: boolean; + storeType?: TCreateModalStoreTypes; +} + +export const CreateUpdateIssueModal: React.FC = observer((props) => { + const { data, isOpen, onClose, onSubmit, withDraftIssueWrapper = true, storeType = EIssuesStoreType.PROJECT } = props; + // states + const [changesMade, setChangesMade] = useState | null>(null); + const [createMore, setCreateMore] = useState(false); + const [activeProjectId, setActiveProjectId] = useState(null); + // store hooks + const { + eventTracker: { postHogEventTracker }, + } = useApplication(); + const { currentUser } = useUser(); + const { + router: { workspaceSlug, projectId, cycleId, moduleId, viewId: projectViewId }, + } = useApplication(); + const { currentWorkspace } = useWorkspace(); + const { workspaceProjectIds } = useProject(); + const { fetchCycleDetails } = useCycle(); + const { fetchModuleDetails } = useModule(); + const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT); + const { issues: moduleIssues } = useIssues(EIssuesStoreType.MODULE); + const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE); + const { issues: viewIssues } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { issues: profileIssues } = useIssues(EIssuesStoreType.PROFILE); + // store mapping based on current store + const issueStores = { + [EIssuesStoreType.PROJECT]: { + store: projectIssues, + dataIdToUpdate: activeProjectId, + viewId: undefined, + }, + [EIssuesStoreType.PROJECT_VIEW]: { + store: viewIssues, + dataIdToUpdate: activeProjectId, + viewId: projectViewId, + }, + [EIssuesStoreType.PROFILE]: { + store: profileIssues, + dataIdToUpdate: currentUser?.id || undefined, + viewId: undefined, + }, + [EIssuesStoreType.CYCLE]: { + store: cycleIssues, + dataIdToUpdate: activeProjectId, + viewId: cycleId, + }, + [EIssuesStoreType.MODULE]: { + store: moduleIssues, + dataIdToUpdate: activeProjectId, + viewId: moduleId, + }, + }; + // toast alert + const { setToastAlert } = useToast(); + // local storage + const { setValue: setLocalStorageDraftIssue } = useLocalStorage("draftedIssue", {}); + // current store details + const { store: currentIssueStore, viewId, dataIdToUpdate } = issueStores[storeType]; + + useEffect(() => { + // if modal is closed, reset active project to null + // and return to avoid activeProjectId being set to some other project + if (!isOpen) { + setActiveProjectId(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) { + setActiveProjectId(data.project_id); + return; + } + + // if data is not present, set active project to the project + // in the url. This has the least priority. + if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId) + setActiveProjectId(projectId ?? workspaceProjectIds?.[0]); + }, [data, projectId, workspaceProjectIds, isOpen, activeProjectId]); + + const addIssueToCycle = async (issue: TIssue, cycleId: string) => { + if (!workspaceSlug || !activeProjectId) return; + + await cycleIssues.addIssueToCycle(workspaceSlug, issue.project_id, cycleId, [issue.id]); + fetchCycleDetails(workspaceSlug, activeProjectId, cycleId); + }; + + const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => { + if (!workspaceSlug || !activeProjectId) return; + + await moduleIssues.addModulesToIssue(workspaceSlug, activeProjectId, issue.id, moduleIds); + moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug, activeProjectId, moduleId)); + }; + + const handleCreateMoreToggleChange = (value: boolean) => { + setCreateMore(value); + }; + + const handleClose = (saveDraftIssueInLocalStorage?: boolean) => { + if (changesMade && saveDraftIssueInLocalStorage) { + const draftIssue = JSON.stringify(changesMade); + setLocalStorageDraftIssue(draftIssue); + } + setActiveProjectId(null); + onClose(); + }; + + const handleCreateIssue = async (payload: Partial): Promise => { + if (!workspaceSlug || !dataIdToUpdate) return; + + try { + const response = await currentIssueStore.createIssue(workspaceSlug, dataIdToUpdate, payload, viewId); + if (!response) throw new Error(); + + currentIssueStore.fetchIssues(workspaceSlug, dataIdToUpdate, "mutation", viewId); + + if (payload.cycle_id && payload.cycle_id !== "" && storeType !== EIssuesStoreType.CYCLE) + await addIssueToCycle(response, payload.cycle_id); + if (payload.module_ids && payload.module_ids.length > 0 && storeType !== EIssuesStoreType.MODULE) + await addIssueToModule(response, payload.module_ids); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + postHogEventTracker( + "ISSUE_CREATED", + { + ...response, + state: "SUCCESS", + }, + { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: currentWorkspace?.id!, + } + ); + !createMore && handleClose(); + return response; + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + postHogEventTracker( + "ISSUE_CREATED", + { + state: "FAILED", + }, + { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: currentWorkspace?.id!, + } + ); + } + }; + + const handleUpdateIssue = async (payload: Partial): Promise => { + if (!workspaceSlug || !dataIdToUpdate || !data?.id) return; + + try { + const response = await currentIssueStore.updateIssue(workspaceSlug, dataIdToUpdate, data.id, payload, viewId); + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue updated successfully.", + }); + postHogEventTracker( + "ISSUE_UPDATED", + { + ...response, + state: "SUCCESS", + }, + { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: currentWorkspace?.id!, + } + ); + handleClose(); + return response; + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + postHogEventTracker( + "ISSUE_UPDATED", + { + state: "FAILED", + }, + { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: currentWorkspace?.id!, + } + ); + } + }; + + const handleFormSubmit = async (formData: Partial) => { + if (!workspaceSlug || !dataIdToUpdate || !storeType) return; + + const payload: Partial = { + ...formData, + description_html: formData.description_html ?? "

", + }; + + let response: TIssue | undefined = undefined; + if (!data?.id) response = await handleCreateIssue(payload); + else response = await handleUpdateIssue(payload); + + if (response != undefined && onSubmit) await onSubmit(response); + }; + + const handleFormChange = (formData: Partial | null) => setChangesMade(formData); + + // don't open the modal if there are no projects + if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null; + + return ( + + handleClose(true)}> + +
+ + +
+
+ + + {withDraftIssueWrapper ? ( + + ) : ( + handleClose(false)} + isCreateMoreToggleEnabled={createMore} + onCreateMoreToggleChange={handleCreateMoreToggleChange} + onSubmit={handleFormSubmit} + projectId={activeProjectId} + /> + )} + + +
+
+
+
+ ); +}); diff --git a/web/components/issues/issue-reaction.tsx b/web/components/issues/issue-reaction.tsx deleted file mode 100644 index 695870e4903..00000000000 --- a/web/components/issues/issue-reaction.tsx +++ /dev/null @@ -1,82 +0,0 @@ -// hooks -import useIssueReaction from "hooks/use-issue-reaction"; -// components -import { ReactionSelector } from "components/core"; -// string helpers -import { renderEmoji } from "helpers/emoji.helper"; -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; - -// types -type Props = { - workspaceSlug: string; - projectId: string; - issueId: string; -}; - -export const IssueReaction: React.FC = observer((props) => { - const { workspaceSlug, projectId, issueId } = props; - - const { - user: { currentUser }, - } = useMobxStore(); - - const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } = useIssueReaction( - workspaceSlug, - projectId, - issueId - ); - - const handleReactionClick = (reaction: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - - const isSelected = reactions?.some((r) => r.actor === currentUser?.id && r.reaction === reaction); - - if (isSelected) { - handleReactionDelete(reaction); - } else { - handleReactionCreate(reaction); - } - }; - - return ( -
- reaction.actor === currentUser?.id).map((r) => r.reaction) || []} - onSelect={handleReactionClick} - /> - - {Object.keys(groupedReactions || {}).map( - (reaction) => - groupedReactions?.[reaction]?.length && - groupedReactions[reaction].length > 0 && ( - - ) - )} -
- ); -}); diff --git a/web/components/issues/issue-update-status.tsx b/web/components/issues/issue-update-status.tsx index 08ca5fc774a..df2357179c1 100644 --- a/web/components/issues/issue-update-status.tsx +++ b/web/components/issues/issue-update-status.tsx @@ -1,20 +1,24 @@ import React from "react"; import { RefreshCw } from "lucide-react"; // types -import { IIssue } from "types"; +import { TIssue } from "@plane/types"; +import { useProject } from "hooks/store"; type Props = { isSubmitting: "submitting" | "submitted" | "saved"; - issueDetail?: IIssue; + issueDetail?: TIssue; }; export const IssueUpdateStatus: React.FC = (props) => { const { isSubmitting, issueDetail } = props; + // hooks + const { getProjectById } = useProject(); + return ( <> {issueDetail && (

- {issueDetail.project_detail?.identifier}-{issueDetail.sequence_id} + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}

)}
= ({ labelDetails, maxRender = 1 }) => ( <> - {labelDetails.length > 0 ? ( + {labelDetails?.length > 0 ? ( labelDetails.length <= maxRender ? ( <> {labelDetails.map((label) => ( diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx deleted file mode 100644 index 0fbf769e476..00000000000 --- a/web/components/issues/main-content.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import Link from "next/link"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import useSWR, { mutate } from "swr"; -import { MinusCircle } from "lucide-react"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import { IssueService, IssueCommentService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { - AddComment, - IssueActivitySection, - IssueAttachmentUpload, - IssueAttachments, - IssueDescriptionForm, - IssueReaction, - IssueUpdateStatus, -} from "components/issues"; -import { useState } from "react"; -import { SubIssuesRoot } from "./sub-issues"; -// ui -import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui"; -// types -import { IIssue, IIssueActivity } from "types"; -// fetch-keys -import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys"; -// constants -import { EUserWorkspaceRoles } from "constants/workspace"; - -type Props = { - issueDetails: IIssue; - submitChanges: (formData: Partial) => Promise; - uneditable?: boolean; -}; - -// services -const issueService = new IssueService(); -const issueCommentService = new IssueCommentService(); - -export const IssueMainContent: React.FC = observer((props) => { - const { issueDetails, submitChanges, uneditable } = props; - // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); - // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; - // toast alert - const { setToastAlert } = useToast(); - // mobx store - const { - user: { currentUser, currentProjectRole }, - project: projectStore, - projectState: { states }, - trackEvent: { postHogEventTracker }, - workspace: { currentWorkspace }, - } = useMobxStore(); - - const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined; - const currentIssueState = projectId - ? states[projectId.toString()]?.find((s) => s.id === issueDetails.state) - : undefined; - - const { data: siblingIssues } = useSWR( - workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, - workspaceSlug && projectId && issueDetails?.parent - ? () => issueService.subIssues(workspaceSlug.toString(), projectId.toString(), issueDetails.parent ?? "") - : null - ); - const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id); - - const { data: issueActivity, mutate: mutateIssueActivity } = useSWR( - workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null, - workspaceSlug && projectId && issueId - ? () => issueService.getIssueActivities(workspaceSlug.toString(), projectId.toString(), issueId.toString()) - : null - ); - - const handleCommentUpdate = async (commentId: string, data: Partial) => { - if (!workspaceSlug || !projectId || !issueId) return; - - await issueCommentService - .patchIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId, data) - .then((res) => { - mutateIssueActivity(); - postHogEventTracker( - "COMMENT_UPDATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleCommentDelete = async (commentId: string) => { - if (!workspaceSlug || !projectId || !issueId || !currentUser) return; - - mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false); - - await issueCommentService - .deleteIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId) - .then(() => { - mutateIssueActivity(); - postHogEventTracker( - "COMMENT_DELETED", - { - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleAddComment = async (formData: IIssueActivity) => { - if (!workspaceSlug || !issueDetails || !currentUser) return; - - await issueCommentService - .createIssueComment(workspaceSlug.toString(), issueDetails.project, issueDetails.id, formData) - .then((res) => { - mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id)); - postHogEventTracker( - "COMMENT_ADDED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }) - .catch(() => - setToastAlert({ - type: "error", - title: "Error!", - message: "Comment could not be posted. Please try again.", - }) - ); - }; - - const isAllowed = - (!!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER) || - (uneditable !== undefined && !uneditable); - - return ( - <> -
- {issueDetails?.parent ? ( -
- -
-
- - - {issueDetails.parent_detail?.project_detail.identifier}-{issueDetails.parent_detail?.sequence_id} - -
- - {issueDetails.parent_detail?.name.substring(0, 50)} - -
- - - - {siblingIssuesList ? ( - siblingIssuesList.length > 0 ? ( - <> -

- Sibling issues -

- {siblingIssuesList.map((issue) => ( - - router.push(`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id}`) - } - className="flex items-center gap-2 py-2" - > - - {issueDetails.project_detail.identifier}-{issue.sequence_id} - - ))} - - ) : ( -

- No sibling issues -

- ) - ) : null} - submitChanges({ parent: null })} - className="flex items-center gap-2 py-2 text-red-500" - > - - Remove Parent Issue - -
-
- ) : null} -
- {currentIssueState && ( - - )} - -
- setIsSubmitting(value)} - isSubmitting={isSubmitting} - workspaceSlug={workspaceSlug as string} - issue={issueDetails} - handleFormSubmit={submitChanges} - isAllowed={isAllowed} - /> - - {workspaceSlug && projectId && ( - - )} - -
- -
-
-
-

Attachments

-
- - -
-
-
-

Comments/Activity

- - -
- - ); -}); diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx deleted file mode 100644 index 0e4dee36d4c..00000000000 --- a/web/components/issues/modal.tsx +++ /dev/null @@ -1,452 +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"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -// services -import { IssueDraftService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; -// components -import { IssueForm, ConfirmIssueDiscard } from "components/issues"; -// types -import type { IIssue } from "types"; -// fetch-keys -import { USER_ISSUE, SUB_ISSUES } from "constants/fetch-keys"; -import { EProjectStore } from "store/command-palette.store"; - -export interface IssuesModalProps { - data?: IIssue | null; - handleClose: () => void; - isOpen: boolean; - prePopulateData?: Partial; - fieldsToShow?: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - | "module" - | "cycle" - )[]; - onSubmit?: (data: Partial) => Promise; - handleSubmit?: (data: Partial) => Promise; - currentStore?: EProjectStore; -} - -const issueDraftService = new IssueDraftService(); - -export const CreateUpdateIssueModal: React.FC = observer((props) => { - const { - data, - handleClose, - isOpen, - prePopulateData: prePopulateDataProps, - fieldsToShow = ["all"], - onSubmit, - handleSubmit, - currentStore = EProjectStore.PROJECT, - } = props; - - // states - const [createMore, setCreateMore] = useState(false); - const [formDirtyState, setFormDirtyState] = useState(null); - const [showConfirmDiscard, setShowConfirmDiscard] = useState(false); - const [activeProject, setActiveProject] = useState(null); - const [prePopulateData, setPreloadedData] = useState>({}); - - const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query as { - workspaceSlug: string; - projectId: string | undefined; - cycleId: string | undefined; - moduleId: string | undefined; - }; - - const { - project: projectStore, - projectIssues: projectIssueStore, - viewIssues: projectViewIssueStore, - workspaceProfileIssues: profileIssueStore, - cycleIssues: cycleIssueStore, - moduleIssues: moduleIssueStore, - user: userStore, - trackEvent: { postHogEventTracker }, - workspace: { currentWorkspace }, - cycle: { fetchCycleWithId }, - module: { fetchModuleDetails }, - } = useMobxStore(); - - const user = userStore.currentUser; - - const issueStores = { - [EProjectStore.PROJECT]: { - store: projectIssueStore, - dataIdToUpdate: activeProject, - viewId: undefined, - }, - [EProjectStore.PROJECT_VIEW]: { - store: projectViewIssueStore, - dataIdToUpdate: activeProject, - viewId: undefined, - }, - [EProjectStore.PROFILE]: { - store: profileIssueStore, - dataIdToUpdate: user?.id || undefined, - viewId: undefined, - }, - [EProjectStore.CYCLE]: { - store: cycleIssueStore, - dataIdToUpdate: activeProject, - viewId: cycleId, - }, - [EProjectStore.MODULE]: { - store: moduleIssueStore, - dataIdToUpdate: activeProject, - viewId: moduleId, - }, - }; - - const { store: currentIssueStore, viewId, dataIdToUpdate } = issueStores[currentStore]; - - const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined; - - const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } = useLocalStorage( - "draftedIssue", - {} - ); - - const { setToastAlert } = useToast(); - - useEffect(() => { - setPreloadedData(prePopulateDataProps ?? {}); - - if (cycleId && !prePopulateDataProps?.cycle) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - cycle: cycleId.toString(), - })); - } - if (moduleId && !prePopulateDataProps?.module) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - module: moduleId.toString(), - })); - } - if ( - (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignees - ) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], - })); - } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); - - /** - * - * @description This function is used to close the modals. This function will show a confirm discard modal if the form is dirty. - * @returns void - */ - - const onClose = () => { - if (!showConfirmDiscard) handleClose(); - if (formDirtyState === null) return setActiveProject(null); - const data = JSON.stringify(formDirtyState); - setValueInLocalStorage(data); - }; - - /** - * @description This function is used to close the modals. This function is to be used when the form is submitted, - * meaning we don't need to show the confirm discard modal or store the form data in local storage. - */ - - const onFormSubmitClose = () => { - setFormDirtyState(null); - handleClose(); - }; - - /** - * @description This function is used to close the modals. This function is to be used when we click outside the modal, - * meaning we don't need to show the confirm discard modal but will store the form data in local storage. - * Use this function when you want to store the form data in local storage. - */ - - const onDiscardClose = () => { - if (formDirtyState !== null && formDirtyState.name.trim() !== "") { - setShowConfirmDiscard(true); - } else { - handleClose(); - setActiveProject(null); - } - }; - - const handleFormDirty = (data: any) => { - setFormDirtyState(data); - }; - - 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) { - setActiveProject(data.project); - return; - } - - // 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((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); - }, [data, projectId, projects, isOpen, activeProject]); - - const addIssueToCycle = async (issue: IIssue, cycleId: string) => { - if (!workspaceSlug || !activeProject) return; - - await cycleIssueStore.addIssueToCycle(workspaceSlug, cycleId, [issue.id]); - fetchCycleWithId(workspaceSlug, activeProject, cycleId); - }; - - const addIssueToModule = async (issue: IIssue, moduleId: string) => { - if (!workspaceSlug || !activeProject) return; - - await moduleIssueStore.addIssueToModule(workspaceSlug, moduleId, [issue.id]); - fetchModuleDetails(workspaceSlug, activeProject, moduleId); - }; - - const createIssue = async (payload: Partial) => { - if (!workspaceSlug || !dataIdToUpdate) return; - - await currentIssueStore - .createIssue(workspaceSlug, dataIdToUpdate, payload, viewId) - .then(async (res) => { - if (!res) throw new Error(); - - if (handleSubmit) { - await handleSubmit(res); - } else { - currentIssueStore.fetchIssues(workspaceSlug, dataIdToUpdate, "mutation", viewId); - - if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res, payload.cycle); - if (payload.module && payload.module !== "") await addIssueToModule(res, payload.module); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - postHogEventTracker( - "ISSUE_CREATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); - } - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Issue could not be created. Please try again.", - }); - postHogEventTracker( - "ISSUE_CREATED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }); - - if (!createMore) onFormSubmitClose(); - }; - - const createDraftIssue = async () => { - if (!workspaceSlug || !activeProject || !user) return; - - const payload: Partial = { - ...formDirtyState, - }; - - await issueDraftService - .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload) - .then(() => { - setToastAlert({ - type: "success", - title: "Success!", - message: "Draft Issue created successfully.", - }); - handleClose(); - setActiveProject(null); - setFormDirtyState(null); - setShowConfirmDiscard(false); - - if (payload.assignees?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug as string)); - - if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Issue could not be created. Please try again.", - }); - }); - }; - - const updateIssue = async (payload: Partial) => { - if (!workspaceSlug || !dataIdToUpdate || !data) return; - - await currentIssueStore - .updateIssue(workspaceSlug, dataIdToUpdate, data.id, payload, viewId) - .then((res) => { - if (!createMore) onFormSubmitClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue updated successfully.", - }); - postHogEventTracker( - "ISSUE_UPDATED", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }) - .catch((err) => { - setToastAlert({ - type: "error", - title: "Error!", - message: err.detail ?? "Issue could not be updated. Please try again.", - }); - postHogEventTracker( - "ISSUE_UPDATED", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - gorupId: currentWorkspace?.id!, - } - ); - }); - }; - - const handleFormSubmit = async (formData: Partial) => { - if (!workspaceSlug || !dataIdToUpdate || !currentStore) return; - - const payload: Partial = { - ...formData, - description: formData.description ?? "", - description_html: formData.description_html ?? "

", - }; - - if (!data) await createIssue(payload); - else await updateIssue(payload); - - if (onSubmit) await onSubmit(payload); - }; - - if (!projects || projects.length === 0) return null; - - return ( - <> - setShowConfirmDiscard(false)} - onConfirm={createDraftIssue} - onDiscard={() => { - handleClose(); - setActiveProject(null); - setFormDirtyState(null); - setShowConfirmDiscard(false); - clearLocalStorageValue(); - }} - /> - - - - -
- - -
-
- - - - - -
-
-
-
- - ); -}); diff --git a/web/components/issues/parent-issues-list-modal.tsx b/web/components/issues/parent-issues-list-modal.tsx index 3c7b1af3447..c8520562e40 100644 --- a/web/components/issues/parent-issues-list-modal.tsx +++ b/web/components/issues/parent-issues-list-modal.tsx @@ -13,7 +13,7 @@ import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; // icons import { Rocket, Search } from "lucide-react"; // types -import { ISearchIssueResponse } from "types"; +import { ISearchIssueResponse } from "@plane/types"; type Props = { isOpen: boolean; diff --git a/web/components/issues/peek-overview/activity/card.tsx b/web/components/issues/peek-overview/activity/card.tsx deleted file mode 100644 index 86d1a138c67..00000000000 --- a/web/components/issues/peek-overview/activity/card.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { FC } from "react"; -import Link from "next/link"; -import { History } from "lucide-react"; -// packages -import { Loader, Tooltip } from "@plane/ui"; -// components -import { ActivityIcon, ActivityMessage } from "components/core"; -import { IssueCommentCard } from "./comment-card"; -// helpers -import { render24HourFormatTime, renderLongDateFormat, timeAgo } from "helpers/date-time.helper"; -// types -import { IIssueActivity, IUser } from "types"; - -interface IIssueActivityCard { - workspaceSlug: string; - projectId: string; - issueId: string; - user: IUser | null; - issueActivity: IIssueActivity[] | null; - issueCommentUpdate: (comment: any) => void; - issueCommentRemove: (commentId: string) => void; - issueCommentReactionCreate: (commentId: string, reaction: string) => void; - issueCommentReactionRemove: (commentId: string, reaction: string) => void; -} - -export const IssueActivityCard: FC = (props) => { - const { - workspaceSlug, - projectId, - issueId, - user, - issueActivity, - issueCommentUpdate, - issueCommentRemove, - issueCommentReactionCreate, - issueCommentReactionRemove, - } = props; - - return ( -
-
    - {issueActivity ? ( - issueActivity.length > 0 && - issueActivity.map((activityItem, index) => { - // determines what type of action is performed - const message = activityItem.field ? : "created the issue."; - - if ("field" in activityItem && activityItem.field !== "updated_by") { - return ( -
  • -
    - {issueActivity.length > 1 && index !== issueActivity.length - 1 ? ( -
    -
  • - ); - } else if ("comment_html" in activityItem) - return ( -
    - -
    - ); - }) - ) : ( - - - - - - - )} -
-
- ); -}; diff --git a/web/components/issues/peek-overview/activity/comment-card.tsx b/web/components/issues/peek-overview/activity/comment-card.tsx deleted file mode 100644 index be8915ad2bd..00000000000 --- a/web/components/issues/peek-overview/activity/comment-card.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; -import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react"; -// services -import { FileService } from "services/file.service"; -// ui -import { CustomMenu } from "@plane/ui"; -import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor"; -// components -import { IssueCommentReaction } from "./comment-reaction"; -// helpers -import { timeAgo } from "helpers/date-time.helper"; -// types -import type { IIssueActivity, IUser } from "types"; - -// services -const fileService = new FileService(); - -type IIssueCommentCard = { - comment: IIssueActivity; - handleCommentDeletion: (comment: string) => void; - onSubmit: (data: Partial) => void; - showAccessSpecifier?: boolean; - workspaceSlug: string; - projectId: string; - issueId: string; - user: IUser | null; - issueCommentReactionCreate: (commentId: string, reaction: string) => void; - issueCommentReactionRemove: (commentId: string, reaction: string) => void; -}; - -export const IssueCommentCard: React.FC = (props) => { - const { - comment, - handleCommentDeletion, - onSubmit, - showAccessSpecifier = false, - workspaceSlug, - projectId, - issueId, - user, - issueCommentReactionCreate, - issueCommentReactionRemove, - } = props; - - const editorRef = React.useRef(null); - const showEditorRef = React.useRef(null); - - const [isEditing, setIsEditing] = useState(false); - - const editorSuggestions = useEditorSuggestions(); - - const { - formState: { isSubmitting }, - handleSubmit, - setFocus, - watch, - setValue, - } = useForm({ - defaultValues: comment, - }); - - const formSubmit = (formData: Partial) => { - if (isSubmitting) return; - - setIsEditing(false); - - onSubmit({ id: comment.id, ...formData }); - - editorRef.current?.setEditorValue(formData.comment_html); - showEditorRef.current?.setEditorValue(formData.comment_html); - }; - - useEffect(() => { - isEditing && setFocus("comment"); - }, [isEditing, setFocus]); - - return ( -
-
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( - { - ) : ( -
- {comment.actor_detail.is_bot - ? comment.actor_detail.first_name.charAt(0) - : comment.actor_detail.display_name.charAt(0)} -
- )} - - - -
-
-
-
- {comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name} -
-

commented {timeAgo(comment.created_at)}

-
- -
-
-
- setValue("comment_html", comment_html)} - mentionSuggestions={editorSuggestions.mentionSuggestions} - mentionHighlights={editorSuggestions.mentionHighlights} - /> -
-
- - - -
-
- -
- {showAccessSpecifier && ( -
- {comment.access === "INTERNAL" ? : } -
- )} - - -
- -
-
-
-
- {user?.id === comment.actor && ( - - setIsEditing(true)} className="flex items-center gap-1"> - - Edit comment - - {showAccessSpecifier && ( - <> - {comment.access === "INTERNAL" ? ( - onSubmit({ id: comment.id, access: "EXTERNAL" })} - className="flex items-center gap-1" - > - - Switch to public comment - - ) : ( - onSubmit({ id: comment.id, access: "INTERNAL" })} - className="flex items-center gap-1" - > - - Switch to private comment - - )} - - )} - { - handleCommentDeletion(comment.id); - }} - className="flex items-center gap-1" - > - - Delete comment - - - )} -
- ); -}; diff --git a/web/components/issues/peek-overview/activity/comment-editor.tsx b/web/components/issues/peek-overview/activity/comment-editor.tsx deleted file mode 100644 index a53e4160f5a..00000000000 --- a/web/components/issues/peek-overview/activity/comment-editor.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from "react"; -import { useRouter } from "next/router"; -import { useForm, Controller } from "react-hook-form"; -import { Globe2, Lock } from "lucide-react"; -// services -import { FileService } from "services/file.service"; -// hooks -import useEditorSuggestions from "hooks/use-editor-suggestions"; -// components -import { LiteTextEditorWithRef } from "@plane/lite-text-editor"; -// ui -import { Button } from "@plane/ui"; -// types -import type { IIssueActivity } from "types"; - -const defaultValues: Partial = { - access: "INTERNAL", - comment_html: "", -}; - -type IIssueCommentEditor = { - disabled?: boolean; - onSubmit: (data: IIssueActivity) => Promise; - showAccessSpecifier?: boolean; -}; - -type commentAccessType = { - icon: any; - key: string; - label: "Private" | "Public"; -}; -const commentAccess: commentAccessType[] = [ - { - icon: Lock, - key: "INTERNAL", - label: "Private", - }, - { - icon: Globe2, - key: "EXTERNAL", - label: "Public", - }, -]; - -// services -const fileService = new FileService(); - -export const IssueCommentEditor: React.FC = (props) => { - const { disabled = false, onSubmit, showAccessSpecifier = false } = props; - - const editorRef = React.useRef(null); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const editorSuggestions = useEditorSuggestions(); - - const { - control, - formState: { isSubmitting }, - handleSubmit, - reset, - } = useForm({ defaultValues }); - - const handleAddComment = async (formData: IIssueActivity) => { - if (!formData.comment_html || isSubmitting) return; - - await onSubmit(formData).then(() => { - reset(defaultValues); - editorRef.current?.clearEditor(); - }); - }; - - return ( -
-
-
- ( - ( -

" : commentValue} - customClassName="p-2 h-full" - editorContentCustomClassNames="min-h-[35px]" - debouncedUpdatesEnabled={false} - mentionSuggestions={editorSuggestions.mentionSuggestions} - mentionHighlights={editorSuggestions.mentionHighlights} - onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)} - commentAccessSpecifier={ - showAccessSpecifier - ? { accessValue: accessValue ?? "INTERNAL", onAccessChange, showAccessSpecifier, commentAccess } - : undefined - } - submitButton={ - - } - /> - )} - /> - )} - /> -
-
-
- ); -}; diff --git a/web/components/issues/peek-overview/activity/comment-reaction.tsx b/web/components/issues/peek-overview/activity/comment-reaction.tsx deleted file mode 100644 index 144252dc98e..00000000000 --- a/web/components/issues/peek-overview/activity/comment-reaction.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { FC } from "react"; -import useSWR from "swr"; -import { observer } from "mobx-react-lite"; -// components -import { IssuePeekOverviewReactions } from "components/issues"; -// hooks -import { useMobxStore } from "lib/mobx/store-provider"; -// types -import { RootStore } from "store/root"; - -interface IIssueCommentReaction { - workspaceSlug: string; - projectId: string; - issueId: string; - user: any; - - comment: any; - issueCommentReactionCreate: (commentId: string, reaction: string) => void; - issueCommentReactionRemove: (commentId: string, reaction: string) => void; -} - -export const IssueCommentReaction: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, user, comment, issueCommentReactionCreate, issueCommentReactionRemove } = - props; - - const { issueDetail: issueDetailStore }: RootStore = useMobxStore(); - - const handleCommentReactionCreate = (reaction: string) => { - if (issueCommentReactionCreate && comment?.id) issueCommentReactionCreate(comment?.id, reaction); - }; - - const handleCommentReactionRemove = (reaction: string) => { - if (issueCommentReactionRemove && comment?.id) issueCommentReactionRemove(comment?.id, reaction); - }; - - useSWR( - workspaceSlug && projectId && issueId && comment && comment?.id - ? `ISSUE+PEEK_OVERVIEW_COMMENT_${comment?.id}` - : null, - () => { - if (workspaceSlug && projectId && issueId && comment && comment.id) { - issueDetailStore.fetchIssueCommentReactions(workspaceSlug, projectId, issueId, comment?.id); - } - } - ); - - let issueReactions = issueDetailStore?.getIssueCommentReactions || null; - issueReactions = issueReactions && comment.id ? issueReactions?.[comment.id] : []; - - return ( -
- -
- ); -}); diff --git a/web/components/issues/peek-overview/activity/index.ts b/web/components/issues/peek-overview/activity/index.ts deleted file mode 100644 index 705c5a33638..00000000000 --- a/web/components/issues/peek-overview/activity/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./card"; -export * from "./comment-card"; -export * from "./comment-editor"; -export * from "./comment-reaction"; -export * from "./view"; diff --git a/web/components/issues/peek-overview/activity/view.tsx b/web/components/issues/peek-overview/activity/view.tsx deleted file mode 100644 index 9abbfe2ab88..00000000000 --- a/web/components/issues/peek-overview/activity/view.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { FC } from "react"; -// components -import { IssueActivityCard, IssueCommentEditor } from "components/issues"; -// types -import { IIssueActivity, IUser } from "types"; - -type Props = { - workspaceSlug: string; - projectId: string; - issueId: string; - user: IUser | null; - issueActivity: IIssueActivity[] | null; - issueCommentCreate: (comment: any) => void; - issueCommentUpdate: (comment: any) => void; - issueCommentRemove: (commentId: string) => void; - issueCommentReactionCreate: (commentId: string, reaction: string) => void; - issueCommentReactionRemove: (commentId: string, reaction: string) => void; - showCommentAccessSpecifier: boolean; -}; - -export const IssueActivity: FC = (props) => { - const { - workspaceSlug, - projectId, - issueId, - user, - issueActivity, - issueCommentCreate, - issueCommentUpdate, - issueCommentRemove, - issueCommentReactionCreate, - issueCommentReactionRemove, - showCommentAccessSpecifier, - } = props; - - const handleAddComment = async (formData: any) => { - if (!formData.comment_html) return; - await issueCommentCreate(formData); - }; - - return ( -
-
Activity
- -
- - -
-
- ); -}; diff --git a/web/components/issues/peek-overview/index.ts b/web/components/issues/peek-overview/index.ts index 38581dada23..6d602e45b80 100644 --- a/web/components/issues/peek-overview/index.ts +++ b/web/components/issues/peek-overview/index.ts @@ -1,5 +1,3 @@ -export * from "./activity"; -export * from "./reactions"; export * from "./issue-detail"; export * from "./properties"; export * from "./root"; diff --git a/web/components/issues/peek-overview/issue-detail.tsx b/web/components/issues/peek-overview/issue-detail.tsx index d8a88cff7e6..fefba171300 100644 --- a/web/components/issues/peek-overview/issue-detail.tsx +++ b/web/components/issues/peek-overview/issue-detail.tsx @@ -1,217 +1,56 @@ -import { ChangeEvent, FC, useCallback, useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import debounce from "lodash/debounce"; -// packages -import { RichTextEditor } from "@plane/rich-text-editor"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; +import { FC } from "react"; // hooks -import useReloadConfirmations from "hooks/use-reload-confirmation"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; +import { useIssueDetail, useProject, useUser } from "hooks/store"; // components -import { IssuePeekOverviewReactions } from "components/issues"; -// ui -import { TextArea } from "@plane/ui"; -// types -import { IIssue, IUser } from "types"; -// services -import { FileService } from "services/file.service"; -// constants -import { EUserWorkspaceRoles } from "constants/workspace"; - -const fileService = new FileService(); +import { IssueDescriptionForm, TIssueOperations } from "components/issues"; +import { IssueReaction } from "../issue-detail/reactions"; interface IPeekOverviewIssueDetails { workspaceSlug: string; - issue: IIssue; - issueReactions: any; - user: IUser | null; - issueUpdate: (issue: Partial) => void; - issueReactionCreate: (reaction: string) => void; - issueReactionRemove: (reaction: string) => void; + projectId: string; + issueId: string; + issueOperations: TIssueOperations; + disabled: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } export const PeekOverviewIssueDetails: FC = (props) => { + const { workspaceSlug, projectId, issueId, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; + // store hooks + const { getProjectById } = useProject(); + const { currentUser } = useUser(); const { - workspaceSlug, - issue, - issueReactions, - user, - issueUpdate, - issueReactionCreate, - issueReactionRemove, - isSubmitting, - setIsSubmitting, - } = props; - // store - const { user: userStore } = useMobxStore(); - const { currentProjectRole } = userStore; - const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - // states - const [characterLimit, setCharacterLimit] = useState(false); - // hooks - const { setShowAlert } = useReloadConfirmations(); - const editorSuggestions = useEditorSuggestions(); - - const { - handleSubmit, - watch, - reset, - control, - formState: { errors }, - } = useForm({ - defaultValues: { - name: issue.name, - description_html: issue.description_html, - }, - }); - - const handleDescriptionFormSubmit = useCallback( - async (formData: Partial) => { - if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; - - await issueUpdate({ - ...issue, - name: formData.name ?? "", - description_html: formData.description_html ?? "

", - }); - }, - [issue, issueUpdate] - ); - - const [localTitleValue, setLocalTitleValue] = useState(""); - const [localIssueDescription, setLocalIssueDescription] = useState({ - id: issue.id, - description_html: issue.description_html, - }); - - // adding issue.description_html or issue.name to dependency array causes - // editor rerendering on every save - useEffect(() => { - if (issue.id) { - setLocalIssueDescription({ id: issue.id, description_html: issue.description_html }); - setLocalTitleValue(issue.name); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [issue.id]); // TODO: Verify the exhaustive-deps warning - - // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS - // TODO: Verify the exhaustive-deps warning - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedFormSave = useCallback( - debounce(async () => { - handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); - }, 1500), - [handleSubmit] - ); - - useEffect(() => { - if (isSubmitting === "submitted") { - setShowAlert(false); - setTimeout(async () => { - setIsSubmitting("saved"); - }, 2000); - } else if (isSubmitting === "submitting") { - setShowAlert(true); - } - }, [isSubmitting, setShowAlert, setIsSubmitting]); - - // reset form values - useEffect(() => { - if (!issue) return; - - reset({ - ...issue, - }); - }, [issue, reset]); + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + if (!issue) return <>; + const projectDetails = getProjectById(issue?.project_id); return ( <> - {issue?.project_detail?.identifier}-{issue?.sequence_id} + {projectDetails?.identifier}-{issue?.sequence_id} - -
- {isAllowed ? ( - ( -