diff --git a/.env.example b/.env.example index 90070de19..71a9074a6 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 4240c10c5..5d19be11c 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 b7ba11679..941fbef87 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 db65fbc2c..38694a62e 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 fd5d5ad03..296e965d7 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 29fbde453..9f6ab1bfb 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 5b5f958d3..47a85f3ba 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 5b96dbf6c..b509fd6f6 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 37178b398..42b0e32e5 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 cb2d1ca28..bd6684fd5 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 c04ee7771..a0e45416a 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 45d357442..3a9602a9e --- 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 0ec2e495c..efea53f87 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 b89c20874..8f62370ec 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 9d2da1254..a70b5f77c 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 837297219..744086783 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 a317b4776..120314ed3 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 fb989c4e6..53f4ccb1d 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 353779983..52a59f313 100644 --- a/apiserver/plane/analytics/apps.py +++ b/apiserver/plane/analytics/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class AnalyticsConfig(AppConfig): - name = 'plane.analytics' + name = "plane.analytics" diff --git a/apiserver/plane/api/apps.py b/apiserver/plane/api/apps.py index 292ad9344..6ba36e7e5 100644 --- a/apiserver/plane/api/apps.py +++ b/apiserver/plane/api/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig 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 1b2c03318..893df7f84 100644 --- a/apiserver/plane/api/middleware/api_authentication.py +++ b/apiserver/plane/api/middleware/api_authentication.py @@ -25,7 +25,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication): 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 @@ class APIKeyAuthentication(authentication.BaseAuthentication): # 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 f91e2d65d..b62936d8e 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 @@ class ApiKeyRateThrottle(SimpleRateThrottle): # Remove old histories while history and history[-1] <= now - self.duration: history.pop() - + # Calculate the requests num_requests = len(history) @@ -35,7 +36,7 @@ class ApiKeyRateThrottle(SimpleRateThrottle): 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 1fd1bce78..10b0182d6 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -13,5 +13,9 @@ from .issue import ( ) 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 b96422501..da8b96964 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -97,9 +97,11 @@ class BaseSerializer(serializers.ModelSerializer): 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 eaff8181a..6fc73a4bc 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -23,7 +23,9 @@ class CycleSerializer(BaseSerializer): 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 CycleIssueSerializer(BaseSerializer): 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 17ae8c1ed..78bb74d13 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 InboxIssueSerializer(BaseSerializer): "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 75396e9bb..4c8d6e815 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -27,6 +27,7 @@ from .module import ModuleSerializer, ModuleLiteSerializer from .user import UserLiteSerializer from .state import StateLiteSerializer + class IssueSerializer(BaseSerializer): assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField( @@ -66,14 +67,16 @@ class IssueSerializer(BaseSerializer): 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 @@ class IssueSerializer(BaseSerializer): 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 @@ class IssueSerializer(BaseSerializer): 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 @@ class IssueSerializer(BaseSerializer): ] 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 IssueLinkSerializer(BaseSerializer): # 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 IssueCommentSerializer(BaseSerializer): 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 ModuleIssueSerializer(BaseSerializer): 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 a96a9b54d..01a201064 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -52,7 +52,9 @@ class ModuleSerializer(BaseSerializer): 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 ModuleLinkSerializer(BaseSerializer): # 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 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 c394a080d..342cc1a81 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 ProjectSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "id", - 'emoji', + "emoji", "workspace", "created_at", "updated_at", @@ -59,12 +64,16 @@ class ProjectSerializer(BaseSerializer): 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 ProjectLiteSerializer(BaseSerializer): "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 9d08193d8..1649a7bcf 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 StateLiteSerializer(BaseSerializer): "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 42b6c3967..fe50021b5 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -13,4 +13,4 @@ class UserLiteSerializer(BaseSerializer): "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 c4c5caceb..a47de3d31 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -5,6 +5,7 @@ from .base import BaseSerializer class WorkspaceLiteSerializer(BaseSerializer): """Lite serializer with only required fields""" + class Meta: model = Workspace fields = [ @@ -12,4 +13,4 @@ class WorkspaceLiteSerializer(BaseSerializer): "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 a5ef0f5f1..84927439e 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -12,4 +12,4 @@ urlpatterns = [ *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 f557f8af0..593e501bf 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -32,4 +32,4 @@ urlpatterns = [ 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 3a2a57786..95eb68f3f 100644 --- a/apiserver/plane/api/urls/inbox.py +++ b/apiserver/plane/api/urls/inbox.py @@ -14,4 +14,4 @@ urlpatterns = [ 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 7117a9e8b..4309f44e9 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -23,4 +23,4 @@ urlpatterns = [ 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 c73e84c89..1ed450c86 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -3,7 +3,7 @@ from django.urls import path from plane.api.views import ProjectAPIEndpoint urlpatterns = [ - path( + path( "workspaces//projects/", ProjectAPIEndpoint.as_view(), name="project", @@ -13,4 +13,4 @@ urlpatterns = [ 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 0676ac5ad..b03f386e6 100644 --- a/apiserver/plane/api/urls/state.py +++ b/apiserver/plane/api/urls/state.py @@ -13,4 +13,4 @@ urlpatterns = [ 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 84d8dcabb..0da79566f 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -18,4 +18,4 @@ from .cycle import ( 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 abde4e8b0..b069ef78c 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 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) 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 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): 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 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @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 310332333..c296bb111 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -12,7 +12,13 @@ from rest_framework import status # 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 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ), ) ) - .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 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): # 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 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ) 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 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .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 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): 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 4f4cdc4ef..c1079345a 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -14,7 +14,14 @@ from rest_framework.response import Response 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 @@ class InboxIssueAPIEndpoint(BaseAPIView): ).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 @@ class InboxIssueAPIEndpoint(BaseAPIView): 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 @@ class InboxIssueAPIEndpoint(BaseAPIView): 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 @@ class InboxIssueAPIEndpoint(BaseAPIView): "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 @@ class InboxIssueAPIEndpoint(BaseAPIView): "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 @@ class InboxIssueAPIEndpoint(BaseAPIView): 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 @@ class InboxIssueAPIEndpoint(BaseAPIView): 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 1ac8ddcff..e91f2a5f6 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): # 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): .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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): # 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): # 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class LabelAPIEndpoint(BaseAPIView): 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 @@ class LabelAPIEndpoint(BaseAPIView): ).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 @@ class LabelAPIEndpoint(BaseAPIView): 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 @@ class IssueLinkAPIEndpoint(BaseAPIView): ) 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 @@ class IssueLinkAPIEndpoint(BaseAPIView): 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 @@ class IssueLinkAPIEndpoint(BaseAPIView): 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 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) 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 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class IssueActivityAPIEndpoint(BaseAPIView): ) .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 959b7ccc3..1a9a21a3c 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -55,7 +55,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): .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 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): ) 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 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .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 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): 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 e8dc9f5a9..cb1f7dc7b 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): # 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): ] ) - 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): ) 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): ) 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 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): 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 3d2861778..f931c2ed2 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -34,7 +34,9 @@ class StateAPIEndpoint(BaseAPIView): ) 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 @@ class StateAPIEndpoint(BaseAPIView): ) 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 @@ class StateAPIEndpoint(BaseAPIView): 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 ddabb4132..893df7f84 100644 --- a/apiserver/plane/app/middleware/api_authentication.py +++ b/apiserver/plane/app/middleware/api_authentication.py @@ -25,7 +25,10 @@ class APIKeyAuthentication(authentication.BaseAuthentication): 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 2298f3442..8e8793504 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 @@ from .project import ( ProjectMemberPermission, ProjectLitePermission, ) - - diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index c406453b7..0d72f9192 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -17,6 +17,7 @@ from .workspace import ( WorkspaceThemeSerializer, WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, + WorkspaceUserPropertiesSerializer, ) from .project import ( ProjectSerializer, @@ -31,14 +32,20 @@ from .project import ( 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 @@ from .module import ( ModuleIssueSerializer, ModuleLinkSerializer, ModuleFavoriteSerializer, + ModuleUserPropertiesSerializer, ) from .api import APITokenSerializer, APITokenReadSerializer @@ -85,20 +93,33 @@ from .integration import ( 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 08bb747d9..264a58f92 100644 --- a/apiserver/plane/app/serializers/api.py +++ b/apiserver/plane/app/serializers/api.py @@ -3,7 +3,6 @@ from plane.db.models import APIToken, APIActivityLog class APITokenSerializer(BaseSerializer): - class Meta: model = APIToken fields = "__all__" @@ -18,14 +17,12 @@ class APITokenSerializer(BaseSerializer): 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 89c9725d9..446fdb6d5 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -4,16 +4,17 @@ from rest_framework import serializers 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 @@ class DynamicBaseSerializer(BaseSerializer): # 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 @@ class DynamicBaseSerializer(BaseSerializer): 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 63abf3a03..77c3f16cc 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -7,7 +7,12 @@ from .user import UserLiteSerializer 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 @@ class CycleWriteSerializer(BaseSerializer): 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 CycleWriteSerializer(BaseSerializer): 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 @@ class CycleSerializer(BaseSerializer): 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 CycleFavoriteSerializer(BaseSerializer): "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 000000000..8fca3c906 --- /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 2c2f26e4e..675390080 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 EstimateSerializer(BaseSerializer): 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 EstimatePointSerializer(BaseSerializer): 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 EstimateReadSerializer(BaseSerializer): "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 5c78cfa69..2dd850fd3 100644 --- a/apiserver/plane/app/serializers/exporter.py +++ b/apiserver/plane/app/serializers/exporter.py @@ -5,7 +5,9 @@ from .user import UserLiteSerializer 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 8997f6392..c058994d6 100644 --- a/apiserver/plane/app/serializers/importer.py +++ b/apiserver/plane/app/serializers/importer.py @@ -7,9 +7,13 @@ from plane.db.models import Importer 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 f52a90660..1dc6f1f4a 100644 --- a/apiserver/plane/app/serializers/inbox.py +++ b/apiserver/plane/app/serializers/inbox.py @@ -46,10 +46,13 @@ class InboxIssueLiteSerializer(BaseSerializer): 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 6f6543b9e..01e484ed0 100644 --- a/apiserver/plane/app/serializers/integration/base.py +++ b/apiserver/plane/app/serializers/integration/base.py @@ -13,7 +13,9 @@ class IntegrationSerializer(BaseSerializer): 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 b13d03e35..be98bc312 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -30,6 +30,8 @@ from plane.db.models import ( CommentReaction, IssueVote, IssueRelation, + State, + Project, ) @@ -69,19 +71,26 @@ class IssueProjectLiteSerializer(BaseSerializer): ##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()), + # ids + state_id = serializers.PrimaryKeyRelatedField( + source="state", + queryset=State.objects.all(), + required=False, + allow_null=True, + ) + 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, ) - - labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) @@ -100,8 +109,10 @@ class IssueCreateSerializer(BaseSerializer): 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 @@ class IssueCreateSerializer(BaseSerializer): 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 @@ class IssueCreateSerializer(BaseSerializer): 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 IssuePropertySerializer(BaseSerializer): 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 LabelLiteSerializer(BaseSerializer): class IssueLabelSerializer(BaseSerializer): - class Meta: model = IssueLabel fields = "__all__" @@ -279,33 +297,50 @@ class IssueLabelSerializer(BaseSerializer): 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 IssueLinkSerializer(BaseSerializer): # 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 IssueAttachmentSerializer(BaseSerializer): class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - + class Meta: model = IssueReaction fields = "__all__" @@ -438,19 +473,6 @@ class IssueReactionSerializer(BaseSerializer): ] -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 CommentReactionSerializer(BaseSerializer): 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 IssueStateFlatSerializer(BaseSerializer): # 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 IssueStateSerializer(DynamicBaseSerializer): 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 IssueLiteSerializer(DynamicBaseSerializer): 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 IssuePublicSerializer(BaseSerializer): 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 48f773b0f..e94195671 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 @@ from plane.db.models import ( 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 ModuleWriteSerializer(BaseSerializer): "created_at", "updated_at", ] - + def to_representation(self, instance): data = super().to_representation(instance) - data['members'] = [str(member.id) for member in instance.members.all()] + 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 ModuleLinkSerializer(BaseSerializer): # 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 @@ class ModuleLinkSerializer(BaseSerializer): 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 ModuleFavoriteSerializer(BaseSerializer): "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 b6a4f3e4a..2152fcf0f 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 ff152627a..a0f5986d6 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -6,19 +6,31 @@ from .base import BaseSerializer 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 PageSerializer(BaseSerializer): "project", "owned_by", ] + def to_representation(self, instance): data = super().to_representation(instance) - data['labels'] = [str(label.id) for label in instance.labels.all()] + data["labels"] = [str(label.id) for label in instance.labels.all()] return data def create(self, validated_data): @@ -94,7 +107,7 @@ class SubPageSerializer(BaseSerializer): 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 @@ class SubPageSerializer(BaseSerializer): 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 aef715e33..999233442 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -4,7 +4,10 @@ from rest_framework import serializers # 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 @@ from plane.db.models import ( 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 ProjectSerializer(BaseSerializer): 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 @@ class ProjectSerializer(BaseSerializer): 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 ProjectMemberAdminSerializer(BaseSerializer): 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 ProjectMemberLiteSerializer(BaseSerializer): 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 ProjectPublicMemberSerializer(BaseSerializer): "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 323254f26..773d8e461 100644 --- a/apiserver/plane/app/serializers/state.py +++ b/apiserver/plane/app/serializers/state.py @@ -6,10 +6,19 @@ from plane.db.models import State 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 StateLiteSerializer(BaseSerializer): "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 1b94758e8..8cd48827e 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -99,7 +99,9 @@ class UserMeSettingsSerializer(BaseSerializer): ).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 @@ class UserMeSettingsSerializer(BaseSerializer): 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 @@ class ChangePasswordSerializer(serializers.Serializer): 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 e7502609a..f864f2b6c 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 @@ from plane.utils.issue_filters import issue_filters 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 @@ class GlobalViewSerializer(BaseSerializer): 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 961466d28..95ca149ff 100644 --- a/apiserver/plane/app/serializers/webhook.py +++ b/apiserver/plane/app/serializers/webhook.py @@ -10,78 +10,113 @@ from rest_framework import serializers # 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 WebhookSerializer(DynamicBaseSerializer): 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 f0ad4b4ab..69f827c24 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 @@ from plane.db.models import ( 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 WorkSpaceSerializer(BaseSerializer): "owner", ] + class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace @@ -61,8 +63,7 @@ class WorkspaceLiteSerializer(BaseSerializer): 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 WorkSpaceMemberSerializer(BaseSerializer): 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 WorkSpaceMemberInviteSerializer(BaseSerializer): 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 @@ class TeamSerializer(BaseSerializer): 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 WorkspaceThemeSerializer(BaseSerializer): "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 d8334ed57..f2b11f127 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -3,6 +3,7 @@ from .asset import urlpatterns as asset_urls 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 @@ urlpatterns = [ *authentication_urls, *configuration_urls, *cycle_urls, + *dashboard_urls, *estimate_urls, *external_urls, *importer_urls, @@ -45,4 +47,4 @@ urlpatterns = [ *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 39986f791..e91e5706b 100644 --- a/apiserver/plane/app/urls/authentication.py +++ b/apiserver/plane/app/urls/authentication.py @@ -31,8 +31,14 @@ urlpatterns = [ 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 @@ urlpatterns = [ ), # 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 12beb63aa..3ea825eb2 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 @@ urlpatterns = [ 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 46e6a5e84..740b0ab43 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -7,6 +7,7 @@ from plane.app.views import ( CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, ) @@ -44,7 +45,7 @@ urlpatterns = [ name="project-issue-cycle", ), path( - "workspaces//projects//cycles//cycle-issues//", + "workspaces//projects//cycles//cycle-issues//", CycleIssueViewSet.as_view( { "get": "retrieve", @@ -84,4 +85,9 @@ urlpatterns = [ 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 000000000..0dc24a808 --- /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 16ea40b21..e9ec4e335 100644 --- a/apiserver/plane/app/urls/inbox.py +++ b/apiserver/plane/app/urls/inbox.py @@ -40,7 +40,7 @@ urlpatterns = [ 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 971fbc395..234c2824d 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -235,7 +235,7 @@ urlpatterns = [ ## 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 @@ urlpatterns = [ "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 5507b3a37..5e9f4f123 100644 --- a/apiserver/plane/app/urls/module.py +++ b/apiserver/plane/app/urls/module.py @@ -7,6 +7,7 @@ from plane.app.views import ( ModuleLinkViewSet, ModuleFavoriteViewSet, BulkImportModulesEndpoint, + ModuleUserPropertiesEndpoint, ) @@ -34,17 +35,26 @@ urlpatterns = [ 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 @@ urlpatterns = [ 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 0c96e5f15..0bbf4f3c7 100644 --- a/apiserver/plane/app/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -5,6 +5,7 @@ from plane.app.views import ( NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) @@ -63,4 +64,9 @@ urlpatterns = [ ), 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 39456a830..f8ecac4c0 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -175,4 +175,4 @@ urlpatterns = [ ), 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 3d45b627a..36372c03a 100644 --- a/apiserver/plane/app/urls/views.py +++ b/apiserver/plane/app/urls/views.py @@ -5,7 +5,7 @@ from plane.app.views import ( IssueViewViewSet, GlobalViewViewSet, GlobalViewIssuesViewSet, - IssueViewFavoriteViewSet, + IssueViewFavoriteViewSet, ) diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 2c3638842..7e64e586a 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -18,6 +18,10 @@ from plane.app.views import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceStatesEndpoint, + WorkspaceEstimatesEndpoint, ) @@ -92,6 +96,11 @@ urlpatterns = [ 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 @@ urlpatterns = [ 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 c6e6183fa..2a47285aa 100644 --- a/apiserver/plane/app/urls_deprecated.py +++ b/apiserver/plane/app/urls_deprecated.py @@ -192,7 +192,7 @@ from plane.app.views import ( ) -#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 @@ urlpatterns = [ 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ # 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 @@ urlpatterns = [ "post": "unarchive", } ), - name="project-page-unarchive" + name="project-page-unarchive", ), path( "workspaces//projects//archived-pages/", @@ -1264,19 +1272,22 @@ urlpatterns = [ { "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 @@ urlpatterns = [ ## 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 c122dce9f..0a959a667 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -45,6 +45,10 @@ from .workspace import ( WorkspaceUserProfileEndpoint, WorkspaceUserProfileIssuesEndpoint, WorkspaceLabelsEndpoint, + WorkspaceProjectMemberEndpoint, + WorkspaceUserPropertiesEndpoint, + WorkspaceStatesEndpoint, + WorkspaceEstimatesEndpoint, ) from .state import StateViewSet from .view import ( @@ -59,6 +63,7 @@ from .cycle import ( CycleDateCheckEndpoint, CycleFavoriteViewSet, TransferCycleIssueEndpoint, + CycleUserPropertiesEndpoint, ) from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue import ( @@ -103,6 +108,7 @@ from .module import ( ModuleIssueViewSet, ModuleLinkViewSet, ModuleFavoriteViewSet, + ModuleUserPropertiesEndpoint, ) from .api import ApiTokenEndpoint @@ -136,7 +142,11 @@ from .page import ( 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 @@ from .notification import ( 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 c1deb0d8f..04a77f789 100644 --- a/apiserver/plane/app/views/analytic.py +++ b/apiserver/plane/app/views/analytic.py @@ -61,7 +61,9 @@ class AnalyticsEndpoint(BaseAPIView): ) # 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 @@ class AnalyticsEndpoint(BaseAPIView): 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 @@ class AnalyticsEndpoint(BaseAPIView): ) 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 @@ class AnalyticViewViewset(BaseViewSet): 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 @@ class ExportAnalyticsEndpoint(BaseAPIView): ) # 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 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ) 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 @@ class DefaultAnalyticsEndpoint(BaseAPIView): .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 ce2d4bd09..86a29c7fa 100644 --- a/apiserver/plane/app/views/api.py +++ b/apiserver/plane/app/views/api.py @@ -71,7 +71,9 @@ class ApiTokenEndpoint(BaseAPIView): 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 17d70d936..fb5590610 100644 --- a/apiserver/plane/app/views/asset.py +++ b/apiserver/plane/app/views/asset.py @@ -10,7 +10,11 @@ from plane.app.serializers import FileAssetSerializer 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 @@ class FileAssetEndpoint(BaseAPIView): 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 @@ class FileAssetEndpoint(BaseAPIView): 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 @@ class FileAssetEndpoint(BaseAPIView): 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 @@ class UserAssetsEndpoint(BaseAPIView): 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 049e5aab9..501f47657 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -128,7 +128,8 @@ class ForgotPasswordEndpoint(BaseAPIView): 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 @@ class ResetPasswordEndpoint(BaseAPIView): } 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 @@ class ChangePasswordEndpoint(BaseAPIView): 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 @@ class SetUserPasswordEndpoint(BaseAPIView): # 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 @@ class MagicGenerateEndpoint(BaseAPIView): 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 @@ class EmailCheckEndpoint(BaseAPIView): 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 @@ class EmailCheckEndpoint(BaseAPIView): 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 @@ class EmailCheckEndpoint(BaseAPIView): 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 @@ class EmailCheckEndpoint(BaseAPIView): 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 256446313..a41200d61 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -73,7 +73,7 @@ class SignUpEndpoint(BaseAPIView): # get configuration values # Get configuration values - ENABLE_SIGNUP, = get_configuration_value( + (ENABLE_SIGNUP,) = get_configuration_value( [ { "key": "ENABLE_SIGNUP", @@ -173,7 +173,7 @@ class SignInEndpoint(BaseAPIView): # Create the user else: - ENABLE_SIGNUP, = get_configuration_value( + (ENABLE_SIGNUP,) = get_configuration_value( [ { "key": "ENABLE_SIGNUP", @@ -325,7 +325,7 @@ class MagicSignInEndpoint(BaseAPIView): ) 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 @@ class MagicSignInEndpoint(BaseAPIView): 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 @@ class MagicSignInEndpoint(BaseAPIView): 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 32449597b..e07cb811c 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 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): 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 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): 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 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) 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 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): 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 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) 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 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @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 c53b30495..29b4bbf8b 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 @@ class ConfigurationEndpoint(BaseAPIView): 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 @@ class ConfigurationEndpoint(BaseAPIView): 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 self managed - data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) + # 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 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 02f259de3..23a227fef 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -14,7 +14,7 @@ from django.db.models import ( Case, When, Value, - CharField + CharField, ) from django.core import serializers from django.utils import timezone @@ -31,10 +31,15 @@ from plane.app.serializers import ( 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 @@ from plane.db.models import ( 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ), ) ) - .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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), - then=Value("CURRENT") + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), ), When( - start_date__gt=timezone.now(), - then=Value("UPCOMING") - ), - 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): def list(self, request, slug, project_id): queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] queryset = queryset.order_by("-is_favorite", "-created_at") @@ -298,7 +313,9 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def partial_update(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): @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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): .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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): } ), 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 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): } ), 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 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) 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 000000000..47fae2c9c --- /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 8f14b230b..3402bb068 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 @@ class BulkEstimatePointEndpoint(BaseViewSet): ) 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 @@ class BulkEstimatePointEndpoint(BaseViewSet): 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 @@ class BulkEstimatePointEndpoint(BaseViewSet): 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 b709a599d..179de81f9 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 @@ class ExportIssuesEndpoint(BaseAPIView): 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 97d509c1e..618c65e3c 100644 --- a/apiserver/plane/app/views/external.py +++ b/apiserver/plane/app/views/external.py @@ -14,7 +14,10 @@ from django.conf import settings 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 @@ class GPTIntegrationEndpoint(BaseAPIView): 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 @@ class ReleaseNotesEndpoint(BaseAPIView): 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 b99d663e2..a15ed36b7 100644 --- a/apiserver/plane/app/views/importer.py +++ b/apiserver/plane/app/views/importer.py @@ -35,14 +35,16 @@ from plane.app.serializers import ( 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 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView): 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 @@ class ImportServiceEndpoint(BaseAPIView): 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 @@ class ImportServiceEndpoint(BaseAPIView): 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 @@ class BulkImportIssuesEndpoint(BaseAPIView): ).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 @@ class BulkImportIssuesEndpoint(BaseAPIView): 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 @@ class BulkImportIssuesEndpoint(BaseAPIView): 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 @@ class BulkImportModulesEndpoint(BaseAPIView): 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 @@ class BulkImportModulesEndpoint(BaseAPIView): 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 331ee2175..01eee78e3 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -62,7 +62,9 @@ class InboxViewSet(BaseViewSet): 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,49 +88,14 @@ 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") - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .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") - ) + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_inbox", @@ -137,8 +104,35 @@ class InboxIssueViewSet(BaseViewSet): ), ) ) - ) - issues_data = IssueStateInboxSerializer(issues, many=True).data + .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") + ) + ).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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): "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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): "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 @@ class InboxIssueViewSet(BaseViewSet): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_serializer.save() else: @@ -285,7 +293,9 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): 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 @@ class InboxIssueViewSet(BaseViewSet): 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 b82957dfb..d757fe471 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 @@ from plane.db.models import ( 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.utils.integrations.github import ( 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 @@ class WorkspaceIntegrationViewSet(BaseViewSet): 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 @@ class WorkspaceIntegrationViewSet(BaseViewSet): 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 29b7a9b2f..2d37c64b0 100644 --- a/apiserver/plane/app/views/integration/github.py +++ b/apiserver/plane/app/views/integration/github.py @@ -21,7 +21,10 @@ from plane.app.serializers import ( 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 @@ class BulkCreateGithubIssueSyncEndpoint(BaseAPIView): 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 3f18a2ab2..410e6b332 100644 --- a/apiserver/plane/app/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -8,9 +8,16 @@ from sentry_sdk import capture_exception # 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 @@ class SlackProjectSyncViewSet(BaseViewSet): 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 @@ class SlackProjectSyncViewSet(BaseViewSet): 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 @@ class SlackProjectSyncViewSet(BaseViewSet): 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 @@ class SlackProjectSyncViewSet(BaseViewSet): ) 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 d489629ba..0b5c612d3 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -34,11 +34,11 @@ from rest_framework.parsers import MultiPartParser, FormParser # 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 @@ from plane.app.serializers import ( ProjectMemberLiteSerializer, IssueReactionSerializer, CommentReactionSerializer, - IssueVoteSerializer, IssueRelationSerializer, RelatedIssueSerializer, - IssuePublicSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -81,6 +79,7 @@ from plane.db.models import ( 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): .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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): # 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueViewSet(WebhookMixin, BaseViewSet): 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 @@ class UserWorkSpaceIssues(BaseAPIView): 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 @@ class UserWorkSpaceIssues(BaseAPIView): 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 @@ class UserWorkSpaceIssues(BaseAPIView): .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 @@ class UserWorkSpaceIssues(BaseAPIView): # 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 @@ class UserWorkSpaceIssues(BaseAPIView): 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 @@ class IssueActivityEndpoint(BaseAPIView): ) ) ) - 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 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): 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 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): 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 @@ class LabelViewSet(BaseViewSet): 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 @@ class SubIssuesEndpoint(BaseAPIView): .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 @@ class SubIssuesEndpoint(BaseAPIView): .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 @@ class SubIssuesEndpoint(BaseAPIView): 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") - ) + # 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)) - result = { - item["state_group"]: item["state_count"] for item in state_distribution - } - - serializer = IssueLiteSerializer( + serializer = IssueSerializer( sub_issues, many=True, ) @@ -758,7 +843,9 @@ class SubIssuesEndpoint(BaseAPIView): _ = 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 @@ class SubIssuesEndpoint(BaseAPIView): 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 @@ class IssueLinkViewSet(BaseViewSet): ) 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 @@ class IssueLinkViewSet(BaseViewSet): 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 @@ class IssueLinkViewSet(BaseViewSet): 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 @@ class IssueAttachmentEndpoint(BaseAPIView): 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 @@ class IssueAttachmentEndpoint(BaseAPIView): 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,31 +1082,9 @@ class IssueArchiveViewSet(BaseViewSet): .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") - ) - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - filters = issue_filters(request.query_params, "GET") - show_sub_issues = request.GET.get("show_sub_issues", "true") - - # 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")) + .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() @@ -993,17 +1092,56 @@ class IssueArchiveViewSet(BaseViewSet): .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") ) ) + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + filters = issue_filters(request.query_params, "GET") + show_sub_issues = request.GET.get("show_sub_issues", "true") + + # 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( @@ -1051,7 +1189,9 @@ class IssueArchiveViewSet(BaseViewSet): 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 @@ class IssueArchiveViewSet(BaseViewSet): 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 @@ class IssueArchiveViewSet(BaseViewSet): 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 @@ class IssueSubscriberViewSet(BaseViewSet): ) 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 @@ class IssueSubscriberViewSet(BaseViewSet): 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 @@ class IssueReactionViewSet(BaseViewSet): 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 @@ class IssueReactionViewSet(BaseViewSet): } ), 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 @@ class CommentReactionViewSet(BaseViewSet): 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 @@ class CommentReactionViewSet(BaseViewSet): } ), 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 @@ class IssueRelationViewSet(BaseViewSet): .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 @@ class IssueRelationViewSet(BaseViewSet): 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 @@ class IssueRelationViewSet(BaseViewSet): 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 @@ class IssueRelationViewSet(BaseViewSet): 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,36 +1679,15 @@ class IssueDraftViewSet(BaseViewSet): .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"), ) ) - ) - - @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] - - # 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() @@ -1484,17 +1695,55 @@ class IssueDraftViewSet(BaseViewSet): .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") ) ) + @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 + ] + + # 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( @@ -1542,14 +1791,17 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): # 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 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): 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 @@ class IssueDraftViewSet(BaseViewSet): 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 a8a8655c3..1f055129a 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -7,6 +7,8 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.core.serializers.json import DjangoJSONEncoder + # Third party imports from rest_framework.response import Response @@ -20,9 +22,13 @@ from plane.app.serializers import ( 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 @@ from plane.db.models import ( ModuleFavorite, IssueLink, IssueAttachment, + IssueSubscriber, + ModuleUserProperties, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -54,7 +62,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) def get_queryset(self): - subquery = ModuleFavorite.objects.filter( user=self.request.user, module_id=OuterRef("pk"), @@ -74,7 +81,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) - .order_by("-is_favorite","-created_at") + .order_by("-is_favorite", "-created_at") ) def create(self, request, slug, project_id): @@ -153,6 +162,18 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): 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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): 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 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) def destroy(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) - ) - 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()), + 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,53 +331,18 @@ 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") - ) - .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) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + def get_queryset(self): + 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") ) - .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) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("labels", "assignees") + .prefetch_related('issue_module__module') + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -353,114 +350,144 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .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") ) - ) - 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) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ).distinct() - def create(self, request, slug, project_id, module_id): + @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 + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # create multiple issues inside a module + def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) if not len(issues): 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) + - ModuleIssue.objects.bulk_update( - records_to_update, - ["module"], + # 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, + ) + + 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 @@ class ModuleFavoriteViewSet(BaseViewSet): 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 9494ea86c..ebe8e5082 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 @@ from plane.db.models import ( 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 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # 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 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # 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 @@ class NotificationViewSet(BaseViewSet, BasePaginator): 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 @@ class MarkAllReadNotificationViewSet(BaseViewSet): # 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 @@ class MarkAllReadNotificationViewSet(BaseViewSet): 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 @@ class MarkAllReadNotificationViewSet(BaseViewSet): 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 @@ class MarkAllReadNotificationViewSet(BaseViewSet): 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 9bd1f1dd4..1d8ff1fbb 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.db.models import Exists, OuterRef, Q 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 @@ class PageViewSet(BaseViewSet): 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 @@ class PageViewSet(BaseViewSet): 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 @@ class PageViewSet(BaseViewSet): 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 @@ class PageViewSet(BaseViewSet): 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 @@ class PageViewSet(BaseViewSet): 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 5b88e3652..5d2f95673 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -36,6 +36,7 @@ from plane.app.serializers import ( ProjectFavoriteSerializer, ProjectDeployBoardSerializer, ProjectMemberAdminSerializer, + ProjectMemberRoleSerializer, ) from plane.app.permissions import ( @@ -67,7 +68,7 @@ from plane.bgtasks.project_invitation_task import project_invitation 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): # 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ] ) - 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): 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 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ) 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 @@ class ProjectInvitationsViewset(BaseViewSet): # 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 @@ class ProjectJoinEndpoint(BaseAPIView): _ = 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 @@ class ProjectMemberViewSet(BaseViewSet): .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 @@ class ProjectMemberViewSet(BaseViewSet): 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 @@ class ProjectMemberViewSet(BaseViewSet): ) ) - # 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 @@ class ProjectMemberViewSet(BaseViewSet): 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 @@ class ProjectMemberViewSet(BaseViewSet): 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 @@ class ProjectMemberViewSet(BaseViewSet): > 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 @@ class ProjectMemberViewSet(BaseViewSet): # 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 @@ class AddTeamToProjectEndpoint(BaseAPIView): 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 @@ class ProjectIdentifierEndpoint(BaseAPIView): 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 @@ class ProjectIdentifierEndpoint(BaseAPIView): if name == "": return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): - return Response( - {"error": "Cannot delete an identifier of an existing project"}, + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST, ) - ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() + if Project.objects.filter( + identifier=name, workspace__slug=slug + ).exists(): + return Response( + { + "error": "Cannot delete an identifier of an existing project" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectIdentifier.objects.filter( + name=name, workspace__slug=slug + ).delete() return Response( status=status.HTTP_204_NO_CONTENT, @@ -939,7 +971,9 @@ class ProjectUserViewsEndpoint(BaseAPIView): ).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 @@ class ProjectUserViewsEndpoint(BaseAPIView): 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 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): 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 @@ class UserProjectRolesEndpoint(BaseAPIView): ).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 4ecb71127..13acabfe8 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -10,7 +10,15 @@ from rest_framework.response import Response # 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class GlobalSearchEndpoint(BaseAPIView): 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 @@ class IssueSearchEndpoint(BaseAPIView): 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 @@ class IssueSearchEndpoint(BaseAPIView): 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 f7226ba6e..242061e18 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -9,9 +9,12 @@ from rest_framework.response import Response 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 @@ class StateViewSet(BaseViewSet): ) 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 008780526..7764e3b97 100644 --- a/apiserver/plane/app/views/user.py +++ b/apiserver/plane/app/views/user.py @@ -43,7 +43,9 @@ class UserEndpoint(BaseViewSet): 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 @@ class UserEndpoint(BaseViewSet): # 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 @@ class UserEndpoint(BaseViewSet): ).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 @@ class UserEndpoint(BaseViewSet): ).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 @@ class UserEndpoint(BaseViewSet): ) 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 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): 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 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView): 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 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): issue_activities, many=True ).data, ) - diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index eb76407b7..27f31f7a9 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -24,10 +24,15 @@ from . import BaseViewSet, BaseAPIView 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 @@ from plane.db.models import ( 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 @@ class GlobalViewViewSet(BaseViewSet): 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 @@ class GlobalViewIssuesViewSet(BaseViewSet): @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 @@ class GlobalViewIssuesViewSet(BaseViewSet): .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 @@ class GlobalViewIssuesViewSet(BaseViewSet): .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 @@ class GlobalViewIssuesViewSet(BaseViewSet): # 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 @@ class GlobalViewIssuesViewSet(BaseViewSet): 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 @@ class IssueViewViewSet(BaseViewSet): .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 @@ class IssueViewFavoriteViewSet(BaseViewSet): 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 48608d583..fe69cd7e6 100644 --- a/apiserver/plane/app/views/webhook.py +++ b/apiserver/plane/app/views/webhook.py @@ -26,8 +26,12 @@ class WebhookEndpoint(BaseAPIView): ) 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 11170114a..f4d3dbbb5 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -41,13 +41,19 @@ from plane.app.serializers import ( 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 @@ from plane.db.models import ( WorkspaceMember, CycleIssue, IssueReaction, + WorkspaceUserProperties, + Estimate, + EstimatePoint, ) from plane.app.permissions import ( WorkSpaceBasePermission, @@ -71,11 +80,13 @@ from plane.app.permissions import ( 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 @@ class WorkSpaceViewSet(BaseViewSet): .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 @@ class WorkSpaceViewSet(BaseViewSet): 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 @@ class WorkSpaceViewSet(BaseViewSet): 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 @@ class UserWorkSpacesEndpoint(BaseAPIView): .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 @@ class WorkspaceInvitationsViewset(BaseViewSet): # 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 @@ class WorkspaceJoinEndpoint(BaseAPIView): # 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 @@ class WorkSpaceMemberViewSet(BaseViewSet): 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 @@ class WorkSpaceMemberViewSet(BaseViewSet): > 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 @@ class WorkSpaceMemberViewSet(BaseViewSet): 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 @@ class WorkSpaceMemberViewSet(BaseViewSet): 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 @@ class TeamMemberViewSet(BaseViewSet): ) 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 @@ class TeamMemberViewSet(BaseViewSet): 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 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): 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,21 +1345,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): project__project_projectmember__member=request.user, ) .filter(**filters) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=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") + .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() @@ -1268,17 +1355,30 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .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") + ) + .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 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): 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 @@ class WorkspaceLabelsEndpoint(BaseAPIView): 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 a4f5b194c..778956229 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 03d29f3e0..7f6ca38f0 100644 --- a/apiserver/plane/bgtasks/apps.py +++ b/apiserver/plane/bgtasks/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig 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 000000000..713835033 --- /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 7d26dd4ab..82a8281a9 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 e895b859d..b99e4b1d9 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 30b638c84..d408c6476 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -19,7 +19,8 @@ from plane.db.models import ExporterHistory 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 339d24583..e372355ef 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -14,10 +14,10 @@ from plane.db.models import FileAsset @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 d790f845d..a2ac62927 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -21,7 +21,7 @@ from plane.license.utils.instance_value import get_email_configuration 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 84d10ecd3..421521363 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -24,8 +24,8 @@ from plane.db.models import ( 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 5d4c0650c..b9f6bd411 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,9 +24,11 @@ from plane.db.models import ( 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 6a09b08ba..974a545fc 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 bb61e0ada..b94ec4bfe 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 4bc27d3ee..6cfbec72a 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -10,9 +10,12 @@ from plane.db.models import ( User, IssueAssignee, Issue, + State, + EmailNotificationLog, Notification, IssueComment, - IssueActivity + IssueActivity, + UserNotificationPreference, ) # Third Party imports @@ -20,8 +23,8 @@ from celery import shared_task 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 - - 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 - - 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 = [] - issue = Issue.objects.filter(pk=issue_id).first() + """ + 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 + """ - 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 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, + ) - 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: - _ = IssueSubscriber.objects.get_or_create( - project_id=project_id, issue_id=issue_id, subscriber_id=actor_id - ) - except Exception as e: - pass + comment_mentions = [] + all_comment_mentions = [] - project = Project.objects.get(pk=project_id) - - issue_subscribers = list(set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)}) - - for subscriber in issue_subscribers: - if subscriber in issue_subscribers: - sender = "in_app:issue_activities:subscribed" - if issue.created_by_id is not None and subscriber == issue.created_by_id: - sender = "in_app:issue_activities:created" - if subscriber in issue_assignees: - sender = "in_app:issue_activities:assigned" + # 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: - 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 "" - ), - }, - }, + # 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) + ) - # Add Mentioned as Issue Subscribers - IssueSubscriber.objects.bulk_create( - mention_subscribers + comment_mention_subscribers, batch_size=100) + issue = Issue.objects.filter(pk=issue_id).first() - 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 + 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 ) - bulk_notifications.append(notification) - + except Exception as e: + pass - 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) + project = Project.objects.get(pk=project_id) + + issue_assignees = IssueAssignee.objects.filter( + issue_id=issue_id, project_id=project_id + ).values_list("assignee", flat=True) + + issue_subscribers = list( + set(issue_subscribers) - {uuid.UUID(actor_id)} + ) + + 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" + + preference = UserNotificationPreference.objects.get( + user_id=subscriber + ) + + 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 + ) + + # 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 b9221855b..a986de332 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -15,6 +15,7 @@ from sentry_sdk import capture_exception 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 33f4b5686..000000000 --- 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 3681f002d..34bba0cf8 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 7039cb875..06dd6e8cd 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 442e72836..0912e276a 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -2,6 +2,7 @@ import os 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 @@ app.conf.beat_schedule = { "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 054523bf9..bdd0b7014 100644 --- a/apiserver/plane/db/management/commands/create_bucket.py +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -5,7 +5,8 @@ from botocore.exceptions import ClientError # 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 @@ class Command(BaseCommand): 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 @@ class Command(BaseCommand): 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 a5b4c9cc8..d48c24b1c 100644 --- a/apiserver/plane/db/management/commands/reset_password.py +++ b/apiserver/plane/db/management/commands/reset_password.py @@ -35,7 +35,7 @@ class Command(BaseCommand): # 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 @@ class Command(BaseCommand): 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 365452a7a..ec971f83a 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 @@ import time 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 000000000..51f2cf339 --- /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 dd158f0a8..936d33fa5 100644 --- a/apiserver/plane/db/migrations/0001_initial.py +++ b/apiserver/plane/db/migrations/0001_initial.py @@ -10,695 +10,2481 @@ import uuid 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 9c25c4518..d69ef1a71 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 @@ import django.db.models.deletion 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 3adac35a7..763d52eb6 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 @@ import django.db.models.deletion 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 0d4616aea..f3489449c 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 @@ from django.db import migrations, models 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 14c280e26..8ab63a22a 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 @@ from django.db import migrations, models 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 f49e263fb..3121f4fe5 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 @@ from django.db import migrations, models 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 03e660473..6e67a3c94 100644 --- a/apiserver/plane/db/migrations/0007_label_parent.py +++ b/apiserver/plane/db/migrations/0007_label_parent.py @@ -5,15 +5,20 @@ import django.db.models.deletion 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 9e630969d..3ca6b91c1 100644 --- a/apiserver/plane/db/migrations/0008_label_colour.py +++ b/apiserver/plane/db/migrations/0008_label_colour.py @@ -4,15 +4,14 @@ from django.db import migrations, models 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 077ab7e82..829baaa62 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 @@ from django.db import migrations, models 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 e8579b5ff..1672a10ab 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 @@ import django.db.models.deletion 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 deeb1cc2f..b52df3012 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 @@ import uuid 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 b1ff63fe1..bc767dd5d 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 @@ import uuid 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 c75537fc1..786e6cb5d 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 @@ from django.db import migrations, models 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 b1786c9c1..5642ae15d 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 @@ from django.db import migrations 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 e3f5dc26a..903c78b05 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 @@ from django.db import migrations, models 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 073c1e117..a22dc9a62 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 @@ import plane.db.models.asset 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 c6bfc2145..1ab721a3e 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 @@ from django.db import migrations 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 03eaeacd7..32f886539 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 @@ import uuid 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 38412aa9e..63545f497 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 @@ from django.db import migrations, models 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 192764078..4269f53b3 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 @@ import django.db.models.deletion 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 bae6a086a..0dc052c28 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 @@ import uuid 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 25a8eef61..69bd577d7 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 @@ import uuid 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 c6985866c..6f6103cae 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 @@ import uuid 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 65880891a..7a95d519e 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 @@ import uuid 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 1097a4612..702d74cfc 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 @@ import uuid 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 6f74fa499..310087f97 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 @@ import plane.db.models.project 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 8d344cf34..0377c84e8 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 @@ import uuid 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 bb0b67b92..ffccccff5 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 @@ import uuid 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 373cc39bd..cd2b1b865 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 @@ import uuid 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 bfc1da530..63db205dc 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 @@ from django.db import migrations 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 7e02b78b2..f4520a8f5 100644 --- a/apiserver/plane/db/migrations/0031_analyticview.py +++ b/apiserver/plane/db/migrations/0031_analyticview.py @@ -7,31 +7,75 @@ import uuid 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 27c13537e..c781d298c 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 @@ from django.db import migrations, models 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 8eb2eda62..1705aead6 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 @@ import uuid 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 cdd722f59..dd6d21f6d 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 @@ from django.db import migrations 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 dec6265e6..806bfef51 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 0b182f50b..86748c778 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 @@ from django.db import migrations, models 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 d11e1afd8..e659133d1 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 plane.db.models.user 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 1f5c63a89..53e50ed41 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 5d5747543..26849d7f7 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 5662ef666..76f8e6272 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 @@ import uuid 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'), ), migrations.AlterField( - model_name='projectidentifier', - name='name', + model_name="project", + name="identifier", + field=models.CharField( + max_length=12, verbose_name="Project Identifier" + ), + ), + migrations.AlterField( + 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 07c302c76..91119dbbd 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 uuid 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 01af46d20..f1fa99a36 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 @@ from django.db import migrations, models 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 5a806c704..81d91bb78 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 19a1449af..d42b3431e 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 4b9c1b1eb..9ac528829 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 f02660e1d..be58c8f5f 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 plane.db.models.issue 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 d44f760d0..f0a52a355 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 @@ import uuid 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 8d896b01d..791affed6 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 @@ import uuid 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 75d5e5982..d59fc5a84 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 a8807d104..327a5ab72 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 19267dfc2..886cee52d 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 @@ from django.db import migrations, models 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 000000000..da16fb9f6 --- /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 000000000..32b5ad2d5 --- /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 000000000..933c229a1 --- /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 000000000..e369c185d --- /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 000000000..2e6645945 --- /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 000000000..9204d43b3 --- /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 000000000..6238ef825 --- /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 728cb9933..263f9ab9a 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 c76df6e5b..d9096bd01 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -9,6 +9,8 @@ from .workspace import ( WorkspaceMemberInvite, TeamMember, WorkspaceTheme, + WorkspaceUserProperties, + WorkspaceBaseModel, ) from .project import ( @@ -48,11 +50,18 @@ from .social_connection import SocialLoginConnection 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 .inbox import Inbox, InboxIssue 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 0fa1d4aba..78da81814 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 ab3c38d9c..713508613 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 d0531e881..63c08afa4 100644 --- a/apiserver/plane/db/models/base.py +++ b/apiserver/plane/db/models/base.py @@ -12,7 +12,11 @@ from ..mixins import AuditModel 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 e5e2c355b..5251c68ec 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -6,10 +6,58 @@ from django.conf import settings 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 CycleFavorite(ProjectBaseModel): 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 000000000..05c5a893f --- /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 d95a86316..bb57e788c 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -8,7 +8,9 @@ from . import ProjectBaseModel 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 0383807b7..d427eb0f6 100644 --- a/apiserver/plane/db/models/exporter.py +++ b/apiserver/plane/db/models/exporter.py @@ -11,14 +11,20 @@ from django.contrib.postgres.fields import ArrayField # 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 a2d1d3166..651927458 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 6ad88e681..809a11821 100644 --- a/apiserver/plane/db/models/inbox.py +++ b/apiserver/plane/db/models/inbox.py @@ -7,7 +7,9 @@ from plane.db.models import ProjectBaseModel 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 3bef68708..34b40e57d 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 47db0483c..0c68adfd2 100644 --- a/apiserver/plane/db/models/integration/base.py +++ b/apiserver/plane/db/models/integration/base.py @@ -11,7 +11,11 @@ from plane.db.mixins import AuditModel 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 Integration(AuditModel): 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 f4d152bb1..f3331c874 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 GithubIssueSync(ProjectBaseModel): 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 6b29968f6..72df4dfd7 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 54acd5c5d..d5ed4247a 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -9,6 +9,7 @@ from django.db.models.signals import post_save 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 @@ class Issue(ProjectBaseModel): 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 @@ class Issue(ProjectBaseModel): 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 IssueRelation(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" - + return f"{self.issue.name} {self.related_issue.name}" + + class IssueMention(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_mention" @@ -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 IssueMention(ProjectBaseModel): 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 @@ class IssueAttachment(ProjectBaseModel): 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 @@ class IssueLabel(ProjectBaseModel): 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 @@ class CommentReaction(ProjectBaseModel): 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 @@ class IssueVote(ProjectBaseModel): 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 e485eea62..9af4e120e 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -6,9 +6,55 @@ from django.conf import settings 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 Module(ProjectBaseModel): 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 ModuleFavorite(ProjectBaseModel): 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 3df935718..b42ae54a9 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 Notification(BaseModel): 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 de65cb98f..6ed94798a 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 PageLog(ProjectBaseModel): 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 @@ class PageBlock(ProjectBaseModel): 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 fe72c260b..b93174724 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 @@ class ProjectMember(ProjectBaseModel): # 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 3370f239d..ab9b780c8 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -8,7 +8,9 @@ from . import ProjectBaseModel 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 fe75a6a26..6f8a82e56 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -6,11 +6,15 @@ import pytz # 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.dispatch import receiver -from django.contrib.auth.models import AbstractBaseUser, UserManager, PermissionsMixin -from django.utils import timezone from django.conf import settings +from django.dispatch import receiver +from django.utils import timezone # 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 @@ class User(AbstractBaseUser, PermissionsMixin): 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 44bc994d0..13500b5a4 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -3,7 +3,51 @@ from django.db import models 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 GlobalView(BaseModel): 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 @@ class GlobalView(BaseModel): 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 ea2b508e5..fbe74d03a 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 505bfbcfa..7e5d6d90b 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 Workspace(BaseModel): 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 @@ class WorkspaceMember(BaseModel): 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 WorkspaceTheme(BaseModel): 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 dff16605a..9ee85404b 100644 --- a/apiserver/plane/license/api/permissions/instance.py +++ b/apiserver/plane/license/api/permissions/instance.py @@ -7,7 +7,6 @@ from plane.license.models import Instance, InstanceAdmin 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 b658ff148..e6beda0e9 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 173d718d9..8a99acbae 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 BaseSerializer 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 InstanceAdminSerializer(BaseSerializer): "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 c88b3b75f..112c68bc8 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -61,7 +61,9 @@ class InstanceEndpoint(BaseAPIView): 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 @@ class InstanceAdminEndpoint(BaseAPIView): 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 @@ class InstanceAdminEndpoint(BaseAPIView): 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 67137d0d9..f81d98cba 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -21,7 +21,7 @@ class Command(BaseCommand): "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 @@ class Command(BaseCommand): ) 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 e6cfa7167..889cd46dc 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -12,13 +12,15 @@ from django.conf import settings 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 @@ class Command(BaseCommand): # 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 @@ class Command(BaseCommand): 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 c8b5f1f02..4eed3adf7 100644 --- a/apiserver/plane/license/migrations/0001_initial.py +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -7,7 +7,6 @@ import uuid 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 28f2c4352..0f35f718d 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 86845c34b..b8957e44f 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -5,9 +5,7 @@ from django.conf import settings # 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 InstanceConfiguration(BaseModel): 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 807833a7e..e6315e021 100644 --- a/apiserver/plane/license/urls.py +++ b/apiserver/plane/license/urls.py @@ -10,32 +10,32 @@ from plane.license.api.views import ( 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 c2d369c2e..11bd9000e 100644 --- a/apiserver/plane/license/utils/encryption.py +++ b/apiserver/plane/license/utils/encryption.py @@ -6,9 +6,10 @@ from cryptography.fernet import Fernet 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 e56525893..bc4fd5d21 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 a1894fad5..a49d43b55 100644 --- a/apiserver/plane/middleware/api_log_middleware.py +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -23,9 +23,13 @@ class APITokenLogMiddleware: 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 3da4958c1..9deac8091 100644 --- a/apiserver/plane/middleware/apps.py +++ b/apiserver/plane/middleware/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class Middleware(AppConfig): - name = 'plane.middleware' + name = "plane.middleware" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 971ed5543..444248382 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -71,13 +71,19 @@ REST_FRAMEWORK = { "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_REGION = os.environ.get("AWS_REGION", "") 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 @@ CELERY_ACCEPT_CONTENT = ["application/json"] 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 @@ CELERY_IMPORTS = ( "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 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( # 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 @@ POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) # 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 34ae16555..1e2a55144 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 89c9725d9..4b92b06fc 100644 --- a/apiserver/plane/space/serializer/base.py +++ b/apiserver/plane/space/serializer/base.py @@ -4,8 +4,8 @@ from rest_framework import serializers 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 @@ class DynamicBaseSerializer(BaseSerializer): # 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 @@ class DynamicBaseSerializer(BaseSerializer): 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 ab4d9441d..d4f5d86e0 100644 --- a/apiserver/plane/space/serializer/cycle.py +++ b/apiserver/plane/space/serializer/cycle.py @@ -4,6 +4,7 @@ from plane.db.models import ( Cycle, ) + class CycleBaseSerializer(BaseSerializer): class Meta: model = Cycle @@ -15,4 +16,4 @@ class CycleBaseSerializer(BaseSerializer): "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 05d99ac55..48ec7c89d 100644 --- a/apiserver/plane/space/serializer/inbox.py +++ b/apiserver/plane/space/serializer/inbox.py @@ -36,12 +36,16 @@ class InboxIssueLiteSerializer(BaseSerializer): 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 1a9a872ef..c7b044b21 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 IssueStateFlatSerializer(BaseSerializer): 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 IssueProjectLiteSerializer(BaseSerializer): 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 IssueRelationSerializer(BaseSerializer): "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 RelatedIssueSerializer(BaseSerializer): "relation_type", "related_issue", "issue", - "id" + "id", ] read_only_fields = [ "workspace", @@ -159,7 +163,8 @@ class IssueLinkSerializer(BaseSerializer): # 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 IssueAttachmentSerializer(BaseSerializer): 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 IssueCreateSerializer(BaseSerializer): 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 @@ class IssueCreateSerializer(BaseSerializer): 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 @@ class IssueCreateSerializer(BaseSerializer): # 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 CommentReactionSerializer(BaseSerializer): 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 LabelLiteSerializer(BaseSerializer): "name", "color", ] - - - - diff --git a/apiserver/plane/space/serializer/module.py b/apiserver/plane/space/serializer/module.py index 39ce9ec32..dda1861d1 100644 --- a/apiserver/plane/space/serializer/module.py +++ b/apiserver/plane/space/serializer/module.py @@ -4,6 +4,7 @@ from plane.db.models import ( Module, ) + class ModuleBaseSerializer(BaseSerializer): class Meta: model = Module @@ -15,4 +16,4 @@ class ModuleBaseSerializer(BaseSerializer): "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 903bcc2f4..55064ed0e 100644 --- a/apiserver/plane/space/serializer/state.py +++ b/apiserver/plane/space/serializer/state.py @@ -6,7 +6,6 @@ from plane.db.models import ( 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 ecf99079f..a31bb3744 100644 --- a/apiserver/plane/space/serializer/workspace.py +++ b/apiserver/plane/space/serializer/workspace.py @@ -4,6 +4,7 @@ from plane.db.models import ( Workspace, ) + class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace @@ -12,4 +13,4 @@ class WorkspaceLiteSerializer(BaseSerializer): "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 b1d749a09..b75f3dd18 100644 --- a/apiserver/plane/space/views/base.py +++ b/apiserver/plane/space/views/base.py @@ -59,7 +59,9 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): 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 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) 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 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): ) 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 53960f672..2bf8f8303 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -48,7 +48,8 @@ class InboxIssuePublicViewSet(BaseViewSet): 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 @@ class InboxIssuePublicViewSet(BaseViewSet): .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 @@ class InboxIssuePublicViewSet(BaseViewSet): .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 @@ class InboxIssuePublicViewSet(BaseViewSet): 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 @@ class InboxIssuePublicViewSet(BaseViewSet): "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 @@ class InboxIssuePublicViewSet(BaseViewSet): ) 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 @@ class InboxIssuePublicViewSet(BaseViewSet): 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 @@ class InboxIssuePublicViewSet(BaseViewSet): "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 @@ class InboxIssuePublicViewSet(BaseViewSet): ) 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 @@ class InboxIssuePublicViewSet(BaseViewSet): ) 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 @@ class InboxIssuePublicViewSet(BaseViewSet): ) 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 faab8834d..8f7fc0eaa 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -128,7 +128,9 @@ class IssueCommentPublicViewSet(BaseViewSet): ) 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 @@ class IssueCommentPublicViewSet(BaseViewSet): 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 @@ class IssueCommentPublicViewSet(BaseViewSet): 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 @@ class IssueReactionPublicViewSet(BaseViewSet): ) 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 @@ class CommentReactionPublicViewSet(BaseViewSet): 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 @@ class CommentReactionPublicViewSet(BaseViewSet): ) 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 @@ class IssueVotePublicViewSet(BaseViewSet): 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 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): # 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 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .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 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): # 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 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): 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 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): "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 e3209a281..f6843c1b6 100644 --- a/apiserver/plane/tests/api/base.py +++ b/apiserver/plane/tests/api/base.py @@ -8,7 +8,9 @@ from plane.app.views.authentication import get_tokens_for_user 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 51a36ba2f..b15d32e40 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 92ad92d6e..af6450ef4 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 4fc46e008..36a0f7a24 100644 --- a/apiserver/plane/tests/api/test_authentication.py +++ b/apiserver/plane/tests/api/test_authentication.py @@ -21,16 +21,16 @@ class SignInEndpointTests(BaseAPITest): 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 @@ class SignInEndpointTests(BaseAPITest): 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 @@ class SignInEndpointTests(BaseAPITest): 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 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): 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 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): 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 @@ class MagicLinkGenerateEndpointTests(BaseAPITest): 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 @@ class MagicSignInEndpointTests(BaseAPITest): 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 @@ class MagicSignInEndpointTests(BaseAPITest): ) 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 @@ class MagicSignInEndpointTests(BaseAPITest): ) 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 04c2d6ba2..72b580c99 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 3e59613e0..a45ff36b1 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 e70e4fccb..1e7dac0ef 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 c4750f9b8..624281a2f 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 49dae5581..9a7c50f19 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 2e939af70..5103b5059 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 ef9631bc2..a336d955a 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 a1da2997a..c1e487fbe 100644 --- a/apiserver/plane/tests/api/test_workspace.py +++ b/apiserver/plane/tests/api/test_workspace.py @@ -14,7 +14,6 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): super().setUp() def test_create_workspace(self): - url = reverse("workspace") # Test with empty data @@ -32,7 +31,9 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): # 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 e437da078..669f3ea73 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -12,7 +12,7 @@ urlpatterns = [ 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 be52bcce4..948eb1b91 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -4,9 +4,15 @@ from datetime import timedelta # 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 853874b31..edc7adc15 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) + 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): + 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) + 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): + 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) + 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 + return main_responsive_dict else: response_dict = {} diff --git a/apiserver/plane/utils/html_processor.py b/apiserver/plane/utils/html_processor.py index 5f61607e9..18d103b64 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 @@ class MLStripper(HTMLParser): 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 b427ba14f..6f3a7c217 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 5f9f1c98c..89753ef1d 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 45cb5925a..5a7ce2aa2 100644 --- a/apiserver/plane/utils/integrations/github.py +++ b/apiserver/plane/utils/integrations/github.py @@ -10,7 +10,9 @@ from django.conf import settings 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 70f26e160..0cc5b93b2 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 06ca4353d..01789c431 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 2da24092a..87284ff24 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -3,10 +3,10 @@ import uuid 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 40f85dde4..d38b1f4c3 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 793614cc0..6b2b49c15 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -31,8 +31,10 @@ class Cursor: 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 @@ class BasePaginator: 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 @@ class BasePaginator: 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 76ca3c4e6..a5861f9b5 100644 --- a/apiserver/plane/web/apps.py +++ b/apiserver/plane/web/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class WebConfig(AppConfig): - name = 'plane.web' + name = "plane.web" diff --git a/apiserver/plane/web/urls.py b/apiserver/plane/web/urls.py index 568b99037..24a3e7b57 100644 --- a/apiserver/plane/web/urls.py +++ b/apiserver/plane/web/urls.py @@ -2,6 +2,5 @@ from django.urls import path 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 ef3ea2780..b3051f9ff 100644 --- a/apiserver/plane/wsgi.py +++ b/apiserver/plane/wsgi.py @@ -9,7 +9,6 @@ import os 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 000000000..773d6090e --- /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 dfe813b86..d45f665de 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 000000000..fa50631c5 --- /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 000000000..f32be504d --- /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 000000000..445f39d69 --- /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 000000000..92533a73b --- /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 8b4ff77ef..b223e722a 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 15150aa40..3f306c559 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 4e1e3b39f..a2e518708 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 59e4a46a0..7db471eca 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 182fc4d83..f86c84aa8 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 b5d997662..64bd22058 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 ef2be61e3..8b31acdaf 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 147797e2d..4a56f07c2 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 86822664b..b0d2a1021 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 8de6298b5..5480a51e9 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 000000000..1c5d34109 --- /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 016cec2c3..64a1740cb 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 000000000..cf67e13d9 --- /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 000000000..0854092a9 --- /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 000000000..83e38054c --- /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 000000000..e66d18904 --- /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 cee0ded83..000000000 --- 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 4ae55f00c..5bfba3b0f 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 { CustomKeymap } from "src/ui/extensions/keymap"; -import { CustomCodeBlock } from "src/ui/extensions/code"; -import { CustomQuoteExtension } from "src/ui/extensions/quote"; +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; +import { CustomKeymap } from "src/ui/extensions/keymap"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; 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 a2c968401..9dcae6ad7 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 bc42b49ff..bd96ff1b1 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 6a47d79f0..e723ca0d7 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 6d706cb79..40e75a1e3 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 610d677f8..f60febc59 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 5795d6c4a..cf7c4ee18 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 72dfab954..7a3e9bdad 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 000000000..136d04e01 --- /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 000000000..fa73adbe1 --- /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 000000000..ff3fd0263 --- /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 000000000..f1d22a68e --- /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 c2d001abe..c60ac0e7a 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 155245f9e..2576d0d74 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 "Press '/' for commands..."; }, - ]; + includeChildren: true, + }), + IssueWidgetPlaceholder(), +]; - 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, - }), - 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 acc6213c2..35a09bcc2 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 75d977e49..96a5c1325 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 0a166c3e3..869c7a8c6 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 9bbb34aa5..264a70152 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 78554c26d..d3b6fd04f 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 c13637bd9..6c744927a 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 615b55dee..000000000 --- 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 df3554024..d1bdbc935 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 94801928c..339249320 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 269caad93..af99fec61 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", +export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) => + Extension.create({ + name: "dragAndDrop", - addProseMirrorPlugins() { - return [ - DragHandle({ - dragHandleWidth: 24, - }), - ]; - }, -}); + 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 7882d60ff..2272b9030 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 129efa4ee..7d93bf36f 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 527fd5674..c4b24d166 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 245248d45..7bd0920da 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 1e81c8173..3d1da6cda 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 17d701600..43c3f8f34 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 5237bf033..93ad475f8 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 213367b4f..b04497011 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 97f7cab84..3465b8196 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 a23b1b3c2..0ad6f2ba9 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 000000000..8fa98db30 --- /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 6c933a033..ce8bcbadb 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 0122cf73a..06a433ddd 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; - magic_login: boolean; - slack_client_id: string | null; - posthog_api_key: string | null; - posthog_host: string | null; + google_client_id: string | null; has_openai_configured: boolean; has_unsplash_configured: boolean; - is_self_managed: boolean; + is_smtp_configured: boolean; + magic_login: boolean; + posthog_api_key: string | null; + posthog_host: string | null; + 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 4f243deeb..12cbab4c6 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 000000000..31751c0d0 --- /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 32925c793..96b584ce1 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 81e1bb22f..877c07196 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 10fc37b31..4d666ae83 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 000000000..c7d33f75b --- /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 000000000..1b4e23e0f --- /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 000000000..2f10c088d --- /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 9f27e818c..6e8ded942 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 09f21eb3a..c54943f90 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 000000000..9f17d78c7 --- /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 000000000..391d06c12 --- /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 000000000..45d34be08 --- /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 000000000..892a3e906 --- /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 000000000..ae210d3b1 --- /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 000000000..527abe630 --- /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 000000000..90daa08fa --- /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 000000000..2c469e682 --- /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 000000000..88ef27426 --- /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 000000000..0b1c5f7cd --- /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 000000000..e604761ed --- /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 000000000..e69de29bb 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 733b8f7de..0e49da7fe 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 a1c241f6a..29552b94c 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 129b0bb3b..b54e3f0f9 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 3fdbaa2d3..120b216da 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 301c1d7c0..81c8abcd5 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 c8c47576b..61cc7081b 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 4f55e8c74..db30554a8 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 754e63711..e270f4f69 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 fb2aca722..2d7e94d95 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 000000000..a500a7385 --- /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 b643d47d4..9228cf9bb 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 4be345961..6344dce83 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 d63d89eb2..10ee815f6 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 ? ( -
- -
- ) : ( - - )} - - )} -
- )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

+ className={cn( + "my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap", + optionsClassName )} + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + > +
+ + 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

+ ) + ) : ( +

Loading...

+ )} +
+ {footerOption}
- {footerOption} -
- + + )} ); }} diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 8fbe3fbdc..0fa183cb2 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 eac53b6e6..06f1c44c0 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 000000000..1bb861477 --- /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 000000000..5331d11c8 --- /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 198391adb..0b98b3e6b 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"; -// types -import { IPriorityIcon } from "./type"; +type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; -export const PriorityIcon: React.FC = ({ priority, className = "", transparentBg = false }) => { - if (!className || className === "") className = "h-4 w-4"; +interface IPriorityIcon { + className?: string; + containerClassName?: string; + priority: TIssuePriorities; + size?: number; + withContainer?: boolean; +} - // Convert to lowercase for string comparison - const lowercasePriority = priority?.toLowerCase(); +export const PriorityIcon: React.FC = (props) => { + const { priority, className = "", containerClassName = "", size = 14, withContainer = false } = props; - //get priority icon - const getPriorityIcon = (): React.ReactNode => { - switch (lowercasePriority) { - case "urgent": - return ; - case "high": - return ; - case "medium": - return ; - case "low": - return ; - default: - return ; - } + 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 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 65b188e4c..4a04c948b 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 4b1bb2fcf..b90b6993a 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 d445480c7..66c70475f 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 471015406..7cf9717a0 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 cb7326b75..55205e707 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 43fd4df31..4f8ed4294 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 686848570..219971759 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 d080ff639..f909f16c5 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 6ebc05490..af1e5d68f 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 b0e5f69d3..0b5ad21d9 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 6b45bc429..4c61fa151 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 d6c3ce4e6..ef1a115d2 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 a82165140..7c6abe199 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 73f67327b..7d180d5ff 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 a3fabdda9..85da11290 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 b2d1f6ccd..ecebf92b7 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 6b2a53e3f..92980b0d7 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 48f0c0422..bd5ee34b5 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 d9260e61d..e0d525c2c 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 53ac1df50..307a65ad2 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 8bbf859a4..000000000 --- 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 275f7ff08..0d1cffbc6 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 27a8bf01c..74bfd6d94 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 48488e07e..c1c57baa0 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 000000000..4cea6ce5b --- /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 aec82cfa5..7c8468acb 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 cf53078be..000000000 --- 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 000000000..67ef720fe --- /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 000000000..d652e51f1 --- /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 1150a071c..8e44f490b 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 ead9b9c9a..d7a595298 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 a75a450e2..fe20d5b10 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 f7ec6b593..c92cd4bd4 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 17dbd2ad4..000000000 --- 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 1a4fa0e49..6e0ae3745 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 6b6071475..0d5861b4e 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 000000000..f84d41abc --- /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 000000000..db14f0ccb --- /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 2335226ce..293e03ef8 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 000000000..da9d7d79a --- /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 000000000..7764b627e --- /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 635fbee7f..a3c083b02 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 9917d0f58..ec7c40195 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 06431ab02..51b4089c4 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 5cfd15482..3c199f807 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 f3d7a9993..19f83e40b 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 7251c5073..3c08e1574 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 4efc6a211..055665d9e 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 66582a1e9..74ee99a77 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 3f7348cce..9f66c6b54 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 41770eec8..d09e8def4 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 2eaaac7fb..4a18011d1 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 7d1a6a3eb..59013a3e3 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; +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 { setToastAlert } = useToast(); + const projectDetails = projectId ? getProjectById(projectId.toString()) ?? undefined : undefined; - const { user: userStore, project: projectStore, cycle: cycleStore, module: moduleStore } = useMobxStore(); + const trackExportAnalytics = () => { + if (!currentUser) return; - 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 = { + 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; + if (projectDetails) { + const workspaceDetails = projectDetails.workspace as IWorkspace; - // fetch cycle details - useEffect(() => { - if (!workspaceSlug || !projectId || !cycleId || cycleDetails) return; + eventPayload.workspaceId = workspaceDetails.id; + eventPayload.workspaceName = workspaceDetails.name; + eventPayload.projectId = projectDetails.id; + eventPayload.projectIdentifier = projectDetails.identifier; + eventPayload.projectName = projectDetails.name; + } - cycleStore.fetchCycleWithId(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); - }, [cycleId, cycleDetails, cycleStore, projectId, workspaceSlug]); + if (cycleDetails || moduleDetails) { + const details = cycleDetails || moduleDetails; - // fetch module details - useEffect(() => { - if (!workspaceSlug || !projectId || !moduleId || moduleDetails) return; + 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; + } - moduleStore.fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); - }, [moduleId, moduleDetails, moduleStore, projectId, workspaceSlug]); + if (cycleDetails) { + eventPayload.cycleId = cycleDetails.id; + eventPayload.cycleName = cycleDetails.name; + } - const selectedProjects = params.project && params.project.length > 0 ? params.project : projects?.map((p) => p.id); + if (moduleDetails) { + eventPayload.moduleId = moduleDetails.id; + eventPayload.moduleName = moduleDetails.name; + } + }; - return ( -
-
+ const exportAnalytics = () => { + if (!workspaceSlug) return; + + const data: IExportAnalyticsFormData = { + 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.", + }) + ); + }; + + 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 2066292c8..c09f26d76 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 55ed1d403..09423e6dd 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 6dfbfdd6b..a4b82c4b6 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 df679fbc5..e66ffeabf 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 4c69a23c5..ea1a51937 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 aec15d9ac..2a62c99d4 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 ed61d3546..993289c10 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 65c5bf362..b3fc3df78 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 a04968dac..ae7717b39 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 1ffa69a78..f28ea3481 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 148924d6f..2de731222 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 f0a3e3d90..8d9d6ecd4 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 7ee4feacd..35b0b9b49 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 6471bc9cf..974efff3a 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 d21eb8b80..8d6662c11 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 eff42bb2d..1d306bb04 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 859a6d23a..4aaaab33a 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 8e188df7b..55f72c85d 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 57af2b62a..96fba41f6 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); - const handleUpdateIssue = async (formData: Partial) => { + 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) => { 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 81b9f7ae9..8d1c48261 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 0ce05bd7b..7841a4a1e 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 1e10b3a46..44b5e6111 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 791c62656..769a26be7 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 f7266a48a..976a63c87 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 84e62593a..1f05234f4 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 005e570e7..342827825 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 0488455fb..213c35f8e 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 8bf0c9938..44fc55bbe 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 192ef8ef9..0d2e042a7 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 7bad18734..efbab8249 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 46be10298..cd0a5b9ff 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 1ac34cf73..72a67883e 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 4433e4c09..3d47d8eca 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 000000000..0e34eac2c --- /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 8cea3784f..c37cdf4b9 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 f58bbc2c3..4d926285b 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 f47c1349f..19cd519cb 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 c55170702..bd6f43569 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 78364562f..bcd847a28 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 47beaa262..a0101b1c1 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 + ); - if (!cycle && isLoading) + const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["active"]; + + 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)} - -
-
-
- -
- -
- {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( -
- - {issue.assignee_details.map((assignee: any) => ( - - ))} - -
- ) : ( - "" - )} -
-
-
- )) - ) : ( -
- No issues present in the cycle. -
- ) - ) : ( - - - - - - )} -
-
+
+
High Priority Issues
+
+ {activeCycleIssues ? ( + activeCycleIssues.length > 0 ? ( + activeCycleIssues.map((issue: any) => ( + +
+ - {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} -
-
- )} + + + {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)} +
+
+ )} +
+ + )) + ) : ( +
+ There are no high priority issues present in this cycle. +
+ ) + ) : ( + + + + + + )} +
-
+
@@ -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 2c9339892..1ffe19260 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 d6806eaf0..b7acff358 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 d43d56872..d2279aeb4 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); - // 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 isDateValid = cycle.start_date || cycle.end_date; - - const { currentProjectRole } = userStore; - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; - + // 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 cycleDetails = getCycleById(cycleId); + + if (!cycleDetails) return null; + + const cycleStatus = cycleDetails.status.toLocaleLowerCase(); + const isCompleted = cycleStatus === "completed"; + const endDate = new Date(cycleDetails.end_date ?? ""); + const startDate = new Date(cycleDetails.start_date ?? ""); + const isDateValid = cycleDetails.start_date || cycleDetails.end_date; + + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; 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 af234b9dc..19e7f2225 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 9ea26ab39..e34f4b30b 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 226807b78..90fcdd8f9 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 3eb37c502..7b58bde45 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 33c6254df..44da175b4 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"; +// hooks +import { useApplication, useCycle } from "hooks/store"; +import useToast from "hooks/use-toast"; // components import { Button } from "@plane/ui"; -// hooks -import useToast from "hooks/use-toast"; // 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`); - - handleClose(); - } catch (error) { - setToastAlert({ - type: "error", - title: "Warning!", - message: "Something went wrong please try again later.", }); - } - else + + 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.", }); + } setLoader(false); }; diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 2cc087eda..865cc68a1 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(date ? renderFormattedPayloadDate(date) : null)} + buttonVariant="border-with-text" + placeholder="Start date" + minDate={new Date()} + maxDate={maxDate ?? undefined} + tabIndex={3} + /> +
+ )} + /> +
+ ( +
+ onChange(val)} - minDate={new Date()} - maxDate={maxDate ?? undefined} + onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} + buttonVariant="border-with-text" + placeholder="End date" + minDate={minDate} + tabIndex={4} /> - )} - /> -
-
- ( - onChange(val)} minDate={minDate} /> - )} - /> -
+
+ )} + />
- -
diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index 76a4d9235..46bc04039 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 9671c22af..26d04e103 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 blockFormat = (blocks: (ICycle | null)[]) => { + if (!blocks) return []; - const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15; + 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 665f9865b..8144feef7 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 f3576fb00..4bf76f91f 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] ?? []; + // 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); - } + // 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] - ); + // 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")} - - + {({ close }) => ( + <> + + + {renderFormattedDate(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} - /> - - + + + { + 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")} - - + + {renderFormattedDate(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} - /> - - - + + + { + 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 dd462e360..5956e4a1e 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 000000000..2e2f9ef88 --- /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 000000000..129cdb69e --- /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 000000000..c0ac90f34 --- /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 000000000..d4a27afc1 --- /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 000000000..f5727f277 --- /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 000000000..0db293a65 --- /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 000000000..cff4cdb44 --- /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 000000000..f60d8efe6 --- /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 000000000..fe93d4404 --- /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 000000000..72ca1dbb2 --- /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 000000000..83c1d0042 --- /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 000000000..b4cc81ce7 --- /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 000000000..ff4218ace --- /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 000000000..ef1c63f73 --- /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 000000000..a481a8881 --- /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 000000000..f5b7d53d4 --- /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 000000000..3da862d91 --- /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 000000000..af2c11660 --- /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 000000000..6ef6ec0ee --- /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 000000000..45b71466d --- /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 000000000..bd4171cfa --- /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 000000000..4de381b29 --- /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 000000000..ee5286f0f --- /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 000000000..4051a2908 --- /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 000000000..d2316802d --- /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 000000000..141bb5533 --- /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 000000000..f72d66ce4 --- /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 000000000..47e895a6e --- /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 000000000..d838967af --- /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 000000000..fc181ffab --- /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 000000000..1a4c2646b --- /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 000000000..fc16946d8 --- /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 000000000..2fafbb9ac --- /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 000000000..aae8ff54b --- /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 9feba79b2..000000000 --- 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 000000000..93d8c187c --- /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 000000000..ce52ad505 --- /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 000000000..d6d4da432 --- /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 000000000..1dba6f780 --- /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 000000000..88fec3199 --- /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 000000000..036ed9f75 --- /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 000000000..067d609c5 --- /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 000000000..a9f7e09c8 --- /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 000000000..cfbdf52e6 --- /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 000000000..673bea8aa --- /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 000000000..980f344a6 --- /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 000000000..e673293e0 --- /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 9cab40f5c..1360f9f45 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 fc145d69c..13d054da1 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 2526199b5..fc0075030 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 { - cycle: cycleStore, - projectIssuesFilter: projectIssueFiltersStore, - project: { currentProjectDetails }, - projectMember: { projectMembers }, - projectLabel: { projectLabels }, - projectState: projectStateStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - cycleIssuesFilter: { issueFilters, updateFilters }, - user: { currentProjectRole }, - } = useMobxStore(); + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCycleIds, getCycleById } = useCycle(); + const { + commandPalette: { toggleCreateIssueModal }, + eventTracker: { setTrackElement }, + } = useApplication(); + const { + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); - const activeLayout = projectIssueFiltersStore.issueFilters?.displayFilters?.layout; + 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 4eee7d8eb..7b45d3fcf 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 d4d7c633f..04a7ecb64 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: { currentProjectDetails }, - projectLabel: { projectLabels }, - projectMember: { projectMembers }, - projectState: projectStateStore, - inbox: inboxStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - // issue filters - projectIssuesFilter: { issueFilters, updateFilters }, - projectIssues: {}, - user: { currentProjectRole }, - } = useMobxStore(); + project: { projectMemberIds }, + } = useMember(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + 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 370dfe6d4..96929f7b0 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 8109b6af4..dca0dc7e6 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 000000000..90cbccd81 --- /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 fd86b6780..2ae373471 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 000000000..26f58131e --- /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 000000000..0ca28b950 --- /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 000000000..301583b4b --- /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 ef1a9e92d..bc8be5506 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 2b8fe7d9b..000000000 --- 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 af0648ca7..000000000 --- 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 3f4ccec44..000000000 --- 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 3a0faf248..000000000 --- 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 376ccbfdd..5ec63ea8a 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 dec274a9d..e152c1b03 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 5267f747b..a69c8d0e1 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 01a3cf643..c06621c03 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 93e0e0ec0..63cf84b4f 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 321e4f313..e3ba5d685 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 ac8eb3e90..9e3166563 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 e9816ae24..3c610d03f 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 433747f31..f7bd0f5fa 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 e07f580e7..70bbb5fa4 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 a746569fe..57fb82319 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 b2831ab66..000000000 --- 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 000000000..0d345a619 --- /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 c1b323e74..bf197980a 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 000000000..2129a4f61 --- /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 1b4915579..000000000 --- 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 d4f391459..e01d2828e 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 9546de31e..d4385e7da 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 000000000..79a6dc840 --- /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 658e825bf..000000000 --- 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 09f29da73..000000000 --- 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 c920caeba..000000000 --- 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 61ac899ad..000000000 --- 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 14ecd7edd..49d9e19dd 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 955d8ac78..6a2caba18 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 2f53a825f..a063980c0 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 677ab5e22..ca6d7e0e7 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 a556c9485..cfd6370fa 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"; +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"; -// hooks -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; // 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"; -// ui -import {} from "components/ui"; -import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; -// icons -import { Sparkle, X } from "lucide-react"; -// 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 { + CycleDropdown, + DateDropdown, + EstimateDropdown, + ModuleDropdown, + PriorityDropdown, + ProjectDropdown, + ProjectMemberDropdown, + StateDropdown, +} from "components/dropdowns"; +// ui +import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; +// helpers +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import type { IUser, TIssue, ISearchIssueResponse } from "@plane/types"; const aiService = new AIService(); const fileService = new FileService(); -const defaultValues: Partial = { - project: "", +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} /> - )} - /> -
+
+ )} + /> )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( -
+ {projectDetails?.cycle_view && ( + ( +
+ onChange(cycleId)} + value={value} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} + + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ +
+ )} + /> + )} + + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && + areEstimatesEnabledForProject(projectId) && ( ( - +
+ +
)} /> -
- )} + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( ( = 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 51ff30d40..0324c1b03 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 c0d1ebc5c..000000000 --- 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 53b873894..3cf88cb7c 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 000000000..fb8449d6f --- /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 000000000..97c28cc7c --- /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 000000000..4a1f79bee --- /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 000000000..b8f12a944 --- /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 000000000..e0b2aca28 --- /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 000000000..63ef560a1 --- /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 000000000..575e8d841 --- /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 000000000..55f07870c --- /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 000000000..449297cbe --- /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 000000000..d9b4475c5 --- /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 000000000..8336e516f --- /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 000000000..e45387535 --- /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 000000000..30f445ec0 --- /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 000000000..e01b94e1b --- /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 000000000..eabe5d518 --- /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 000000000..e86b1fb57 --- /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 000000000..dd44879cf --- /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 000000000..02108d70b --- /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 000000000..b9c59c9b3 --- /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 000000000..15343392f --- /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 000000000..c8089d233 --- /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 000000000..7a78be7bd --- /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 000000000..afe814ee2 --- /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 000000000..273bd319b --- /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 000000000..e68a7c373 --- /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 000000000..95b3cda80 --- /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 000000000..7cc47c2c8 --- /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 000000000..a4b40ec31 --- /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 000000000..0f5f6876e --- /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 000000000..af44463d5 --- /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 000000000..4dbc36f6b --- /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 000000000..2000721ee --- /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 000000000..bb79c9817 --- /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 000000000..4e2775c4a --- /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 000000000..5c6634ce5 --- /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 000000000..42d856b1e --- /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 000000000..72bc034f8 --- /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 000000000..83f1e73bc --- /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 000000000..3c3164c5a --- /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 000000000..fd714e002 --- /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 000000000..2ef9bec6e --- /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 000000000..4af089d5e --- /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 000000000..c31e1bc61 --- /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 000000000..fc9eb3838 --- /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 000000000..4a06c89af --- /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 000000000..c92c13977 --- /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 000000000..368bddb91 --- /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 000000000..94124085a --- /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 000000000..075525801 --- /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 000000000..1c4d80168 --- /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 000000000..9a1aa48ad --- /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 000000000..1b5a96749 --- /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 000000000..2176ccecc --- /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 000000000..cbcf4741b --- /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 000000000..45eca81d4 --- /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 000000000..8dc6f05bd --- /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 000000000..30a8621e4 --- /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 000000000..d6b33e36b --- /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 000000000..67bba8697 --- /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 000000000..fda73e94f --- /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 000000000..668d3538f --- /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 000000000..b57e75bed --- /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 b080bc838..7cb53ad39 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 a2626b023..1652aa89b 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 { - calendar: calendarStore, - projectIssues: issueStore, - user: { currentProjectRole }, - } = useMobxStore(); + issues: { viewFlags }, + } = useIssues(EIssuesStoreType.PROJECT); + const issueCalendarView = useCalendarView(); + const { + 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 c1778b334..0abe8580d 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 1a2280d05..ebbb510fc 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 f8eead33f..f66bf2ec0 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 85a74a997..d486b2f48 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 88025ad68..585b1a5e1 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 4a7cfbd3f..d2b23e176 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 e71cc7e3b..40f72e7b8 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 95d746eec..573a9cf20 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"; -// types -import { IIssue } from "types"; -import { EIssueActions } from "../../types"; import { BaseCalendarRoot } from "../base-calendar-root"; +// types +import { TIssue } from "@plane/types"; +import { EIssueActions } from "../../types"; +// constants +import { EIssuesStoreType } from "constants/issue"; -export const ProjectViewCalendarLayout: React.FC = observer(() => { - const { - viewIssues: projectViewIssuesStore, - viewIssuesFilter: projectIssueViewFiltersStore, - calendarHelpers: { handleDragDrop: handleCalenderDragDrop }, - } = useMobxStore(); +export interface IViewCalendarLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + }; +} +export const ProjectViewCalendarLayout: React.FC = observer((props) => { + const { issueActions } = props; + // store + const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); + // router const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - 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); - }, - }; - - 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 - ); - }; + 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 000000000..82d9ce0ce --- /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 1d7c4de3d..c34aaef97 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 52baa2eb8..f0727c50e 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 { - cycleIssues: cycleIssueStore, - commandPalette: commandPaletteStore, - trackEvent: { setTrackElement }, - user: { currentProjectRole: userRole }, - } = useMobxStore(); + commandPalette: { toggleCreateIssueModal }, + eventTracker: { setTrackElement }, + } = useApplication(); + const { + 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 000000000..1d2695ff9 --- /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 d4348c4bf..cd4070186 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 0373709d2..1320076e7 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 ed7f73358..109a903a2 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 000000000..b72dfff18 --- /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 2fd297a90..919decd51 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 7db04b36a..000000000 --- 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 c1e7b8cec..891fd6ddd 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 7ff8056b9..4ca2538e5 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 08e7aee44..799233d01 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 1dd61d339..ff5034c97 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 88b39dc00..be3240b55 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 b1e17cfe3..4c5affe8d 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 2b6571d3b..227dc025b 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 b7c8b6889..827382da7 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 d3d56266d..e9024afeb 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 543d18645..0dae3c8bd 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(() => { +type Props = { + globalViewId: string; +}; + +export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { + const { globalViewId } = props; + // router const router = useRouter(); - const { workspaceSlug } = router.query as { workspaceSlug: string; globalViewId: string }; - + const { workspaceSlug } = router.query; + // store hooks const { - project: { workspaceProjects }, - workspace: { workspaceLabels }, - workspaceMember: { workspaceMembers }, - workspaceGlobalIssuesFilter: { issueFilters, updateFilters }, - } = useMobxStore(); + issuesFilter: { filters, updateFilters }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { workspaceLabels } = useLabel(); + const { globalViewMap, updateGlobalView } = useGlobalView(); + const { + 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; - // globalViewsStore.updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), { - // query_data: { - // ...viewDetails.query_data, - // filters: { - // ...(storedFilters ?? {}), - // }, - // }, - // }); - // }; + updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), { + filters: { + ...(appliedFilters ?? {}), + }, + }); + }; - // update stored filters when view details are fetched - // useEffect(() => { - // if (!globalViewId || !viewDetails) return; + const areFiltersEqual = isEqual(appliedFilters, viewDetails?.filters); - // if (!globalViewFiltersStore.storedFilters[globalViewId.toString()]) - // globalViewFiltersStore.updateStoredFilters(globalViewId.toString(), viewDetails?.query_data?.filters ?? {}); - // }, [globalViewId, globalViewFiltersStore, viewDetails]); + const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + + 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 62cd4b3d8..b823a4bd1 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 89870d98a..7a6c39336 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 31317366c..68b5e6727 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 6b037a031..0768064ec 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 64f95983e..620a8f781 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 9cff84d9b..59a873162 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 412e54794..3c94b4f3f 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 0abe6442a..3ea1453e8 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 cb75b53f4..0feb1d891 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 aa057e417..659d86d08 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 a6fa2bf06..59c83a200 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 004d1b6e9..e417c650e 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 f66422427..331051161 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 0a1ecf3ea..168e31bc0 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 de6b73596..b226f42b3 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 8e2f4b402..a6af9833a 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 b536b1fa8..eb7005cbd 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 b48698fa7..68b09135c 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; + router: { workspaceSlug, projectId }, + } = useApplication(); + const { setPeekIssue } = useIssueDetail(); - const router = useRouter(); - - 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 }, - }); - } - }; - - 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 - )} -
-
- )} - -
{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; -}; - -const KanbanIssueMemoBlock = memo(KanbanIssueDetailsBlock, validateMemo); - -export const KanbanIssueBlock: React.FC = (props) => { - const { - sub_group_id, - columnId, - index, - issue, - isDragDisabled, - showEmptyGroup, - handleIssues, - quickActions, - displayProperties, - canEditProperties, - } = props; - - let draggableId = issue.id; - if (columnId) draggableId = `${draggableId}__${columnId}`; - if (sub_group_id) draggableId = `${draggableId}__${sub_group_id}`; - - const canEditIssueProperties = canEditProperties(issue.project); + const handleIssuePeekOverview = (issue: TIssue) => + workspaceSlug && + issue && + issue.project_id && + issue.id && + setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); return ( <> - - {(provided, snapshot) => ( -
- {issue.tempId !== undefined && ( -
- )} - + +
+
+ {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
- )} - +
{quickActions(issue)}
+
+
+ + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + + + ); -}; +}); + +export const KanbanIssueBlock: React.FC = memo((props) => { + const { + peekIssueId, + issueId, + issuesMap, + displayProperties, + isDragDisabled, + draggableId, + index, + handleIssues, + quickActions, + canEditProperties, + } = props; + + const issue = issuesMap[issueId]; + + if (!issue) return null; + + const canEditIssueProperties = canEditProperties(issue.project_id); + + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( +
+ {issue.tempId !== undefined && ( +
+ )} +
+ +
+
+ )} + + ); +}); + +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 fe3f85c33..15c797833 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 87fb98bf7..de6c1ddae 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 e90e292d7..000000000 --- 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 840d21b92..000000000 --- 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 90c7e302f..713a6644a 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 f668b1b79..000000000 --- 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 0924ad078..000000000 --- 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 0dc654a4c..000000000 --- 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 62bbbd2ae..000000000 --- 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 b192a4757..000000000 --- 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 95cff31cb..000000000 --- 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 de5e7abc4..ea9464780 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 8cdf1c9ec..000000000 --- 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 000000000..1a25c563e --- /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 5be5a12c5..000000000 --- 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 9c4406f7d..b4610a2e0 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 { workspaceSlug, projectId } = router.query; + // store hooks + const { getProjectById } = useProject(); - const { workspace: workspaceStore, project: projectStore } = useMobxStore(); - - 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 b54b18edb..0903355ce 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 { 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.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 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.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 issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) 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() - ); - }; + 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 78f5a76eb..9152dbfe5 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 138787b1f..c3af69e6e 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 { 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.toString()); - fetchModuleDetails(workspaceSlug.toString(), issue.project, moduleId.toString()); - }, - [EIssueActions.DELETE]: async (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.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 issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, + [EIssueActions.REMOVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; - await moduleIssueStore.removeIssueFromModule( - workspaceSlug.toString(), - issue.project, - moduleId.toString(), - issue.id, - issue.bridge_id - ); - fetchModuleDetails(workspaceSlug.toString(), issue.project, moduleId.toString()); - }, - }; + 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 c1466140f..2e189c9f4 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(); + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - if (!workspaceSlug || !userId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; - await profileIssuesStore.updateIssue(workspaceSlug, userId, issue.id, issue); - }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - 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 profileIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id, userId); - }, - }; + 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 16ef0f65b..89e2ee187 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 5edfe10ad..1cdf71d45 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 const ProjectViewKanBanLayout: React.FC = observer(() => { - 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); - }, +export interface IViewKanBanLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; +} - 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 - ); +export const ProjectViewKanBanLayout: React.FC = observer((props) => { + const { issueActions } = props; + // router + const router = useRouter(); + const { viewId } = router.query; + + 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 f03e3a8d0..1b9f27828 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; + const member = useMember(); + const project = useProject(); + const label = useLabel(); + const projectState = useProjectState(); + + const groupByList = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); + const subGroupByList = getGroupByColumns(sub_group_by as GroupByColumnTypes, project, label, projectState, member); + + if (!groupByList || !subGroupByList) return null; + 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" && ( + {sub_group_by && ( - )} - - {sub_group_by && sub_group_by === "state" && ( - - )} - - {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/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts new file mode 100644 index 000000000..5c5de8c45 --- /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 55b2fce55..10f3582f1 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 562f599ab..b2222a69e 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 canEditIssueProperties = canEditProperties(issue.project); + const issue = issuesMap[issueId]; + + 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 7270ae06d..5e02d638f 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 24781bb41..95e31b758 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 d129774aa..000000000 --- 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 77306998b..000000000 --- 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 c7b16fe26..000000000 --- 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 c703ea66b..7a7a2d1ab 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 50ed9ad98..000000000 --- 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 b4d740e37..000000000 --- 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 5eb19fbfd..000000000 --- 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 7578214b2..000000000 --- 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 421a1da8f..000000000 --- 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 926743464..000000000 --- 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 efdd79cfc..9e3bb8701 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 07129910f..000000000 --- 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 9237d8a1f..540d4d7f6 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 { workspaceSlug, projectId } = router.query; + // hooks + const { getProjectById } = useProject(); - const { workspace: workspaceStore, project: projectStore } = useMobxStore(); - - 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 cf4c74063..2ba4ea7f5 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 issues.removeIssue(workspaceSlug, projectId, issue.id); + }, + }), + [issues, workspaceSlug, projectId] + ); - await archivedIssueStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; - - 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 de579473b..89da8dd54 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 6049ec3bc..e11971874 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 { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + 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 issues.updateIssue(workspaceSlug, projectId, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; - await projectIssuesStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; + 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 5d076a0cc..520a2da32 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 eedf7ae81..91e80382a 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(); + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !userId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + 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 issues.updateIssue(workspaceSlug, issue.project_id, issue.id, issue, userId); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; - await profileIssuesStore.removeIssue(workspaceSlug, issue.project, issue.id, userId); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; + 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 0d23f7656..f0479b71f 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 { issuesFilter, issues } = useIssues(EIssuesStoreType.PROJECT); - const issueActions = { - [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + 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 issues.updateIssue(workspaceSlug, projectId, issue.id, issue); + }, + [EIssueActions.DELETE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; - await projectIssuesStore.removeIssue(workspaceSlug, projectId, issue.id); - }, - }; - - const getProjects = (projectStore: IProjectStore) => projectStore.workspaceProjects; + 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 52fa1a759..dd384ba93 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"; - -// store -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; -// constants import { useRouter } from "next/router"; +// store +import { useIssues } from "hooks/store"; +// constants +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 000000000..4057c7b93 --- /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 01dec9b83..000000000 --- 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 d0bb29711..000000000 --- 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 e3f617958..000000000 --- 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 000000000..95f3ce21f --- /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 3e2e2acd6..000000000 --- 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 d0045c3d4..0f121bed7 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,49 +43,27 @@ 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)); + } }; - 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, - content: ( -
- -
{label.name}
-
- ), - })); - - const filteredOptions = - query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); - const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", modifiers: [ @@ -99,17 +76,41 @@ export const IssuePropertyLabels: React.FC = observer((pro ], }); + if (!value) return null; + + let projectLabels: IIssueLabel[] = defaultOptions; + if (storeLabels && storeLabels.length > 0) projectLabels = storeLabels; + + 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 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 133cce1f9..264093778 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 af018a652..d21535639 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 0ad1f610b..0decd5555 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 12438b2a3..044030184 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 7ab5621b8..2ba023674 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 { - workspaceMember: { workspaceMembers }, - workspace: { workspaceLabels }, - globalViews: { fetchAllGlobalViews }, - workspaceGlobalIssues: { loader, getIssues, getIssuesIds, fetchIssues, updateIssue, removeIssue }, - workspaceGlobalIssuesFilter: { currentView, issueFilters, fetchFilters, updateFilters, setCurrentView }, - workspaceMember: { currentWorkspaceUserProjectsRole }, - } = useMobxStore(); + issuesFilter: { filters, fetchFilters, updateFilters }, + issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue }, + } = useIssues(EIssuesStoreType.GLOBAL); + + const { dataViewId, issueIds } = groupedIssueIds; + const { + 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; - }; - - const issuesResponse = getIssues; - const issueIds = (getIssuesIds ?? []) as TUnGroupedIssues; - const issues = issueIds?.filter((id) => id && issuesResponse?.[id]).map((id) => issuesResponse?.[id]); - - const issueActions = { - [EIssueActions.UPDATE]: async (issue: IIssue) => { - const projectId = issue.project; - if (!workspaceSlug || !projectId) return; - - await updateIssue(workspaceSlug, projectId, issue.id, issue, currentIssueView); + return !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; }, - [EIssueActions.DELETE]: async (issue: IIssue) => { - const projectId = issue.project; - if (!workspaceSlug || !projectId) return; + [currentWorkspaceAllProjectsRole] + ); - await removeIssue(workspaceSlug, projectId, issue.id, currentIssueView); - }, - }; + const issueFilters = globalViewId ? filters?.[globalViewId.toString()] : undefined; + + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + const projectId = issue.project_id; + if (!workspaceSlug || !projectId || !globalViewId) 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.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 53171f4e5..430383a9f 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 f77dfbed4..3e07d16fc 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 d09d47714..075a16aa2 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 72f71aae2..727e3e393 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 21f52564e..10db13e49 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 db30e4b7c..ddc1e9917 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 b6623f72c..75ac2bd9e 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 activeLayout = issueFilters?.displayFilters?.layout; + const issueActions = useMemo( + () => ({ + [EIssueActions.UPDATE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + 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 42fac26ef..8bf2cb211 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 42c40ceee..54df6ca24 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 = useCallback( + (projectId: string | undefined) => { + const isEditingAllowedBasedOnProject = + canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; - const canEditProperties = (projectId: string | undefined) => { - const isEditingAllowedBasedOnProject = - canEditPropertiesBasedOnProject && projectId ? canEditPropertiesBasedOnProject(projectId) : isEditingAllowed; + return enableInlineEditing && isEditingAllowedBasedOnProject; + }, + [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] + ); - return enableInlineEditing && isEditingAllowedBasedOnProject; - }; - - const issuesResponse = issueStore.getIssues; - const issueIds = (issueStore.getIssuesIds ?? []) as TUnGroupedIssues; - - 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 3d2e96499..2656143ac 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 d8d4964a8..c17a433b8 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 c71343cfc..000000000 --- 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 dfe0e5513..8d373efb4 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 73d761cf9..dbc27a3db 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 f902c82df..50878ccce 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 000000000..dc9f8c7c6 --- /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 a6c4979b3..acfd02fc5 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 b8d09d1df..000000000 --- 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 c2f3eddc8..000000000 --- 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 738880d65..000000000 --- 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 b034afd9f..82015056e 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; +export const SpreadsheetLabelColumn: React.FC = observer((props: Props) => { + const { issue, onChange, disabled } = props; + // hooks + const { labelMap } = useLabel(); - const isExpanded = expandedIssues.indexOf(issue.id) > -1; - - 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 13713f630..2d3e7b670 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 116579a70..0a8321740 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 3233baadb..778f9cdac 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 5e41d680f..0050c8acf 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 5f4b8c234..c0e41d2c0 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 97cc0fcff..f84989192 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 10fc26219..8f7c4a7fd 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 000000000..579b8863c --- /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 04fbeacca..605e8bea1 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 f61b14eb1..40b933557 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 c3fe9f0b7..28b766cd1 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; - - 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); - }, +export interface IViewSpreadsheetLayout { + issueActions: { + [EIssueActions.DELETE]: (issue: TIssue) => Promise; + [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; + [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; }; +} + +export const ProjectViewSpreadsheetLayout: React.FC = observer((props) => { + const { issueActions } = props; + // router + const router = useRouter(); + const { viewId } = router.query; + + 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 7e8cad64a..000000000 --- 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 000000000..704c9f904 --- /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 000000000..369e6633c --- /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 4b7ffe6da..e99b17850 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 000000000..83ec363b9 --- /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 000000000..274df2981 --- /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 000000000..31cb9dd66 --- /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 000000000..feac885d4 --- /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 000000000..da13e6353 --- /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 695870e49..000000000 --- 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 08ca5fc77..df2357179 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 0fbf769e4..000000000 --- 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 0e4dee36d..000000000 --- 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 3c7b1af34..c8520562e 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 86d1a138c..000000000 --- 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 be8915ad2..000000000 --- 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 a53e4160f..000000000 --- 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 144252dc9..000000000 --- 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 705c5a336..000000000 --- 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 9abbfe2ab..000000000 --- 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 38581dada..6d602e45b 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 d8a88cff7..fefba1713 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 ? ( - ( -