diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 2b40af672..000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,23 +0,0 @@ -version = 1 - -exclude_patterns = [ - "bin/**", - "**/node_modules/", - "**/*.min.js" -] - -[[analyzers]] -name = "shell" - -[[analyzers]] -name = "javascript" - - [analyzers.meta] - plugins = ["react"] - environment = ["nodejs"] - -[[analyzers]] -name = "python" - - [analyzers.meta] - runtime_version = "3.x.x" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 3adaa4230..d1d7fa009 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -55,12 +55,19 @@ body: - Safari - Other - type: dropdown - id: version + id: variant attributes: - label: Version + label: Variant options: - Cloud - Self-hosted - Local + validations: + required: true +- type: input + id: version + attributes: + label: Version + placeholder: v0.17.0-dev validations: required: true \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 0d8d2af09..306f92957 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -2,32 +2,10 @@ name: Branch Build on: workflow_dispatch: - inputs: - build-web: - required: false - description: "Build Web" - type: boolean - default: false - build-space: - required: false - description: "Build Space" - type: boolean - default: false - build-api: - required: false - description: "Build API" - type: boolean - default: false - build-proxy: - required: false - description: "Build Proxy" - type: boolean - default: false push: branches: - master - preview - - develop release: types: [released, prereleased] @@ -95,7 +73,7 @@ jobs: - nginx/** branch_build_push_frontend: - if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -147,7 +125,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_space: - if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event.inputs.build-space=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -199,7 +177,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_backend: - if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -251,7 +229,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_proxy: - if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9f6ab1bfb..dbfd81168 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,13 +1,13 @@ name: "CodeQL" on: + workflow_dispatch: push: - branches: [ 'develop', 'preview', 'master' ] + branches: ["preview", "master"] pull_request: - # The branches below must be a subset of the branches above - branches: [ 'develop', 'preview', 'master' ] + branches: ["develop", "preview", "master"] schedule: - - cron: '53 19 * * 5' + - cron: "53 19 * * 5" jobs: analyze: @@ -21,45 +21,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python', 'javascript' ] + language: ["python", "javascript"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 8644f04f0..ad1a605b6 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -11,7 +11,7 @@ env: jobs: sync_changes: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 permissions: pull-requests: write contents: read diff --git a/.gitignore b/.gitignore index 0b655bd0e..3989f4356 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ staticfiles mediafiles .env .DS_Store +logs/ node_modules/ assets/dist/ diff --git a/Dockerfile b/Dockerfile index 0f4ecfd36..0d5951dee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ RUN yarn install COPY --from=builder /app/out/full/ . COPY turbo.json turbo.json COPY replace-env-vars.sh /usr/local/bin/ -USER root + RUN chmod +x /usr/local/bin/replace-env-vars.sh RUN yarn turbo run build @@ -89,21 +89,17 @@ RUN chmod -R 777 /code WORKDIR /app -# Don't run production as root -RUN addgroup --system --gid 1001 plane -RUN adduser --system --uid 1001 captain - COPY --from=installer /app/apps/app/next.config.js . COPY --from=installer /app/apps/app/package.json . COPY --from=installer /app/apps/space/next.config.js . COPY --from=installer /app/apps/space/package.json . -COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./ +COPY --from=installer /app/apps/app/.next/standalone ./ -COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static +COPY --from=installer /app/apps/app/.next/static ./apps/app/.next/static -COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./ -COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next +COPY --from=installer /app/apps/space/.next/standalone ./ +COPY --from=installer /app/apps/space/.next ./apps/space/.next ENV NEXT_TELEMETRY_DISABLED 1 @@ -118,7 +114,6 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -USER root COPY replace-env-vars.sh /usr/local/bin/ COPY start.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/replace-env-vars.sh diff --git a/README.md b/README.md index 6834199ff..ece8ff1e2 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@

- Website â€ĸ - Releases â€ĸ - Twitter â€ĸ - Documentation + Website â€ĸ + Releases â€ĸ + Twitter â€ĸ + Documentation

@@ -40,28 +40,28 @@

-Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀ī¸ +Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀ī¸ -> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. +> 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 in our upcoming releases. ## ⚡ Installation The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users. -If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). +If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). | Installation Methods | Documentation Link | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/docker-compose) | +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/self-hosting/methods/docker-compose) | | Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) | `Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature. ## 🚀 Features -- **Issues**: Quickly create issues and add details using a powerful, rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. +- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. -- **Cycles** +- **Cycles**: Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features. - **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily. @@ -74,11 +74,11 @@ If you want more control over your data prefer to self-host Plane, please refer - **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. -## 🛠ī¸ Contributors Quick Start +## 🛠ī¸ Quick start for contributors > Development system must have docker engine installed and running. -Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute +Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute - 1. Clone the code locally using: ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..36cdb982c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ +# Security Policy + +This document outlines security procedures and vulnerabilities reporting for the Plane project. + +At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients. + +To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it. + +## Out of Scope Vulnerabilities + +We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope: + +- Attacks requiring MITM or physical access to a user's device. +- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS. +- Email spoofing. +- Missing DNSSEC, CAA, CSP headers. +- Lack of Secure or HTTP only flag on non-sensitive cookies. + +## Reporting Process + +If you discover a vulnerability, please adhere to the following reporting process: + +1. Email your findings to security@plane.so. +2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary. +3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data. +4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved. +5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications. + +When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability. + +## Our Commitment + +We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us: + +- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date. +- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines. +- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent. +- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability. +- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability. +- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved. + +We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts. + +reference: https://supabase.com/.well-known/security.txt diff --git a/apiserver/.env.example b/apiserver/.env.example index 97dc4dda8..d8554f400 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -44,4 +44,3 @@ WEB_URL="http://localhost" # Gunicorn Workers GUNICORN_WORKERS=2 - diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 0e4e0ac50..31124c8f5 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -32,27 +32,19 @@ RUN apk add --no-cache --virtual .build-deps \ apk del .build-deps -RUN addgroup -S plane && \ - adduser -S captain -G plane - -RUN chown captain.plane /code - -USER captain - # Add in Django deps and generate Django's static files COPY manage.py manage.py COPY plane plane/ COPY templates templates/ COPY package.json package.json -USER root + RUN apk --no-cache add "bash~=5.2" COPY ./bin ./bin/ +RUN mkdir -p /code/plane/logs RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat RUN chmod -R 777 /code -USER captain - # Expose container port and run entry point script EXPOSE 8000 diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index bd6684fd5..6a225fec3 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -30,16 +30,13 @@ ADD requirements ./requirements # Install the local development settings RUN pip install -r requirements/local.txt --compile --no-cache-dir -RUN addgroup -S plane && \ - adduser -S captain -G plane COPY . . -RUN chown -R captain.plane /code +RUN mkdir -p /code/plane/logs RUN chmod -R +x /code/bin RUN chmod -R 777 /code -USER captain # Expose container port and run entry point script EXPOSE 8000 diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index a0e45416a..328b9db2b 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -182,7 +182,7 @@ def update_label_color(): labels = Label.objects.filter(color="") updated_labels = [] for label in labels: - label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF) + label.color = f"#{random.randint(0, 0xFFFFFF+1):06X}" updated_labels.append(label) Label.objects.bulk_update(updated_labels, ["color"], batch_size=100) diff --git a/apiserver/package.json b/apiserver/package.json index 060944406..2840f6bef 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.16.0" + "version": "0.18.0" } diff --git a/apiserver/plane/api/serializers/base.py b/apiserver/plane/api/serializers/base.py index da8b96964..5b68a7113 100644 --- a/apiserver/plane/api/serializers/base.py +++ b/apiserver/plane/api/serializers/base.py @@ -66,11 +66,11 @@ class BaseSerializer(serializers.ModelSerializer): if expand in self.fields: # Import all the expandable serializers from . import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, - UserLiteSerializer, - StateLiteSerializer, IssueSerializer, + ProjectLiteSerializer, + StateLiteSerializer, + UserLiteSerializer, + WorkspaceLiteSerializer, ) # Expansion mapper diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index b8f194b32..c40f56ccc 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,32 +1,33 @@ -from lxml import html +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator # Django imports from django.utils import timezone -from django.core.validators import URLValidator -from django.core.exceptions import ValidationError +from lxml import html # Third party imports from rest_framework import serializers # Module imports from plane.db.models import ( - User, Issue, - State, + IssueActivity, IssueAssignee, - Label, + IssueAttachment, + IssueComment, IssueLabel, IssueLink, - IssueComment, - IssueAttachment, - IssueActivity, + Label, ProjectMember, + State, + User, ) + from .base import BaseSerializer -from .cycle import CycleSerializer, CycleLiteSerializer -from .module import ModuleSerializer, ModuleLiteSerializer -from .user import UserLiteSerializer +from .cycle import CycleLiteSerializer, CycleSerializer +from .module import ModuleLiteSerializer, ModuleSerializer from .state import StateLiteSerializer +from .user import UserLiteSerializer class IssueSerializer(BaseSerializer): @@ -78,8 +79,8 @@ class IssueSerializer(BaseSerializer): parsed_str = html.tostring(parsed, encoding="unicode") data["description_html"] = parsed_str - except Exception as e: - raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + except Exception: + raise serializers.ValidationError("Invalid HTML passed") # Validate assignees are from project if data.get("assignees", []): @@ -294,7 +295,7 @@ class IssueLinkSerializer(BaseSerializer): raise serializers.ValidationError("Invalid URL format.") # Check URL scheme - if not value.startswith(('http://', 'https://')): + if not value.startswith(("http://", "https://")): raise serializers.ValidationError("Invalid URL scheme.") return value @@ -365,8 +366,8 @@ class IssueCommentSerializer(BaseSerializer): parsed_str = html.tostring(parsed, encoding="unicode") data["comment_html"] = parsed_str - except Exception as e: - raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + except Exception: + raise serializers.ValidationError("Invalid HTML passed") return data diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 9dd4c9b85..ce354ba5f 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -7,6 +7,7 @@ from plane.db.models import ( ProjectIdentifier, WorkspaceMember, ) + from .base import BaseSerializer diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index fe50021b5..e853b90c2 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -1,5 +1,6 @@ # Module imports from plane.db.models import User + from .base import BaseSerializer @@ -10,7 +11,9 @@ class UserLiteSerializer(BaseSerializer): "id", "first_name", "last_name", + "email", "avatar", "display_name", + "email", ] read_only_fields = fields diff --git a/apiserver/plane/api/urls/cycle.py b/apiserver/plane/api/urls/cycle.py index 593e501bf..b0ae21174 100644 --- a/apiserver/plane/api/urls/cycle.py +++ b/apiserver/plane/api/urls/cycle.py @@ -4,6 +4,7 @@ from plane.api.views.cycle import ( CycleAPIEndpoint, CycleIssueAPIEndpoint, TransferCycleIssueAPIEndpoint, + CycleArchiveUnarchiveAPIEndpoint, ) urlpatterns = [ @@ -32,4 +33,14 @@ urlpatterns = [ TransferCycleIssueAPIEndpoint.as_view(), name="transfer-issues", ), + path( + "workspaces//projects//cycles//archive/", + CycleArchiveUnarchiveAPIEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles/", + CycleArchiveUnarchiveAPIEndpoint.as_view(), + name="cycle-archive-unarchive", + ), ] diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 070ea8bd9..5ce9db85c 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -6,9 +6,15 @@ from plane.api.views import ( IssueLinkAPIEndpoint, IssueCommentAPIEndpoint, IssueActivityAPIEndpoint, + WorkspaceIssueAPIEndpoint, ) urlpatterns = [ + path( + "workspaces//issues/-/", + WorkspaceIssueAPIEndpoint.as_view(), + name="issue-by-identifier", + ), path( "workspaces//projects//issues/", IssueAPIEndpoint.as_view(), diff --git a/apiserver/plane/api/urls/module.py b/apiserver/plane/api/urls/module.py index 4309f44e9..a131f4d4f 100644 --- a/apiserver/plane/api/urls/module.py +++ b/apiserver/plane/api/urls/module.py @@ -1,6 +1,10 @@ from django.urls import path -from plane.api.views import ModuleAPIEndpoint, ModuleIssueAPIEndpoint +from plane.api.views import ( + ModuleAPIEndpoint, + ModuleIssueAPIEndpoint, + ModuleArchiveUnarchiveAPIEndpoint, +) urlpatterns = [ path( @@ -23,4 +27,14 @@ urlpatterns = [ ModuleIssueAPIEndpoint.as_view(), name="module-issues", ), + path( + "workspaces//projects//modules//archive/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(), + name="module-archive-unarchive", + ), + path( + "workspaces//projects//archived-modules/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(), + name="module-archive-unarchive", + ), ] diff --git a/apiserver/plane/api/urls/project.py b/apiserver/plane/api/urls/project.py index 1ed450c86..5efb85bb0 100644 --- a/apiserver/plane/api/urls/project.py +++ b/apiserver/plane/api/urls/project.py @@ -1,6 +1,9 @@ from django.urls import path -from plane.api.views import ProjectAPIEndpoint +from plane.api.views import ( + ProjectAPIEndpoint, + ProjectArchiveUnarchiveAPIEndpoint, +) urlpatterns = [ path( @@ -9,8 +12,13 @@ urlpatterns = [ name="project", ), path( - "workspaces//projects//", + "workspaces//projects//", ProjectAPIEndpoint.as_view(), name="project", ), + path( + "workspaces//projects//archive/", + ProjectArchiveUnarchiveAPIEndpoint.as_view(), + name="project-archive-unarchive", + ), ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 0da79566f..d59b40fc5 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -1,8 +1,9 @@ -from .project import ProjectAPIEndpoint +from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint from .state import StateAPIEndpoint from .issue import ( + WorkspaceIssueAPIEndpoint, IssueAPIEndpoint, LabelAPIEndpoint, IssueLinkAPIEndpoint, @@ -14,8 +15,13 @@ from .cycle import ( CycleAPIEndpoint, CycleIssueAPIEndpoint, TransferCycleIssueAPIEndpoint, + CycleArchiveUnarchiveAPIEndpoint, ) -from .module import ModuleAPIEndpoint, ModuleIssueAPIEndpoint +from .module import ( + ModuleAPIEndpoint, + ModuleIssueAPIEndpoint, + ModuleArchiveUnarchiveAPIEndpoint, +) from .inbox import InboxIssueAPIEndpoint diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 146f61f48..13047eb78 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,26 +1,27 @@ # Python imports -import zoneinfo from urllib.parse import urlparse +import zoneinfo # Django imports from django.conf import settings -from django.db import IntegrityError from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError +from django.urls import resolve from django.utils import timezone +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response # Third party imports from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated -from rest_framework import status -from sentry_sdk import capture_exception # Module imports from plane.api.middleware.api_authentication import APIKeyAuthentication from plane.api.rate_limit import ApiKeyRateThrottle -from plane.utils.paginator import BasePaginator from plane.bgtasks.webhook_task import send_webhook +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator class TimezoneMixin: @@ -106,27 +107,23 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if isinstance(e, ValidationError): return Response( - { - "error": "The provided payload is not valid please try with a valid payload" - }, + {"error": "Please provide valid detail"}, status=status.HTTP_400_BAD_REQUEST, ) if isinstance(e, ObjectDoesNotExist): return Response( - {"error": "The required object does not exist."}, + {"error": "The requested resource does not exist."}, status=status.HTTP_404_NOT_FOUND, ) if isinstance(e, KeyError): return Response( - {"error": " The required key does not exist."}, + {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -169,7 +166,12 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def project_id(self): - return self.kwargs.get("project_id", None) + project_id = self.kwargs.get("project_id", None) + if project_id: + return project_id + + if resolve(self.request.path_info).url_name == "project": + return self.kwargs.get("pk", None) @property def fields(self): diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 2ae7faea4..d9c75ff41 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -2,29 +2,31 @@ import json # Django imports -from django.db.models import Q, Count, Sum, F, OuterRef, Func -from django.utils import timezone from django.core import serializers +from django.db.models import Count, F, Func, OuterRef, Q, Sum +from django.utils import timezone # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response # Module imports -from .base import BaseAPIView, WebhookMixin -from plane.db.models import ( - Cycle, - Issue, - CycleIssue, - IssueLink, - IssueAttachment, +from plane.api.serializers import ( + CycleIssueSerializer, + CycleSerializer, ) from plane.app.permissions import ProjectEntityPermission -from plane.api.serializers import ( - CycleSerializer, - CycleIssueSerializer, -) from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + IssueAttachment, + IssueLink, +) +from plane.utils.analytics_plot import burndown_plot + +from .base import BaseAPIView, WebhookMixin class CycleAPIEndpoint(WebhookMixin, BaseAPIView): @@ -140,7 +142,9 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): def get(self, request, slug, project_id, pk=None): if pk: - queryset = self.get_queryset().get(pk=pk) + queryset = ( + self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + ) data = CycleSerializer( queryset, fields=self.fields, @@ -150,7 +154,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): data, status=status.HTTP_200_OK, ) - queryset = self.get_queryset() + queryset = self.get_queryset().filter(archived_at__isnull=True) cycle_view = request.GET.get("cycle_view", "all") # Current Cycle @@ -291,6 +295,11 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) + if cycle.archived_at: + return Response( + {"error": "Archived cycle cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) request_data = request.data @@ -368,6 +377,144 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) +class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(archived_at__isnull=False) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id): + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + + def post(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + if cycle.end_date >= timezone.now().date(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + cycle.archived_at = timezone.now() + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + cycle.archived_at = None + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): """ This viewset automatically provides `list`, `create`, @@ -409,7 +556,21 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .distinct() ) - def get(self, request, slug, project_id, cycle_id): + def get(self, request, slug, project_id, cycle_id, issue_id=None): + # Get + if issue_id: + cycle_issue = CycleIssue.objects.get( + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + issue_id=issue_id, + ) + serializer = CycleIssueSerializer( + cycle_issue, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # List order_by = request.GET.get("order_by", "created_at") issues = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) @@ -585,7 +746,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): class TransferCycleIssueAPIEndpoint(BaseAPIView): """ - This viewset provides `create` actions for transfering the issues into a particular cycle. + This viewset provides `create` actions for transferring the issues into a particular cycle. """ @@ -606,6 +767,209 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): workspace__slug=slug, project_id=project_id, pk=new_cycle_id ) + old_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + ) + + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + # Get the assignee distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + # Get the label distribution + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + # Label distribution serilization + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + if current_cycle: + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + # Save the snapshot of the current cycle + current_cycle.save(update_fields=["progress_snapshot"]) + if ( new_cycle.end_date is not None and new_cycle.end_date < timezone.now().date() diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index fb36ea2a9..5e6e4a215 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -2,27 +2,28 @@ import json # Django improts -from django.utils import timezone -from django.db.models import Q from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Q +from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports -from .base import BaseAPIView -from plane.app.permissions import ProjectLitePermission from plane.api.serializers import InboxIssueSerializer, IssueSerializer +from plane.app.permissions import ProjectLitePermission +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( + Inbox, InboxIssue, Issue, - State, - ProjectMember, Project, - Inbox, + ProjectMember, + State, ) -from plane.bgtasks.issue_activites_task import issue_activity + +from .base import BaseAPIView class InboxIssueAPIEndpoint(BaseAPIView): @@ -134,10 +135,11 @@ class InboxIssueAPIEndpoint(BaseAPIView): # Create or get state state, _ = State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=project_id, color="#ff7700", + is_triage=True, ) # create an issue @@ -270,6 +272,9 @@ class InboxIssueAPIEndpoint(BaseAPIView): serializer = InboxIssueSerializer( inbox_issue, data=request.data, partial=True ) + current_instance = json.dumps( + InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder + ) if serializer.is_valid(): serializer.save() @@ -298,7 +303,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) # Update the issue state only if it is in triage state - if issue.state.name == "Triage": + if issue.state.is_triage: # Move to default state state = State.objects.filter( workspace__slug=slug, @@ -309,6 +314,21 @@ class InboxIssueAPIEndpoint(BaseAPIView): issue.state = state issue.save() + # create a activity for status change + issue_activity.delay( + type="inbox.activity.created", + 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=False, + 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 diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e2ef742b9..46a6b6937 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -32,6 +32,7 @@ from plane.api.serializers import ( LabelSerializer, ) from plane.app.permissions import ( + WorkspaceEntityPermission, ProjectEntityPermission, ProjectLitePermission, ProjectMemberPermission, @@ -51,6 +52,65 @@ from plane.db.models import ( from .base import BaseAPIView, WebhookMixin + +class WorkspaceIssueAPIEndpoint(WebhookMixin, BaseAPIView): + """ + This viewset provides `retrieveByIssueId` on workspace level + + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ + ProjectEntityPermission + ] + serializer_class = IssueSerializer + + + @property + def project__identifier(self): + return self.kwargs.get("project__identifier", None) + + 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") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__identifier=self.kwargs.get("project__identifier")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + def get(self, request, slug, project__identifier=None, issue__identifier=None): + if issue__identifier and project__identifier: + 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__identifier=project__identifier, sequence_id=issue__identifier) + return Response( + IssueSerializer( + issue, + fields=self.fields, + expand=self.expand, + ).data, + status=status.HTTP_200_OK, + ) + class IssueAPIEndpoint(WebhookMixin, BaseAPIView): """ This viewset automatically provides `list`, `create`, `retrieve`, @@ -282,7 +342,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): ) if serializer.is_valid(): if ( - str(request.data.get("external_id")) + request.data.get("external_id") and (issue.external_id != str(request.data.get("external_id"))) and Issue.objects.filter( project_id=project_id, @@ -308,8 +368,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), - external_id__isnull=False, - external_source__isnull=False, current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) @@ -357,6 +415,7 @@ class LabelAPIEndpoint(BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .select_related("project") .select_related("workspace") .select_related("parent") @@ -489,6 +548,7 @@ class IssueLinkAPIEndpoint(BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) @@ -618,6 +678,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .select_related("workspace", "project", "issue", "actor") .annotate( is_member=Exists( @@ -793,6 +854,7 @@ class IssueActivityAPIEndpoint(BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 677f65ff8..38744eaa5 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -2,32 +2,33 @@ import json # Django imports -from django.db.models import Count, Prefetch, Q, F, Func, OuterRef -from django.utils import timezone from django.core import serializers +from django.db.models import Count, F, Func, OuterRef, Prefetch, Q +from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports -from .base import BaseAPIView, WebhookMixin +from plane.api.serializers import ( + IssueSerializer, + ModuleIssueSerializer, + ModuleSerializer, +) from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( - Project, - Module, - ModuleLink, Issue, - ModuleIssue, IssueAttachment, IssueLink, + Module, + ModuleIssue, + ModuleLink, + Project, ) -from plane.api.serializers import ( - ModuleSerializer, - ModuleIssueSerializer, - IssueSerializer, -) -from plane.bgtasks.issue_activites_task import issue_activity + +from .base import BaseAPIView, WebhookMixin class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): @@ -67,6 +68,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ), ) .annotate( @@ -77,6 +79,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -87,6 +90,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -97,6 +101,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -107,6 +112,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -117,6 +123,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .order_by(self.kwargs.get("order_by", "-created_at")) @@ -165,6 +172,11 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): module = Module.objects.get( pk=pk, project_id=project_id, workspace__slug=slug ) + if module.archived_at: + return Response( + {"error": "Archived module cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = ModuleSerializer( module, data=request.data, @@ -197,7 +209,9 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): def get(self, request, slug, project_id, pk=None): if pk: - queryset = self.get_queryset().get(pk=pk) + queryset = ( + self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + ) data = ModuleSerializer( queryset, fields=self.fields, @@ -209,7 +223,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): ) return self.paginate( request=request, - queryset=(self.get_queryset()), + queryset=(self.get_queryset().filter(archived_at__isnull=True)), on_results=lambda modules: ModuleSerializer( modules, many=True, @@ -279,6 +293,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .select_related("project") .select_related("workspace") .select_related("module") @@ -446,3 +461,130 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): epoch=int(timezone.now().timestamp()), ) return Response(status=status.HTTP_204_NO_CONTENT) + + +class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + return ( + Module.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(archived_at__isnull=False) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + distinct=True, + ), + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + distinct=True, + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + distinct=True, + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + distinct=True, + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + distinct=True, + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + def get(self, request, slug, project_id, pk): + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda modules: ModuleSerializer( + modules, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + + def post(self, request, slug, project_id, pk): + module = Module.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + if module.status not in ["completed", "cancelled"]: + return Response( + { + "error": "Only completed or cancelled modules can be archived" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + module.archived_at = timezone.now() + module.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id, pk): + module = Module.objects.get( + pk=pk, project_id=project_id, workspace__slug=slug + ) + module.archived_at = None + module.save() + 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 e994dfbec..fcb0cc4fb 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,26 +1,29 @@ # Django imports from django.db import IntegrityError -from django.db.models import Exists, OuterRef, Q, F, Func, Subquery, Prefetch +from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery +from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response from rest_framework.serializers import ValidationError +from plane.api.serializers import ProjectSerializer +from plane.app.permissions import ProjectBasePermission + # Module imports from plane.db.models import ( - Workspace, - Project, - ProjectMember, - ProjectDeployBoard, - State, Cycle, - Module, - IssueProperty, Inbox, + IssueProperty, + Module, + Project, + ProjectDeployBoard, + ProjectMember, + State, + Workspace, ) -from plane.app.permissions import ProjectBasePermission -from plane.api.serializers import ProjectSerializer + from .base import BaseAPIView, WebhookMixin @@ -39,7 +42,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): return ( Project.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter( - Q(project_projectmember__member=self.request.user) + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) | Q(network=2) ) .select_related( @@ -99,8 +105,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): .distinct() ) - def get(self, request, slug, project_id=None): - if project_id is None: + def get(self, request, slug, pk=None): + if pk is None: sort_order_query = ProjectMember.objects.filter( member=request.user, project_id=OuterRef("pk"), @@ -131,7 +137,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): expand=self.expand, ).data, ) - project = self.get_queryset().get(workspace__slug=slug, pk=project_id) + project = self.get_queryset().get(workspace__slug=slug, pk=pk) serializer = ProjectSerializer( project, fields=self.fields, @@ -255,10 +261,16 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_410_GONE, ) - def patch(self, request, slug, project_id=None): + def patch(self, request, slug, pk): try: workspace = Workspace.objects.get(slug=slug) - project = Project.objects.get(pk=project_id) + project = Project.objects.get(pk=pk) + + if project.archived_at: + return Response( + {"error": "Archived project cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = ProjectSerializer( project, @@ -279,10 +291,11 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): # Create the triage state in Backlog group State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", - project_id=project_id, + project_id=pk, color="#ff7700", + is_triage=True, ) project = ( @@ -312,7 +325,26 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView): status=status.HTTP_410_GONE, ) - def delete(self, request, slug, project_id): - project = Project.objects.get(pk=project_id, workspace__slug=slug) + def delete(self, request, slug, pk): + project = Project.objects.get(pk=pk, workspace__slug=slug) project.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = timezone.now() + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = None + project.save() + 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 53ed5d6b7..024a12d07 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -1,16 +1,16 @@ # Django imports from django.db import IntegrityError -from django.db.models import Q # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.api.serializers import StateSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import Issue, State # Module imports from .base import BaseAPIView -from plane.api.serializers import StateSerializer -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import State, Issue class StateAPIEndpoint(BaseAPIView): @@ -28,7 +28,8 @@ class StateAPIEndpoint(BaseAPIView): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) - .filter(~Q(name="Triage")) + .filter(is_triage=False) + .filter(project__archived_at__isnull=True) .select_related("project") .select_related("workspace") .distinct() @@ -85,7 +86,11 @@ class StateAPIEndpoint(BaseAPIView): def get(self, request, slug, project_id, state_id=None): if state_id: - serializer = StateSerializer(self.get_queryset().get(pk=state_id)) + serializer = StateSerializer( + self.get_queryset().get(pk=state_id), + fields=self.fields, + expand=self.expand, + ) return Response(serializer.data, status=status.HTTP_200_OK) return self.paginate( request=request, @@ -100,7 +105,7 @@ class StateAPIEndpoint(BaseAPIView): def delete(self, request, slug, project_id, state_id): state = State.objects.get( - ~Q(name="Triage"), + is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug, diff --git a/apiserver/plane/app/permissions/project.py b/apiserver/plane/app/permissions/project.py index 80775cbf6..25e5aaeb0 100644 --- a/apiserver/plane/app/permissions/project.py +++ b/apiserver/plane/app/permissions/project.py @@ -1,8 +1,8 @@ # Third Party imports -from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.permissions import SAFE_METHODS, BasePermission # Module import -from plane.db.models import WorkspaceMember, ProjectMember +from plane.db.models import ProjectMember, WorkspaceMember # Permission Mappings Admin = 20 @@ -79,6 +79,16 @@ class ProjectEntityPermission(BasePermission): if request.user.is_anonymous: return False + # Handle requests based on project__identifier + if hasattr(view, "project__identifier") and view.project__identifier: + if request.method in SAFE_METHODS: + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project__identifier=view.project__identifier, + is_active=True, + ).exists() + ## Safe Methods -> Handle the filtering logic in queryset if request.method in SAFE_METHODS: return ProjectMember.objects.filter( diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 22673dabc..cd0fc11ce 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -59,6 +59,7 @@ from .issue import ( IssueFlatSerializer, IssueStateSerializer, IssueLinkSerializer, + IssueInboxSerializer, IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, @@ -92,6 +93,7 @@ from .page import ( PageSerializer, PageLogSerializer, SubPageSerializer, + PageDetailSerializer, PageFavoriteSerializer, ) @@ -107,6 +109,7 @@ from .inbox import ( InboxIssueSerializer, IssueStateInboxSerializer, InboxIssueLiteSerializer, + InboxIssueDetailSerializer, ) from .analytic import AnalyticViewSerializer diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 30e6237f1..13d321780 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -31,6 +31,7 @@ class CycleWriteSerializer(BaseSerializer): "workspace", "project", "owned_by", + "archived_at", ] diff --git a/apiserver/plane/app/serializers/inbox.py b/apiserver/plane/app/serializers/inbox.py index 1dc6f1f4a..e0c18b3d1 100644 --- a/apiserver/plane/app/serializers/inbox.py +++ b/apiserver/plane/app/serializers/inbox.py @@ -3,7 +3,11 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import IssueFlatSerializer, LabelLiteSerializer +from .issue import ( + IssueInboxSerializer, + LabelLiteSerializer, + IssueDetailSerializer, +) from .project import ProjectLiteSerializer from .state import StateLiteSerializer from .user import UserLiteSerializer @@ -24,17 +28,62 @@ class InboxSerializer(BaseSerializer): class InboxIssueSerializer(BaseSerializer): - issue_detail = IssueFlatSerializer(source="issue", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) + issue = IssueInboxSerializer(read_only=True) class Meta: model = InboxIssue - fields = "__all__" + fields = [ + "id", + "status", + "duplicate_to", + "snoozed_till", + "source", + "issue", + "created_by", + ] read_only_fields = [ "project", "workspace", ] + def to_representation(self, instance): + # Pass the annotated fields to the Issue instance if they exist + if hasattr(instance, "label_ids"): + instance.issue.label_ids = instance.label_ids + return super().to_representation(instance) + + +class InboxIssueDetailSerializer(BaseSerializer): + issue = IssueDetailSerializer(read_only=True) + duplicate_issue_detail = IssueInboxSerializer( + read_only=True, source="duplicate_to" + ) + + class Meta: + model = InboxIssue + fields = [ + "id", + "status", + "duplicate_to", + "snoozed_till", + "duplicate_issue_detail", + "source", + "issue", + ] + read_only_fields = [ + "project", + "workspace", + ] + + def to_representation(self, instance): + # Pass the annotated fields to the Issue instance if they exist + if hasattr(instance, "assignee_ids"): + instance.issue.assignee_ids = instance.assignee_ids + if hasattr(instance, "label_ids"): + instance.issue.label_ids = instance.label_ids + + return super().to_representation(instance) + class InboxIssueLiteSerializer(BaseSerializer): class Meta: diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 45f844cf0..8c641b720 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -533,8 +533,8 @@ class IssueReactionLiteSerializer(DynamicBaseSerializer): model = IssueReaction fields = [ "id", - "actor_id", - "issue_id", + "actor", + "issue", "reaction", ] @@ -620,6 +620,26 @@ class IssueStateSerializer(DynamicBaseSerializer): fields = "__all__" +class IssueInboxSerializer(DynamicBaseSerializer): + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "priority", + "sequence_id", + "project_id", + "created_at", + "label_ids", + ] + read_only_fields = fields + + class IssueSerializer(DynamicBaseSerializer): # ids cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) @@ -688,7 +708,7 @@ class IssueLiteSerializer(DynamicBaseSerializer): class IssueDetailSerializer(IssueSerializer): description_html = serializers.CharField() - is_subscribed = serializers.BooleanField() + is_subscribed = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): fields = IssueSerializer.Meta.fields + [ diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 100b6314a..687747242 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -39,6 +39,7 @@ class ModuleWriteSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", + "archived_at", ] def to_representation(self, instance): @@ -209,6 +210,7 @@ class ModuleSerializer(DynamicBaseSerializer): "backlog_issues", "created_at", "updated_at", + "archived_at", ] read_only_fields = fields diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 4dfe6ea9d..604ac2c2e 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -3,9 +3,6 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import LabelLiteSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer from plane.db.models import ( Page, PageLog, @@ -17,22 +14,33 @@ from plane.db.models import ( class PageSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=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 - ) class Meta: model = Page - fields = "__all__" + fields = [ + "id", + "name", + "owned_by", + "access", + "color", + "labels", + "parent", + "is_favorite", + "is_locked", + "archived_at", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + "view_props", + ] read_only_fields = [ "workspace", "project", @@ -48,8 +56,12 @@ class PageSerializer(BaseSerializer): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] + description_html = self.context["description_html"] page = Page.objects.create( - **validated_data, project_id=project_id, owned_by_id=owned_by_id + **validated_data, + description_html=description_html, + project_id=project_id, + owned_by_id=owned_by_id, ) if labels is not None: @@ -91,6 +103,13 @@ class PageSerializer(BaseSerializer): return super().update(instance, validated_data) +class PageDetailSerializer(PageSerializer): + description_html = serializers.CharField() + + class Meta(PageSerializer.Meta): + fields = PageSerializer.Meta.fields + ["description_html"] + + class SubPageSerializer(BaseSerializer): entity_details = serializers.SerializerMethodField() diff --git a/apiserver/plane/app/urls/cycle.py b/apiserver/plane/app/urls/cycle.py index 740b0ab43..ce2e0f6dc 100644 --- a/apiserver/plane/app/urls/cycle.py +++ b/apiserver/plane/app/urls/cycle.py @@ -8,6 +8,7 @@ from plane.app.views import ( CycleFavoriteViewSet, TransferCycleIssueEndpoint, CycleUserPropertiesEndpoint, + CycleArchiveUnarchiveEndpoint, ) @@ -90,4 +91,19 @@ urlpatterns = [ CycleUserPropertiesEndpoint.as_view(), name="cycle-user-filters", ), + path( + "workspaces//projects//cycles//archive/", + CycleArchiveUnarchiveEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles/", + CycleArchiveUnarchiveEndpoint.as_view(), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles//", + CycleArchiveUnarchiveEndpoint.as_view(), + name="cycle-archive-unarchive", + ), ] diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py index e9ec4e335..b6848244b 100644 --- a/apiserver/plane/app/urls/inbox.py +++ b/apiserver/plane/app/urls/inbox.py @@ -30,7 +30,7 @@ urlpatterns = [ name="inbox", ), path( - "workspaces//projects//inboxes//inbox-issues/", + "workspaces//projects//inbox-issues/", InboxIssueViewSet.as_view( { "get": "list", @@ -40,7 +40,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//projects//inboxes//inbox-issues//", + "workspaces//projects//inbox-issues//", InboxIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py index 981b4d1fb..bf6c84b2f 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, ModuleUserPropertiesEndpoint, + ModuleArchiveUnarchiveEndpoint, ) @@ -110,4 +111,19 @@ urlpatterns = [ ModuleUserPropertiesEndpoint.as_view(), name="cycle-user-filters", ), + path( + "workspaces//projects//modules//archive/", + ModuleArchiveUnarchiveEndpoint.as_view(), + name="module-archive-unarchive", + ), + path( + "workspaces//projects//archived-modules/", + ModuleArchiveUnarchiveEndpoint.as_view(), + name="module-archive-unarchive", + ), + path( + "workspaces//projects//archived-modules//", + ModuleArchiveUnarchiveEndpoint.as_view(), + name="module-archive-unarchive", + ), ] diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 58cec2cd4..1a73e4ed3 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -31,102 +31,51 @@ urlpatterns = [ ), name="project-pages", ), + # favorite pages path( - "workspaces//projects//user-favorite-pages/", + "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view( { - "get": "list", "post": "create", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//user-favorite-pages//", - PageFavoriteViewSet.as_view( - { "delete": "destroy", } ), name="user-favorite-pages", ), + # archived pages path( - "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//", - PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//archive/", + "workspaces//projects//pages//archive/", PageViewSet.as_view( { "post": "archive", + "delete": "unarchive", } ), - name="project-page-archive", + name="project-page-archive-unarchive", ), + # lock and unlock path( - "workspaces//projects//pages//unarchive/", - PageViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-page-unarchive", - ), - path( - "workspaces//projects//archived-pages/", - PageViewSet.as_view( - { - "get": "archive_list", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//lock/", + "workspaces//projects//pages//lock/", PageViewSet.as_view( { "post": "lock", + "delete": "unlock", } ), - name="project-pages", + name="project-pages-lock-unlock", ), path( - "workspaces//projects//pages//unlock/", - PageViewSet.as_view( - { - "post": "unlock", - } - ), - ), - path( - "workspaces//projects//pages//transactions/", + "workspaces//projects//pages//transactions/", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//transactions//", + "workspaces//projects//pages//transactions//", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//sub-pages/", + "workspaces//projects//pages//sub-pages/", SubPagesEndpoint.as_view(), name="sub-page", ), diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index f8ecac4c0..7ea636df8 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -14,6 +14,7 @@ from plane.app.views import ( ProjectPublicCoverImagesEndpoint, ProjectDeployBoardViewSet, UserProjectRolesEndpoint, + ProjectArchiveUnarchiveEndpoint, ) @@ -175,4 +176,9 @@ urlpatterns = [ ), name="project-deploy-board", ), + path( + "workspaces//projects//archive/", + ProjectArchiveUnarchiveEndpoint.as_view(), + name="project-archive-unarchive", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index bb5b7dd74..3d7603e24 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -5,6 +5,7 @@ from .project.base import ( ProjectFavoritesViewSet, ProjectPublicCoverImagesEndpoint, ProjectDeployBoardViewSet, + ProjectArchiveUnarchiveEndpoint, ) from .project.invite import ( @@ -37,7 +38,7 @@ from .workspace.base import ( WorkSpaceAvailabilityCheckEndpoint, UserWorkspaceDashboardEndpoint, WorkspaceThemeViewSet, - ExportWorkspaceUserActivityEndpoint + ExportWorkspaceUserActivityEndpoint, ) from .workspace.member import ( @@ -95,6 +96,9 @@ from .cycle.base import ( from .cycle.issue import ( CycleIssueViewSet, ) +from .cycle.archive import ( + CycleArchiveUnarchiveEndpoint, +) from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue.base import ( @@ -175,6 +179,10 @@ from .module.issue import ( ModuleIssueViewSet, ) +from .module.archive import ( + ModuleArchiveUnarchiveEndpoint, +) + from .api import ApiTokenEndpoint diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index cdba62350..1908cfdc9 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -1,27 +1,27 @@ # Python imports import zoneinfo +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError # Django imports from django.urls import resolve -from django.conf import settings from django.utils import timezone -from django.db import IntegrityError -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django_filters.rest_framework import DjangoFilterBackend # Third part imports from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response from rest_framework.exceptions import APIException -from rest_framework.views import APIView from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated -from sentry_sdk import capture_exception -from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet # Module imports -from plane.utils.paginator import BasePaginator from plane.bgtasks.webhook_task import send_webhook +from plane.utils.exception_logger import log_exception +from plane.utils.paginator import BasePaginator class TimezoneMixin: @@ -87,7 +87,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): try: return self.model.objects.all() except Exception as e: - capture_exception(e) + log_exception(e) raise APIException( "Please check the view", status.HTTP_400_BAD_REQUEST ) @@ -121,13 +121,13 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) if isinstance(e, KeyError): - capture_exception(e) + log_exception(e) return Response( {"error": "The required key does not exist."}, status=status.HTTP_400_BAD_REQUEST, ) - capture_exception(e) + log_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -233,9 +233,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): status=status.HTTP_400_BAD_REQUEST, ) - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py new file mode 100644 index 000000000..e6d82795a --- /dev/null +++ b/apiserver/plane/app/views/cycle/archive.py @@ -0,0 +1,409 @@ +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) +from django.db.models.functions import Coalesce +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Cycle, + CycleFavorite, + Issue, + Label, + User, +) +from plane.utils.analytics_plot import burndown_plot + +# Module imports +from .. import BaseAPIView + + +class CycleArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + favorite_subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(archived_at__isnull=False) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only( + "avatar", "first_name", "id" + ).distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only( + "name", "color", "id" + ).distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When( + start_date__gt=timezone.now(), then=Value("UPCOMING") + ), + When(end_date__lt=timezone.now(), then=Value("COMPLETED")), + When( + Q(start_date__isnull=True) & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=True, + filter=~Q( + issue_cycle__issue__assignees__id__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .order_by("-is_favorite", "name") + .distinct() + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = ( + self.get_queryset() + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + "archived_at", + ) + ).order_by("-is_favorite", "-created_at") + return Response(queryset, status=status.HTTP_200_OK) + else: + queryset = ( + self.get_queryset() + .filter(archived_at__isnull=False) + .filter(pk=pk) + ) + data = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=False, + issue_cycle__cycle_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "sub_issues", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + queryset = queryset.first() + + if data is None: + return Response( + {"error": "Cycle does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Assignee Distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .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", + ) + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + # Label Distribution + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + 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, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def post(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + + if cycle.end_date >= timezone.now().date(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle.archived_at = timezone.now() + cycle.save() + return Response( + {"archived_at": str(cycle.archived_at)}, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + cycle.archived_at = None + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index e777a93a6..dd9826c56 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -2,61 +2,53 @@ import json # Django imports -from django.db.models import ( - Func, - F, - Q, - Exists, - OuterRef, - Count, - Prefetch, - Case, - When, - Value, - CharField, -) -from django.core import serializers -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) from django.db.models.functions import Coalesce +from django.utils import timezone # Third party imports -from rest_framework.response import Response from rest_framework import status - -# Module imports -from .. import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - CycleSerializer, - CycleIssueSerializer, - CycleFavoriteSerializer, - IssueSerializer, - CycleWriteSerializer, - CycleUserPropertiesSerializer, -) +from rest_framework.response import Response from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, ) -from plane.db.models import ( - User, - Cycle, - CycleIssue, - Issue, - CycleFavorite, - IssueLink, - IssueAttachment, - Label, - CycleUserProperties, +from plane.app.serializers import ( + CycleFavoriteSerializer, + CycleSerializer, + CycleUserPropertiesSerializer, + CycleWriteSerializer, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.issue_filters import issue_filters +from plane.db.models import ( + Cycle, + CycleFavorite, + CycleIssue, + CycleUserProperties, + Issue, + Label, + User, +) from plane.utils.analytics_plot import burndown_plot +# Module imports +from .. import BaseAPIView, BaseViewSet, WebhookMixin + class CycleViewSet(WebhookMixin, BaseViewSet): serializer_class = CycleSerializer @@ -88,6 +80,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .select_related("project", "workspace", "owned_by") .prefetch_related( Prefetch( @@ -106,9 +99,20 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) ) .annotate(is_favorite=Exists(favorite_subquery)) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) .annotate( completed_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, @@ -118,7 +122,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( cancelled_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, @@ -128,7 +133,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( started_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, @@ -138,7 +144,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( unstarted_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, @@ -148,7 +155,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( backlog_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, @@ -192,15 +200,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def list(self, request, slug, project_id): - queryset = self.get_queryset().annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) + queryset = self.get_queryset().filter(archived_at__isnull=True) cycle_view = request.GET.get("cycle_view", "all") # Update the order by @@ -354,8 +354,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "external_id", "progress_snapshot", # meta fields - "total_issues", "is_favorite", + "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -402,6 +402,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): # meta fields "is_favorite", "cancelled_issues", + "total_issues", "completed_issues", "started_issues", "unstarted_issues", @@ -428,6 +429,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): workspace__slug=slug, project_id=project_id, pk=pk ) cycle = queryset.first() + if cycle.archived_at: + return Response( + {"error": "Archived cycle cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) request_data = request.data if ( @@ -472,6 +478,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", + "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -485,31 +492,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): def retrieve(self, request, slug, project_id, pk): queryset = ( - self.get_queryset() - .filter(pk=pk) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) + self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) ) data = ( self.get_queryset() .filter(pk=pk) - .annotate( - total_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id"), - parent__isnull=True, - issue_cycle__cycle_id=pk, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .annotate( sub_issues=Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), @@ -551,6 +538,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .first() ) queryset = queryset.first() + + if data is None: + return Response( + {"error": "Cycle does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Assignee Distribution assignee_distribution = ( Issue.objects.filter( diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 84af4ff32..2a5505dd0 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -74,6 +74,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, ) + .filter(project__archived_at__isnull=True) .filter(cycle_id=self.kwargs.get("cycle_id")) .select_related("project") .select_related("workspace") @@ -142,7 +143,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 27e45f59c..9558348d9 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -38,7 +38,6 @@ from plane.db.models import ( IssueLink, IssueAttachment, IssueRelation, - IssueAssignee, User, ) from plane.app.serializers import ( @@ -150,7 +149,8 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -304,7 +304,8 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -472,6 +473,7 @@ def dashboard_recent_activity(self, request, slug): workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, actor=request.user, ).select_related("actor", "workspace", "issue", "project")[:8] @@ -487,6 +489,7 @@ def dashboard_recent_projects(self, request, slug): workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, actor=request.user, ) .values_list("project_id", flat=True) @@ -501,6 +504,7 @@ def dashboard_recent_projects(self, request, slug): additional_projects = Project.objects.filter( project_projectmember__member=request.user, project_projectmember__is_active=True, + archived_at__isnull=True, workspace__slug=slug, ).exclude(id__in=unique_project_ids) @@ -523,6 +527,7 @@ def dashboard_recent_collaborators(self, request, slug): actor=OuterRef("member"), project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .values("actor") .annotate(num_activities=Count("pk")) @@ -535,6 +540,7 @@ def dashboard_recent_collaborators(self, request, slug): workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .annotate( num_activities=Coalesce( @@ -565,14 +571,16 @@ def dashboard_recent_collaborators(self, request, slug): return self.paginate( request=request, queryset=project_members_with_activities, - controller=self.get_results_controller, + controller=lambda qs: self.get_results_controller(qs, slug), ) class DashboardEndpoint(BaseAPIView): - def get_results_controller(self, project_members_with_activities): + def get_results_controller(self, project_members_with_activities, slug): user_active_issue_counts = ( - User.objects.filter(id__in=project_members_with_activities) + User.objects.filter( + id__in=project_members_with_activities, + ) .annotate( active_issue_count=Count( Case( @@ -581,10 +589,13 @@ class DashboardEndpoint(BaseAPIView): "unstarted", "started", ], - then=1, + issue_assignee__issue__workspace__slug=slug, + issue_assignee__issue__project__project_projectmember__is_active=True, + then=F("issue_assignee__issue__id"), ), output_field=IntegerField(), - ) + ), + distinct=True, ) ) .values("active_issue_count", user_id=F("id")) diff --git a/apiserver/plane/app/views/exporter/base.py b/apiserver/plane/app/views/exporter/base.py index 846508515..698d9eb99 100644 --- a/apiserver/plane/app/views/exporter/base.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -29,7 +29,10 @@ class ExportIssuesEndpoint(BaseAPIView): if provider in ["csv", "xlsx", "json"]: if not project_ids: project_ids = Project.objects.filter( - workspace__slug=slug + workspace__slug=slug, + project_projectmember__member=request.user, + project_projectmember__is_active=True, + archived_at__isnull=True, ).values_list("id", flat=True) project_ids = [str(project_id) for project_id in project_ids] diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index fb3b9227f..8e433a127 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -3,7 +3,7 @@ import json # Django import from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch from django.core.serializers.json import DjangoJSONEncoder from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField @@ -24,16 +24,15 @@ from plane.db.models import ( State, IssueLink, IssueAttachment, + Project, ProjectMember, - IssueReaction, - IssueSubscriber, ) from plane.app.serializers import ( IssueCreateSerializer, IssueSerializer, InboxSerializer, InboxIssueSerializer, - IssueDetailSerializer, + InboxIssueDetailSerializer, ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activites_task import issue_activity @@ -64,13 +63,20 @@ class InboxViewSet(BaseViewSet): .select_related("workspace", "project") ) + def list(self, request, slug, project_id): + inbox = self.get_queryset().first() + return Response( + InboxSerializer(inbox).data, + status=status.HTTP_200_OK, + ) + def perform_create(self, serializer): serializer.save(project_id=self.kwargs.get("project_id")) def destroy(self, request, slug, project_id, pk): - inbox = Inbox.objects.get( + inbox = Inbox.objects.filter( workspace__slug=slug, project_id=project_id, pk=pk - ) + ).first() # Handle default inbox delete if inbox.is_default: return Response( @@ -98,7 +104,6 @@ class InboxIssueViewSet(BaseViewSet): Issue.objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_inbox__inbox_id=self.kwargs.get("inbox_id"), ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") @@ -146,7 +151,8 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -161,51 +167,49 @@ class InboxIssueViewSet(BaseViewSet): ) ).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") - ) - if self.expand: - issues = IssueSerializer( - issue_queryset, expand=self.expand, many=True - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + def list(self, request, slug, project_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + filters = issue_filters(request.GET, "GET", "issue__") + inbox_issue = ( + InboxIssue.objects.filter( + inbox_id=inbox_id.id, project_id=project_id, **filters ) - return Response( - issues, - status=status.HTTP_200_OK, + .select_related("issue") + .prefetch_related( + "issue__labels", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + ).order_by(request.GET.get("order_by", "-issue__created_at")) + # inbox status filter + inbox_status = [ + item + for item in request.GET.get("status", "-2").split(",") + if item != "null" + ] + if inbox_status: + inbox_issue = inbox_issue.filter(status__in=inbox_status) + + return self.paginate( + request=request, + queryset=(inbox_issue), + on_results=lambda inbox_issues: InboxIssueSerializer( + inbox_issues, + many=True, + ).data, ) - def create(self, request, slug, project_id, inbox_id): + def create(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( {"error": "Name is required"}, @@ -228,49 +232,88 @@ class InboxIssueViewSet(BaseViewSet): # Create or get state state, _ = State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=project_id, color="#ff7700", + is_triage=True, ) # create an issue - issue = Issue.objects.create( - name=request.data.get("issue", {}).get("name"), - description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get( - "description_html", "

" - ), - priority=request.data.get("issue", {}).get("priority", "low"), - project_id=project_id, - state=state, + project = Project.objects.get(pk=project_id) + serializer = IssueCreateSerializer( + data=request.data.get("issue"), + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, ) + if serializer.is_valid(): + serializer.save() + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data["id"]), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + # create an inbox issue + inbox_issue = InboxIssue.objects.create( + inbox_id=inbox_id.id, + project_id=project_id, + issue_id=serializer.data["id"], + source=request.data.get("source", "in-app"), + ) + inbox_issue = ( + InboxIssue.objects.select_related("issue") + .prefetch_related( + "issue__labels", + "issue__assignees", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .get( + inbox_id=inbox_id.id, + issue_id=serializer.data["id"], + project_id=project_id, + ) + ) + serializer = InboxIssueDetailSerializer(inbox_issue) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) - # Create an Issue Activity - issue_activity.delay( - type="issue.activity.created", - 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=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - # create an inbox issue - InboxIssue.objects.create( - inbox_id=inbox_id, - project_id=project_id, - issue=issue, - source=request.data.get("source", "in-app"), - ) - - issue = self.get_queryset().filter(pk=issue.id).first() - serializer = IssueSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) - - def partial_update(self, request, slug, project_id, inbox_id, issue_id): + def partial_update(self, request, slug, project_id, issue_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() inbox_issue = InboxIssue.objects.get( issue_id=issue_id, workspace__slug=slug, @@ -295,9 +338,12 @@ class InboxIssueViewSet(BaseViewSet): # Get issue data issue_data = request.data.pop("issue", False) - if bool(issue_data): - issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first() + issue = Issue.objects.get( + 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: # viewers and guests since only viewers and guests @@ -345,7 +391,9 @@ class InboxIssueViewSet(BaseViewSet): serializer = InboxIssueSerializer( inbox_issue, data=request.data, partial=True ) - + current_instance = json.dumps( + InboxIssueSerializer(inbox_issue).data, cls=DjangoJSONEncoder + ) if serializer.is_valid(): serializer.save() # Update the issue state if the issue is rejected or marked as duplicate @@ -373,7 +421,7 @@ class InboxIssueViewSet(BaseViewSet): ) # Update the issue state only if it is in triage state - if issue.state.name == "Triage": + if issue.state.is_triage: # Move to default state state = State.objects.filter( workspace__slug=slug, @@ -383,60 +431,108 @@ class InboxIssueViewSet(BaseViewSet): if state is not None: issue.state = state issue.save() - return Response(status=status.HTTP_204_NO_CONTENT) + # create a activity for status change + issue_activity.delay( + type="inbox.activity.created", + 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=False, + origin=request.META.get("HTTP_ORIGIN"), + ) + + inbox_issue = ( + InboxIssue.objects.filter( + inbox_id=inbox_id.id, + issue_id=serializer.data["id"], + project_id=project_id, + ) + .select_related("issue") + .prefetch_related( + "issue__labels", + "issue__assignees", + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value( + [], + output_field=ArrayField(UUIDField()), + ), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value( + [], + output_field=ArrayField(UUIDField()), + ), + ), + ).first() + ) + serializer = InboxIssueDetailSerializer(inbox_issue).data + return Response(serializer, status=status.HTTP_200_OK) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) else: - issue = self.get_queryset().filter(pk=issue_id).first() - serializer = IssueSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) + serializer = InboxIssueDetailSerializer(inbox_issue).data + return Response(serializer, status=status.HTTP_200_OK) - def retrieve(self, request, slug, project_id, inbox_id, issue_id): - issue = ( - self.get_queryset() - .filter(pk=issue_id) + def retrieve(self, request, slug, project_id, issue_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id + ).first() + inbox_issue = ( + InboxIssue.objects.select_related("issue") .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related("created_by"), - ) + "issue__labels", + "issue__assignees", ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=OuterRef("pk"), - subscriber=request.user, - ) - ) + label_ids=Coalesce( + ArrayAgg( + "issue__labels__id", + distinct=True, + filter=~Q(issue__labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "issue__assignees__id", + distinct=True, + filter=~Q(issue__assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) + .get( + inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id + ) + ) + issue = InboxIssueDetailSerializer(inbox_issue).data + return Response( + issue, + status=status.HTTP_200_OK, + ) + + def destroy(self, request, slug, project_id, issue_id): + inbox_id = Inbox.objects.filter( + workspace__slug=slug, project_id=project_id ).first() - if issue is None: - return Response( - {"error": "Requested object was not found"}, - status=status.HTTP_404_NOT_FOUND, - ) - - serializer = IssueDetailSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, inbox_id, issue_id): inbox_issue = InboxIssue.objects.get( issue_id=issue_id, workspace__slug=slug, diff --git a/apiserver/plane/app/views/issue/activity.py b/apiserver/plane/app/views/issue/activity.py index ea6e9b389..6815b254e 100644 --- a/apiserver/plane/app/views/issue/activity.py +++ b/apiserver/plane/app/views/issue/activity.py @@ -44,6 +44,7 @@ class IssueActivityEndpoint(BaseAPIView): ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) .filter(**filters) @@ -54,6 +55,7 @@ class IssueActivityEndpoint(BaseAPIView): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) .filter(**filters) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 540715a24..d9274ae4f 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -105,7 +105,8 @@ class IssueArchiveViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index 63d4358b0..23df58540 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -1,84 +1,59 @@ # Python imports import json -import random -from itertools import chain + +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Case, + CharField, + Exists, + F, + Func, + Max, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) +from django.db.models.functions import Coalesce # Django imports from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, -) -from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.db import IntegrityError -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models import Value, UUIDField -from django.db.models.functions import Coalesce +from rest_framework import status # Third Party imports from rest_framework.response import Response -from rest_framework import status -from rest_framework.parsers import MultiPartParser, FormParser -# Module imports -from .. import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - IssueActivitySerializer, - IssueCommentSerializer, - IssuePropertySerializer, - IssueSerializer, - IssueCreateSerializer, - LabelSerializer, - IssueFlatSerializer, - IssueLinkSerializer, - IssueLiteSerializer, - IssueAttachmentSerializer, - IssueSubscriberSerializer, - ProjectMemberLiteSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueRelationSerializer, - RelatedIssueSerializer, - IssueDetailSerializer, -) from plane.app.permissions import ( ProjectEntityPermission, - WorkSpaceAdminPermission, - ProjectMemberPermission, ProjectLitePermission, ) -from plane.db.models import ( - Project, - Issue, - IssueActivity, - IssueComment, - IssueProperty, - Label, - IssueLink, - IssueAttachment, - IssueSubscriber, - ProjectMember, - IssueReaction, - CommentReaction, - IssueRelation, +from plane.app.serializers import ( + IssueCreateSerializer, + IssueDetailSerializer, + IssuePropertySerializer, + IssueSerializer, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueProperty, + IssueReaction, + IssueSubscriber, + Project, +) from plane.utils.issue_filters import issue_filters -from collections import defaultdict -from plane.utils.cache import invalidate_cache + +# Module imports +from .. import BaseAPIView, BaseViewSet, WebhookMixin + class IssueListEndpoint(BaseAPIView): @@ -142,7 +117,8 @@ class IssueListEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -336,7 +312,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index eb2d5834c..0d61f1325 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -48,6 +48,7 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .select_related("project") .select_related("workspace") @@ -163,6 +164,7 @@ class CommentReactionViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .order_by("-created_at") .distinct() diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index 08032934b..077d7dcaf 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -2,51 +2,52 @@ import json # Django imports -from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - UUIDField, -) -from django.core.serializers.json import DjangoJSONEncoder -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Case, + CharField, + Exists, + F, + Func, + Max, + OuterRef, + Prefetch, + Q, + UUIDField, + Value, + When, +) from django.db.models.functions import Coalesce +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.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ProjectEntityPermission +from plane.app.serializers import ( + IssueCreateSerializer, + IssueDetailSerializer, + IssueFlatSerializer, + IssueSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueReaction, + IssueSubscriber, + Project, +) +from plane.utils.issue_filters import issue_filters # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueSerializer, - IssueCreateSerializer, - IssueFlatSerializer, - IssueDetailSerializer, -) -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - Project, - Issue, - IssueLink, - IssueAttachment, - IssueSubscriber, - IssueReaction, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.issue_filters import issue_filters class IssueDraftViewSet(BaseViewSet): @@ -99,7 +100,8 @@ class IssueDraftViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -117,12 +119,6 @@ class IssueDraftViewSet(BaseViewSet): @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 = [ diff --git a/apiserver/plane/app/views/issue/label.py b/apiserver/plane/app/views/issue/label.py index 557c2018f..c5dc35809 100644 --- a/apiserver/plane/app/views/issue/label.py +++ b/apiserver/plane/app/views/issue/label.py @@ -87,7 +87,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView): Label( name=label.get("name", "Migrated"), description=label.get("description", "Migrated Issue"), - color="#" + "%06x" % random.randint(0, 0xFFFFFF), + color=f"#{random.randint(0, 0xFFFFFF+1):06X}", project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, diff --git a/apiserver/plane/app/views/issue/link.py b/apiserver/plane/app/views/issue/link.py index ca3290759..c965a7d4d 100644 --- a/apiserver/plane/app/views/issue/link.py +++ b/apiserver/plane/app/views/issue/link.py @@ -35,6 +35,7 @@ class IssueLinkViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .order_by("-created_at") .distinct() diff --git a/apiserver/plane/app/views/issue/reaction.py b/apiserver/plane/app/views/issue/reaction.py index c6f6823be..da8f6ebb5 100644 --- a/apiserver/plane/app/views/issue/reaction.py +++ b/apiserver/plane/app/views/issue/reaction.py @@ -34,6 +34,7 @@ class IssueReactionViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .order_by("-created_at") .distinct() diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index 45a5dc9a7..eb5aff9af 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -41,6 +41,7 @@ class IssueRelationViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 6ec4a2de1..da479e0e9 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -83,7 +83,8 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/subscriber.py b/apiserver/plane/app/views/issue/subscriber.py index 61e09e4a2..dc727de28 100644 --- a/apiserver/plane/app/views/issue/subscriber.py +++ b/apiserver/plane/app/views/issue/subscriber.py @@ -54,6 +54,7 @@ class IssueSubscriberViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .order_by("-created_at") .distinct() diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py new file mode 100644 index 000000000..9c0b6cca3 --- /dev/null +++ b/apiserver/plane/app/views/module/archive.py @@ -0,0 +1,356 @@ +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + Count, + Exists, + F, + Func, + IntegerField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, +) +from django.db.models.functions import Coalesce +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.app.serializers import ( + ModuleDetailSerializer, +) +from plane.db.models import ( + Issue, + Module, + ModuleFavorite, + ModuleLink, +) +from plane.utils.analytics_plot import burndown_plot + +# Module imports +from .. import BaseAPIView + + +class ModuleArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get_queryset(self): + favorite_subquery = ModuleFavorite.objects.filter( + user=self.request.user, + module_id=OuterRef("pk"), + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ) + cancelled_issues = ( + Issue.issue_objects.filter( + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + completed_issues = ( + Issue.issue_objects.filter( + state__group="completed", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + started_issues = ( + Issue.issue_objects.filter( + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + unstarted_issues = ( + Issue.issue_objects.filter( + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + backlog_issues = ( + Issue.issue_objects.filter( + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + total_issues = ( + Issue.issue_objects.filter( + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + return ( + Module.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(archived_at__isnull=False) + .annotate(is_favorite=Exists(favorite_subquery)) + .select_related("workspace", "project", "lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), + ) + ) + .annotate( + completed_issues=Coalesce( + Subquery(completed_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + cancelled_issues=Coalesce( + Subquery(cancelled_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + started_issues=Coalesce( + Subquery(started_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + unstarted_issues=Coalesce( + Subquery(unstarted_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + backlog_issues=Coalesce( + Subquery(backlog_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + total_issues=Coalesce( + Subquery(total_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=~Q(members__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .order_by("-is_favorite", "-created_at") + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + queryset = self.get_queryset() + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "total_issues", + "is_favorite", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + "archived_at", + ) + return Response(modules, status=status.HTTP_200_OK) + else: + queryset = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=False, + issue_module__module_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .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", + ) + .annotate( + total_issues=Count( + "id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + data = ModuleDetailSerializer(queryset.first()).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + # Fetch the modules + modules = queryset.first() + if modules and modules.start_date and modules.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + module_id=pk, + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + def post(self, request, slug, project_id, module_id): + module = Module.objects.get( + pk=module_id, project_id=project_id, workspace__slug=slug + ) + if module.status not in ["completed", "cancelled"]: + return Response( + { + "error": "Only completed or cancelled modules can be archived" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + module.archived_at = timezone.now() + module.save() + return Response( + {"archived_at": str(module.archived_at)}, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, module_id): + module = Module.objects.get( + pk=module_id, project_id=project_id, workspace__slug=slug + ) + module.archived_at = None + module.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 881730d65..4cd52b3b1 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -1,44 +1,57 @@ # Python imports import json -# Django Imports -from django.utils import timezone -from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q, Func from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import Value, UUIDField +from django.db.models import ( + Count, + Exists, + F, + Func, + IntegerField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, +) from django.db.models.functions import Coalesce +# Django Imports +from django.utils import timezone +from rest_framework import status + # Third party imports from rest_framework.response import Response -from rest_framework import status -# Module imports -from .. import BaseViewSet, BaseAPIView, WebhookMixin -from plane.app.serializers import ( - ModuleWriteSerializer, - ModuleSerializer, - ModuleLinkSerializer, - ModuleFavoriteSerializer, - ModuleUserPropertiesSerializer, - ModuleDetailSerializer, -) from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, ) -from plane.db.models import ( - Module, - ModuleIssue, - Project, - Issue, - ModuleLink, - ModuleFavorite, - ModuleUserProperties, +from plane.app.serializers import ( + ModuleDetailSerializer, + ModuleFavoriteSerializer, + ModuleLinkSerializer, + ModuleSerializer, + ModuleUserPropertiesSerializer, + ModuleWriteSerializer, ) from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + Module, + ModuleFavorite, + ModuleIssue, + ModuleLink, + ModuleUserProperties, + Project, +) from plane.utils.analytics_plot import burndown_plot +# Module imports +from .. import BaseAPIView, BaseViewSet, WebhookMixin + class ModuleViewSet(WebhookMixin, BaseViewSet): model = Module @@ -61,6 +74,59 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + cancelled_issues = ( + Issue.issue_objects.filter( + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + completed_issues = ( + Issue.issue_objects.filter( + state__group="completed", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + started_issues = ( + Issue.issue_objects.filter( + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + unstarted_issues = ( + Issue.issue_objects.filter( + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + backlog_issues = ( + Issue.issue_objects.filter( + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + total_issues = ( + Issue.issue_objects.filter( + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) return ( super() .get_queryset() @@ -80,62 +146,39 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) ) .annotate( - total_issues=Count( - "issue_module", - filter=Q( - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - ), - ) - .annotate( - completed_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="completed", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), + completed_issues=Coalesce( + Subquery(completed_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - cancelled_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="cancelled", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), + cancelled_issues=Coalesce( + Subquery(cancelled_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - started_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="started", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), + started_issues=Coalesce( + Subquery(started_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - unstarted_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="unstarted", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), + unstarted_issues=Coalesce( + Subquery(unstarted_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - backlog_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="backlog", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), + backlog_issues=Coalesce( + Subquery(backlog_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + total_issues=Coalesce( + Subquery(total_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( @@ -185,6 +228,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "is_favorite", "cancelled_issues", "completed_issues", + "total_issues", "started_issues", "unstarted_issues", "backlog_issues", @@ -196,7 +240,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request, slug, project_id): - queryset = self.get_queryset() + queryset = self.get_queryset().filter(archived_at__isnull=True) if self.fields: modules = ModuleSerializer( queryset, @@ -238,17 +282,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): def retrieve(self, request, slug, project_id, pk): queryset = ( self.get_queryset() + .filter(archived_at__isnull=True) .filter(pk=pk) - .annotate( - total_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id"), - parent__isnull=True, - issue_module__module_id=pk, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .annotate( sub_issues=Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), @@ -360,9 +395,11 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "completion_chart": {}, } - if queryset.first().start_date and queryset.first().target_date: + # Fetch the modules + modules = queryset.first() + if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset.first(), + queryset=modules, slug=slug, project_id=project_id, module_id=pk, @@ -374,14 +411,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) def partial_update(self, request, slug, project_id, pk): - queryset = self.get_queryset().filter(pk=pk) + module = self.get_queryset().filter(pk=pk) + + if module.first().archived_at: + return Response( + {"error": "Archived module cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer = ModuleWriteSerializer( - queryset.first(), data=request.data, partial=True + module.first(), data=request.data, partial=True ) if serializer.is_valid(): serializer.save() - module = queryset.values( + module = module.values( # Required fields "id", "workspace_id", @@ -405,6 +448,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "cancelled_issues", "completed_issues", "started_issues", + "total_issues", "unstarted_issues", "backlog_issues", "created_at", @@ -464,6 +508,7 @@ class ModuleLinkViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .order_by("-created_at") .distinct() diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index cfa8ee478..d26433340 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -93,7 +93,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 34a9ee638..29dc2dbf5 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -1,5 +1,7 @@ # Python imports +import json from datetime import datetime +from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection @@ -17,6 +19,7 @@ from plane.app.serializers import ( PageLogSerializer, PageSerializer, SubPageSerializer, + PageDetailSerializer, ) from plane.db.models import ( Page, @@ -28,6 +31,8 @@ from plane.db.models import ( # Module imports from ..base import BaseAPIView, BaseViewSet +from plane.bgtasks.page_transaction_task import page_transaction + def unarchive_archive_page_and_descendants(page_id, archived_at): # Your SQL query @@ -70,6 +75,7 @@ class PageViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) @@ -86,11 +92,21 @@ class PageViewSet(BaseViewSet): def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, - context={"project_id": project_id, "owned_by_id": request.user.id}, + context={ + "project_id": project_id, + "owned_by_id": request.user.id, + "description_html": request.data.get( + "description_html", "

" + ), + }, ) if serializer.is_valid(): serializer.save() + # capture the page transaction + page_transaction.delay(request.data, None, serializer.data["id"]) + page = Page.objects.get(pk=serializer.data["id"]) + serializer = PageDetailSerializer(page) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -124,9 +140,25 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = PageSerializer(page, data=request.data, partial=True) + serializer = PageDetailSerializer( + page, data=request.data, partial=True + ) + page_description = page.description_html if serializer.is_valid(): serializer.save() + # capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_value=request.data, + old_value=json.dumps( + { + "description_html": page_description, + }, + cls=DjangoJSONEncoder, + ), + page_id=pk, + ) + return Response(serializer.data, status=status.HTTP_200_OK) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST @@ -139,18 +171,30 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - def lock(self, request, slug, project_id, page_id): + def retrieve(self, request, slug, project_id, pk=None): + page = self.get_queryset().filter(pk=pk).first() + if page is None: + return Response( + {"error": "Page not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + else: + return Response( + PageDetailSerializer(page).data, status=status.HTTP_200_OK + ) + + def lock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ).first() page.is_locked = True page.save() return Response(status=status.HTTP_204_NO_CONTENT) - def unlock(self, request, slug, project_id, page_id): + def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ).first() page.is_locked = False @@ -159,13 +203,13 @@ class PageViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, slug, project_id): - queryset = self.get_queryset().filter(archived_at__isnull=True) + queryset = self.get_queryset() pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) - def archive(self, request, slug, project_id, page_id): + def archive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ) # only the owner or admin can archive the page @@ -183,13 +227,16 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - unarchive_archive_page_and_descendants(page_id, datetime.now()) + unarchive_archive_page_and_descendants(pk, datetime.now()) - return Response(status=status.HTTP_204_NO_CONTENT) + return Response( + {"archived_at": str(datetime.now())}, + status=status.HTTP_200_OK, + ) - def unarchive(self, request, slug, project_id, page_id): + def unarchive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=page_id, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, project_id=project_id ) # only the owner or admin can un archive the page @@ -212,19 +259,10 @@ class PageViewSet(BaseViewSet): page.parent = None page.save(update_fields=["parent"]) - unarchive_archive_page_and_descendants(page_id, None) + unarchive_archive_page_and_descendants(pk, None) return Response(status=status.HTTP_204_NO_CONTENT) - def archive_list(self, request, slug, project_id): - pages = Page.objects.filter( - project_id=project_id, - workspace__slug=slug, - ).filter(archived_at__isnull=False) - - 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 @@ -268,29 +306,20 @@ class PageFavoriteViewSet(BaseViewSet): serializer_class = PageFavoriteSerializer model = PageFavorite - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(archived_at__isnull=True) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related("page", "page__owned_by") + def create(self, request, slug, project_id, pk): + _ = PageFavorite.objects.create( + project_id=project_id, + page_id=pk, + user=request.user, ) + return Response(status=status.HTTP_204_NO_CONTENT) - def create(self, request, slug, project_id): - serializer = PageFavoriteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(user=request.user, project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def destroy(self, request, slug, project_id, page_id): + def destroy(self, request, slug, project_id, pk): page_favorite = PageFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, - page_id=page_id, + page_id=pk, ) page_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 74d4e3466..50435e3a8 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -13,6 +13,7 @@ from django.db.models import ( Subquery, ) from django.conf import settings +from django.utils import timezone # Third Party imports from rest_framework.response import Response @@ -72,7 +73,10 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter( - Q(project_projectmember__member=self.request.user) + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) | Q(network=2) ) .select_related( @@ -176,6 +180,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): def retrieve(self, request, slug, pk): project = ( self.get_queryset() + .filter(archived_at__isnull=True) .filter(pk=pk) .annotate( total_issues=Issue.issue_objects.filter( @@ -346,12 +351,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): {"name": "The project name is already taken"}, status=status.HTTP_410_GONE, ) - except Workspace.DoesNotExist as e: + except Workspace.DoesNotExist: return Response( {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND, ) - except serializers.ValidationError as e: + except serializers.ValidationError: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, @@ -363,6 +368,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): project = Project.objects.get(pk=pk) + if project.archived_at: + return Response( + {"error": "Archived projects cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = ProjectSerializer( project, data={**request.data}, @@ -382,10 +393,11 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): # Create the triage state in Backlog group State.objects.get_or_create( name="Triage", - group="backlog", + group="triage", description="Default state for managing all Inbox Issues", project_id=pk, color="#ff7700", + is_triage=True, ) project = ( @@ -410,13 +422,35 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND, ) - except serializers.ValidationError as e: + except serializers.ValidationError: return Response( {"identifier": "The project identifier is already taken"}, status=status.HTTP_410_GONE, ) +class ProjectArchiveUnarchiveEndpoint(BaseAPIView): + + permission_classes = [ + ProjectBasePermission, + ] + + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = timezone.now() + project.save() + return Response( + {"archived_at": str(project.archived_at)}, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = None + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + class ProjectIdentifierEndpoint(BaseAPIView): permission_classes = [ ProjectBasePermission, diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 42aa05e4f..4a4ffd826 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -50,6 +50,7 @@ class GlobalSearchEndpoint(BaseAPIView): q, project_projectmember__member=self.request.user, project_projectmember__is_active=True, + archived_at__isnull=True, workspace__slug=slug, ) .distinct() @@ -72,6 +73,7 @@ class GlobalSearchEndpoint(BaseAPIView): q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) @@ -97,6 +99,7 @@ class GlobalSearchEndpoint(BaseAPIView): q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) @@ -121,6 +124,7 @@ class GlobalSearchEndpoint(BaseAPIView): q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) @@ -145,6 +149,7 @@ class GlobalSearchEndpoint(BaseAPIView): q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) @@ -169,6 +174,7 @@ class GlobalSearchEndpoint(BaseAPIView): q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, workspace__slug=slug, ) @@ -243,6 +249,7 @@ class IssueSearchEndpoint(BaseAPIView): workspace__slug=slug, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True ) if workspace_search == "false": diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 137a89d99..b488d9efb 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -1,9 +1,6 @@ # Python imports from itertools import groupby -# Django imports -from django.db.models import Q - # Third party imports from rest_framework.response import Response from rest_framework import status @@ -17,6 +14,7 @@ from plane.app.permissions import ( from plane.db.models import State, Issue from plane.utils.cache import invalidate_cache + class StateViewSet(BaseViewSet): serializer_class = StateSerializer model = State @@ -33,14 +31,17 @@ class StateViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) - .filter(~Q(name="Triage")) + .filter(is_triage=False) .select_related("project") .select_related("workspace") .distinct() ) - @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) + @invalidate_cache( + path="workspaces/:slug/states/", url_params=True, user=False + ) def create(self, request, slug, project_id): serializer = StateSerializer(data=request.data) if serializer.is_valid(): @@ -61,7 +62,9 @@ class StateViewSet(BaseViewSet): return Response(state_dict, status=status.HTTP_200_OK) return Response(states, status=status.HTTP_200_OK) - @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) + @invalidate_cache( + path="workspaces/:slug/states/", url_params=True, user=False + ) def mark_as_default(self, request, slug, project_id, pk): # Select all the states which are marked as default _ = State.objects.filter( @@ -72,10 +75,12 @@ class StateViewSet(BaseViewSet): ).update(default=True) return Response(status=status.HTTP_204_NO_CONTENT) - @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) + @invalidate_cache( + path="workspaces/:slug/states/", url_params=True, user=False + ) def destroy(self, request, slug, project_id, pk): state = State.objects.get( - ~Q(name="Triage"), + is_triage=False, pk=pk, project_id=project_id, workspace__slug=slug, diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index 4d69d1cf2..487e365cd 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -49,7 +49,12 @@ class UserEndpoint(BaseViewSet): {"is_instance_admin": is_admin}, status=status.HTTP_200_OK ) - @invalidate_cache(path="/api/users/me/") + @invalidate_cache( + path="/api/users/me/", + ) + @invalidate_cache( + path="/api/users/me/settings/", + ) def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 16c50e880..35772ccf3 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -125,7 +125,8 @@ class GlobalViewIssuesViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -282,6 +283,7 @@ class IssueViewViewSet(BaseViewSet): .filter( project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .select_related("project") .select_related("workspace") @@ -324,11 +326,11 @@ class IssueViewFavoriteViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, view_id): - view_favourite = IssueViewFavorite.objects.get( + view_favorite = IssueViewFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, view_id=view_id, ) - view_favourite.delete() + view_favorite.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 0fb8f2d80..24a3d7302 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -1,49 +1,51 @@ # Python imports -from datetime import date -from dateutil.relativedelta import relativedelta import csv import io +from datetime import date +from dateutil.relativedelta import relativedelta +from django.db import IntegrityError +from django.db.models import ( + Count, + F, + Func, + OuterRef, + Prefetch, + Q, +) +from django.db.models.fields import DateField +from django.db.models.functions import Cast, ExtractDay, ExtractWeek # Django imports from django.http import HttpResponse -from django.db import IntegrityError from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Count, -) -from django.db.models.functions import ExtractWeek, Cast, ExtractDay -from django.db.models.fields import DateField # Third party modules from rest_framework import status from rest_framework.response import Response +from plane.app.permissions import ( + WorkSpaceAdminPermission, + WorkSpaceBasePermission, + WorkspaceEntityPermission, +) + # Module imports from plane.app.serializers import ( WorkSpaceSerializer, WorkspaceThemeSerializer, ) -from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.app.views.base import BaseAPIView, BaseViewSet from plane.db.models import ( - Workspace, - IssueActivity, Issue, - WorkspaceTheme, + IssueActivity, + Workspace, WorkspaceMember, -) -from plane.app.permissions import ( - WorkSpaceBasePermission, - WorkSpaceAdminPermission, - WorkspaceEntityPermission, + WorkspaceTheme, ) from plane.utils.cache import cache_response, invalidate_cache + class WorkSpaceViewSet(BaseViewSet): model = Workspace serializer_class = WorkSpaceSerializer @@ -138,6 +140,7 @@ class WorkSpaceViewSet(BaseViewSet): {"slug": "The workspace with the slug already exists"}, status=status.HTTP_410_GONE, ) + @cache_response(60 * 60 * 2) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -148,7 +151,8 @@ class WorkSpaceViewSet(BaseViewSet): return super().partial_update(request, *args, **kwargs) @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/") + @invalidate_cache(path="/api/users/me/workspaces/", multiple=True, user=False) + @invalidate_cache(path="/api/users/me/settings/", multiple=True, user=False) def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index ea081cf99..e85fa1cef 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -27,6 +27,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): .select_related("project") .select_related("workspace") .select_related("owned_by") + .filter(archived_at__isnull=False) .annotate( total_issues=Count( "issue_cycle", diff --git a/apiserver/plane/app/views/workspace/estimate.py b/apiserver/plane/app/views/workspace/estimate.py index 6b64d8c90..59a23d867 100644 --- a/apiserver/plane/app/views/workspace/estimate.py +++ b/apiserver/plane/app/views/workspace/estimate.py @@ -3,15 +3,10 @@ from rest_framework import status from rest_framework.response import Response # Module imports +from plane.app.permissions import WorkspaceEntityPermission from plane.app.serializers import WorkspaceEstimateSerializer from plane.app.views.base import BaseAPIView -from plane.db.models import Project, Estimate -from plane.app.permissions import WorkspaceEntityPermission - -# Django imports -from django.db.models import ( - Prefetch, -) +from plane.db.models import Estimate, Project from plane.utils.cache import cache_response @@ -25,15 +20,11 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): 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=Project.objects.select_related( - "estimate", "workspace", "project" - ), - ) + estimates = ( + Estimate.objects.filter(pk__in=estimate_ids, workspace__slug=slug) + .prefetch_related("points") + .select_related("workspace", "project") ) + serializer = WorkspaceEstimateSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/invite.py b/apiserver/plane/app/views/workspace/invite.py index 807c060ad..d3511a865 100644 --- a/apiserver/plane/app/views/workspace/invite.py +++ b/apiserver/plane/app/views/workspace/invite.py @@ -1,36 +1,39 @@ # Python imports -import jwt from datetime import datetime +import jwt + # Django imports from django.conf import settings -from django.utils import timezone -from django.db.models import Count from django.core.exceptions import ValidationError from django.core.validators import validate_email +from django.db.models import Count +from django.utils import timezone # Third party modules from rest_framework import status -from rest_framework.response import Response from rest_framework.permissions import AllowAny +from rest_framework.response import Response # Module imports +from plane.app.permissions import WorkSpaceAdminPermission from plane.app.serializers import ( - WorkSpaceMemberSerializer, WorkSpaceMemberInviteSerializer, + WorkSpaceMemberSerializer, ) from plane.app.views.base import BaseAPIView -from .. import BaseViewSet +from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.db.models import ( User, Workspace, - WorkspaceMemberInvite, WorkspaceMember, + WorkspaceMemberInvite, ) -from plane.app.permissions import WorkSpaceAdminPermission -from plane.bgtasks.workspace_invitation_task import workspace_invitation -from plane.bgtasks.event_tracking_task import workspace_invite_event -from plane.utils.cache import invalidate_cache +from plane.utils.cache import invalidate_cache, invalidate_cache_directly + +from .. import BaseViewSet + class WorkspaceInvitationsViewset(BaseViewSet): """Endpoint for creating, listing and deleting workspaces""" @@ -166,7 +169,14 @@ class WorkspaceJoinEndpoint(BaseAPIView): """Invitation response endpoint the user can respond to the invitation""" @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/") + @invalidate_cache(path="/api/users/me/workspaces/", multiple=True) + @invalidate_cache( + path="/api/workspaces/:slug/members/", + user=False, + multiple=True, + url_params=True, + ) + @invalidate_cache(path="/api/users/me/settings/", multiple=True) def post(self, request, slug, pk): workspace_invite = WorkspaceMemberInvite.objects.get( pk=pk, workspace__slug=slug @@ -264,10 +274,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): ) @invalidate_cache(path="/api/workspaces/", user=False) - @invalidate_cache(path="/api/users/me/workspaces/") - @invalidate_cache( - path="/api/workspaces/:slug/members/", url_params=True, user=False - ) + @invalidate_cache(path="/api/users/me/workspaces/", multiple=True) def create(self, request): invitations = request.data.get("invitations", []) workspace_invitations = WorkspaceMemberInvite.objects.filter( @@ -276,6 +283,12 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): # If the user is already a member of workspace and was deactivated then activate the user for invitation in workspace_invitations: + invalidate_cache_directly( + path=f"/api/workspaces/{invitation.workspace.slug}/members/", + user=False, + request=request, + multiple=True, + ) # Update the WorkspaceMember for this specific invitation WorkspaceMember.objects.filter( workspace_id=invitation.workspace_id, member=request.user diff --git a/apiserver/plane/app/views/workspace/label.py b/apiserver/plane/app/views/workspace/label.py index ba396a842..328f3f8c1 100644 --- a/apiserver/plane/app/views/workspace/label.py +++ b/apiserver/plane/app/views/workspace/label.py @@ -20,6 +20,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView): workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) serializer = LabelSerializer(labels, many=True).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index ff88e47f8..39b2f3d98 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -1,41 +1,43 @@ # Django imports from django.db.models import ( - Q, + CharField, Count, + Q, ) from django.db.models.functions import Cast -from django.db.models import CharField # Third party modules from rest_framework import status from rest_framework.response import Response -# Module imports -from plane.app.serializers import ( - WorkSpaceMemberSerializer, - TeamSerializer, - UserLiteSerializer, - WorkspaceMemberAdminSerializer, - WorkspaceMemberMeSerializer, - ProjectMemberRoleSerializer, -) -from plane.app.views.base import BaseAPIView -from .. import BaseViewSet -from plane.db.models import ( - User, - Workspace, - Team, - ProjectMember, - Project, - WorkspaceMember, -) from plane.app.permissions import ( WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceUserPermission, ) + +# Module imports +from plane.app.serializers import ( + ProjectMemberRoleSerializer, + TeamSerializer, + UserLiteSerializer, + WorkspaceMemberAdminSerializer, + WorkspaceMemberMeSerializer, + WorkSpaceMemberSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + Project, + ProjectMember, + Team, + User, + Workspace, + WorkspaceMember, +) from plane.utils.cache import cache_response, invalidate_cache +from .. import BaseViewSet + class WorkSpaceMemberViewSet(BaseViewSet): serializer_class = WorkspaceMemberAdminSerializer @@ -100,7 +102,10 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) @invalidate_cache( - path="/api/workspaces/:slug/members/", url_params=True, user=False + path="/api/workspaces/:slug/members/", + url_params=True, + user=False, + multiple=True, ) def partial_update(self, request, slug, pk): workspace_member = WorkspaceMember.objects.get( @@ -145,7 +150,14 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @invalidate_cache( - path="/api/workspaces/:slug/members/", url_params=True, user=False + path="/api/workspaces/:slug/members/", + url_params=True, + user=False, + multiple=True, + ) + @invalidate_cache(path="/api/users/me/settings/", multiple=True) + @invalidate_cache( + path="/api/users/me/workspaces/", user=False, multiple=True ) def destroy(self, request, slug, pk): # Check the user role who is deleting the user @@ -212,7 +224,14 @@ class WorkSpaceMemberViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) @invalidate_cache( - path="/api/workspaces/:slug/members/", url_params=True, user=False + path="/api/workspaces/:slug/members/", + url_params=True, + user=False, + multiple=True, + ) + @invalidate_cache(path="/api/users/me/settings/") + @invalidate_cache( + path="api/users/me/workspaces/", user=False, multiple=True ) def leave(self, request, slug): workspace_member = WorkspaceMember.objects.get( diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py index fbd760271..085787694 100644 --- a/apiserver/plane/app/views/workspace/module.py +++ b/apiserver/plane/app/views/workspace/module.py @@ -30,6 +30,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): .select_related("workspace") .select_related("lead") .prefetch_related("members") + .filter(archived_at__isnull=False) .prefetch_related( Prefetch( "link_module", @@ -45,6 +46,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ), ) .annotate( @@ -55,6 +57,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -65,6 +68,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -75,6 +79,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -85,6 +90,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .annotate( @@ -95,6 +101,7 @@ class WorkspaceModulesEndpoint(BaseAPIView): issue_module__issue__archived_at__isnull=True, issue_module__issue__is_draft=False, ), + distinct=True, ) ) .order_by(self.kwargs.get("order_by", "-created_at")) diff --git a/apiserver/plane/app/views/workspace/state.py b/apiserver/plane/app/views/workspace/state.py index d44f83e73..c69b56d4f 100644 --- a/apiserver/plane/app/views/workspace/state.py +++ b/apiserver/plane/app/views/workspace/state.py @@ -20,6 +20,8 @@ class WorkspaceStatesEndpoint(BaseAPIView): workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + is_triage=False, ) serializer = StateSerializer(states, many=True).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 36b00b738..94a22a1a7 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -124,7 +124,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): | Q(issue_subscribers__subscriber_id=user_id), workspace__slug=slug, project__project_projectmember__member=request.user, - project__project_projectmember__is_active=True + project__project_projectmember__is_active=True, ) .filter(**filters) .select_related("workspace", "project", "state", "parent") @@ -165,7 +165,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ArrayAgg( "assignees__id", distinct=True, - filter=~Q(assignees__id__isnull=True), + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -299,6 +300,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): workspace__slug=slug, project_projectmember__member=request.user, project_projectmember__is_active=True, + archived_at__isnull=True, ) .annotate( created_issues=Count( @@ -387,6 +389,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, actor=user_id, ).select_related("actor", "workspace", "issue", "project") @@ -498,6 +501,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): subscriber_id=user_id, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .filter(**filters) .count() diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 62620ab9d..e6788df79 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -1,22 +1,22 @@ # Python imports import csv import io +import logging + +# Third party imports +from celery import shared_task # Django imports 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 - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports from plane.db.models import Issue -from plane.utils.analytics_plot import build_graph_plot -from plane.utils.issue_filters import issue_filters from plane.license.utils.instance_value import get_email_configuration +from plane.utils.analytics_plot import build_graph_plot +from plane.utils.exception_logger import log_exception +from plane.utils.issue_filters import issue_filters row_mapping = { "state__name": "State", @@ -55,6 +55,7 @@ def send_export_email(email, slug, csv_buffer, rows): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -64,6 +65,7 @@ def send_export_email(email, slug, csv_buffer, rows): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -210,9 +212,9 @@ def generate_segmented_rows( None, ) if assignee: - generated_row[ - 0 - ] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + generated_row[0] = ( + f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + ) if x_axis == LABEL_ID: label = next( @@ -279,9 +281,9 @@ def generate_segmented_rows( None, ) if assignee: - row_zero[ - index + 2 - ] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + row_zero[index + 2] = ( + f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + ) if segmented == LABEL_ID: for index, segm in enumerate(row_zero[2:]): @@ -366,9 +368,9 @@ def generate_non_segmented_rows( None, ) if assignee: - row[ - 0 - ] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + row[0] = ( + f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" + ) if x_axis == LABEL_ID: label = next( @@ -504,10 +506,8 @@ def analytic_export_task(email, data, slug): csv_buffer = generate_csv_from_rows(rows) send_export_email(email, slug, csv_buffer, rows) + logging.getLogger("plane").info("Email sent succesfully.") return except Exception as e: - print(e) - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py new file mode 100644 index 000000000..e76cdac22 --- /dev/null +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -0,0 +1,679 @@ +# Python imports +import uuid +import random +from datetime import datetime, timedelta + +# Django imports +from django.db.models import Max + +# Third party imports +from celery import shared_task +from faker import Faker + +# Module imports +from plane.db.models import ( + Workspace, + User, + Project, + ProjectMember, + State, + Label, + Cycle, + Module, + Issue, + IssueSequence, + IssueAssignee, + IssueLabel, + IssueActivity, + CycleIssue, + ModuleIssue, + Page, + PageLabel, + Inbox, + InboxIssue, +) + + +def create_project(workspace, user_id): + fake = Faker() + name = fake.name() + unique_id = str(uuid.uuid4())[:5] + + project = Project.objects.create( + workspace=workspace, + name=f"{name}_{unique_id}", + identifier=name[ + : random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1) + ].upper(), + created_by_id=user_id, + inbox_view=True, + ) + + # Add current member as project member + _ = ProjectMember.objects.create( + project=project, + member_id=user_id, + role=20, + ) + + return project + + +def create_project_members(workspace, project, members): + members = User.objects.filter(email__in=members) + + _ = ProjectMember.objects.bulk_create( + [ + ProjectMember( + project=project, + workspace=workspace, + member=member, + role=20, + sort_order=random.randint(0, 65535), + ) + for member in members + ], + ignore_conflicts=True, + ) + return + + +def create_states(workspace, project, user_id): + states = [ + { + "name": "Backlog", + "color": "#A3A3A3", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#3A3A3A", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#16A34A", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#EF4444", + "sequence": 55000, + "group": "cancelled", + }, + ] + + states = State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=project, + sequence=state["sequence"], + workspace=workspace, + group=state["group"], + default=state.get("default", False), + created_by_id=user_id, + ) + for state in states + ] + ) + + return states + + +def create_labels(workspace, project, user_id): + fake = Faker() + Faker.seed(0) + + return Label.objects.bulk_create( + [ + Label( + name=fake.color_name(), + color=fake.hex_color(), + project=project, + workspace=workspace, + created_by_id=user_id, + sort_order=random.randint(0, 65535), + ) + for _ in range(0, 50) + ], + ignore_conflicts=True, + ) + + +def create_cycles(workspace, project, user_id, cycle_count): + fake = Faker() + Faker.seed(0) + + cycles = [] + used_date_ranges = set() # Track used date ranges + + while len(cycles) <= cycle_count: + # Generate a start date, allowing for None + start_date_option = [None, fake.date_this_year()] + start_date = start_date_option[random.randint(0, 1)] + + # Initialize end_date based on start_date + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + # Ensure end_date is strictly after start_date if start_date is not None + while start_date is not None and ( + end_date <= start_date + or (start_date, end_date) in used_date_ranges + ): + end_date = fake.date_this_year() + + # Add the unique date range to the set + ( + used_date_ranges.add((start_date, end_date)) + if (end_date is not None and start_date is not None) + else None + ) + + # Append the cycle with unique date range + cycles.append( + Cycle( + name=fake.name(), + owned_by_id=user_id, + sort_order=random.randint(0, 65535), + start_date=start_date, + end_date=end_date, + project=project, + workspace=workspace, + ) + ) + + return Cycle.objects.bulk_create(cycles, ignore_conflicts=True) + + +def create_modules(workspace, project, user_id, module_count): + fake = Faker() + Faker.seed(0) + + modules = [] + for _ in range(0, module_count): + start_date = [None, fake.date_this_year()][random.randint(0, 1)] + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + modules.append( + Module( + name=fake.name(), + sort_order=random.randint(0, 65535), + start_date=start_date, + target_date=end_date, + project=project, + workspace=workspace, + ) + ) + + return Module.objects.bulk_create(modules, ignore_conflicts=True) + + +def create_pages(workspace, project, user_id, pages_count): + fake = Faker() + Faker.seed(0) + + pages = [] + for _ in range(0, pages_count): + text = fake.text(max_nb_chars=60000) + pages.append( + Page( + name=fake.name(), + project=project, + workspace=workspace, + owned_by_id=user_id, + access=random.randint(0, 1), + color=fake.hex_color(), + description_html=f"

{text}

", + archived_at=None, + is_locked=False, + ) + ) + + return Page.objects.bulk_create(pages, ignore_conflicts=True) + + +def create_page_labels(workspace, project, user_id, pages_count): + # labels + labels = Label.objects.filter(project=project).values_list("id", flat=True) + pages = random.sample( + list( + Page.objects.filter(project=project).values_list("id", flat=True) + ), + int(pages_count / 2), + ) + + # Bulk page labels + bulk_page_labels = [] + for page in pages: + for label in random.sample( + list(labels), random.randint(0, len(labels) - 1) + ): + bulk_page_labels.append( + PageLabel( + page_id=page, + label_id=label, + project=project, + workspace=workspace, + ) + ) + + # Page labels + PageLabel.objects.bulk_create( + bulk_page_labels, batch_size=1000, ignore_conflicts=True + ) + + +def create_issues(workspace, project, user_id, issue_count): + fake = Faker() + Faker.seed(0) + + states = State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True) + creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True) + + issues = [] + + # Get the maximum sequence_id + last_id = IssueSequence.objects.filter( + project=project, + ).aggregate( + largest=Max("sequence") + )["largest"] + + last_id = 1 if last_id is None else last_id + 1 + + # Get the maximum sort order + largest_sort_order = Issue.objects.filter( + project=project, + state_id=states[random.randint(0, len(states) - 1)], + ).aggregate(largest=Max("sort_order"))["largest"] + + largest_sort_order = ( + 65535 if largest_sort_order is None else largest_sort_order + 10000 + ) + + for _ in range(0, issue_count): + start_date = [None, fake.date_this_year()][random.randint(0, 1)] + end_date = ( + None + if start_date is None + else fake.date_between_dates( + date_start=start_date, + date_end=datetime.now().date().replace(month=12, day=31), + ) + ) + + text = fake.text(max_nb_chars=60000) + issues.append( + Issue( + state_id=states[random.randint(0, len(states) - 1)], + project=project, + workspace=workspace, + name=text[:254], + description_html=f"

{text}

", + description_stripped=text, + sequence_id=last_id, + sort_order=largest_sort_order, + start_date=start_date, + target_date=end_date, + priority=["urgent", "high", "medium", "low", "none"][ + random.randint(0, 4) + ], + created_by_id=creators[random.randint(0, len(creators) - 1)], + ) + ) + + largest_sort_order = largest_sort_order + random.randint(0, 1000) + last_id = last_id + 1 + + issues = Issue.objects.bulk_create( + issues, ignore_conflicts=True, batch_size=1000 + ) + # Sequences + _ = IssueSequence.objects.bulk_create( + [ + IssueSequence( + issue=issue, + sequence=issue.sequence_id, + project=project, + workspace=workspace, + ) + for issue in issues + ], + batch_size=100, + ) + + # Track the issue activities + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue=issue, + actor_id=user_id, + project=project, + workspace=workspace, + comment="created the issue", + verb="created", + created_by_id=user_id, + ) + for issue in issues + ], + batch_size=100, + ) + return issues + + +def create_inbox_issues(workspace, project, user_id, inbox_issue_count): + issues = create_issues(workspace, project, user_id, inbox_issue_count) + inbox, create = Inbox.objects.get_or_create( + name="Inbox", + project=project, + is_default=True, + ) + InboxIssue.objects.bulk_create( + [ + InboxIssue( + issue=issue, + inbox=inbox, + status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]), + snoozed_till=( + datetime.now() + timedelta(days=random.randint(1, 30)) + if status == 0 + else None + ), + source="in-app", + workspace=workspace, + project=project, + ) + for issue in issues + ], + batch_size=100, + ) + + +def create_issue_parent(workspace, project, user_id, issue_count): + + parent_count = issue_count / 4 + + parent_issues = Issue.objects.filter(project=project).values_list( + "id", flat=True + )[: int(parent_count)] + sub_issues = Issue.objects.filter(project=project).exclude( + pk__in=parent_issues + )[: int(issue_count / 2)] + + bulk_sub_issues = [] + for sub_issue in sub_issues: + sub_issue.parent_id = parent_issues[ + random.randint(0, int(parent_count - 1)) + ] + + Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000) + + +def create_issue_assignees(workspace, project, user_id, issue_count): + # assignees + assignees = ProjectMember.objects.filter(project=project).values_list( + "member_id", flat=True + ) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_issue_assignees = [] + for issue in issues: + for assignee in random.sample( + list(assignees), random.randint(0, len(assignees) - 1) + ): + bulk_issue_assignees.append( + IssueAssignee( + issue_id=issue, + assignee_id=assignee, + project=project, + workspace=workspace, + ) + ) + + # Issue assignees + IssueAssignee.objects.bulk_create( + bulk_issue_assignees, batch_size=1000, ignore_conflicts=True + ) + + +def create_issue_labels(workspace, project, user_id, issue_count): + # labels + labels = Label.objects.filter(project=project).values_list("id", flat=True) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_issue_labels = [] + for issue in issues: + for label in random.sample( + list(labels), random.randint(0, len(labels) - 1) + ): + bulk_issue_labels.append( + IssueLabel( + issue_id=issue, + label_id=label, + project=project, + workspace=workspace, + ) + ) + + # Issue labels + IssueLabel.objects.bulk_create( + bulk_issue_labels, batch_size=1000, ignore_conflicts=True + ) + + +def create_cycle_issues(workspace, project, user_id, issue_count): + # assignees + cycles = Cycle.objects.filter(project=project).values_list("id", flat=True) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_cycle_issues = [] + for issue in issues: + cycle = cycles[random.randint(0, len(cycles) - 1)] + bulk_cycle_issues.append( + CycleIssue( + cycle_id=cycle, + issue_id=issue, + project=project, + workspace=workspace, + ) + ) + + # Issue assignees + CycleIssue.objects.bulk_create( + bulk_cycle_issues, batch_size=1000, ignore_conflicts=True + ) + + +def create_module_issues(workspace, project, user_id, issue_count): + # assignees + modules = Module.objects.filter(project=project).values_list( + "id", flat=True + ) + issues = random.sample( + list( + Issue.objects.filter(project=project).values_list("id", flat=True) + ), + int(issue_count / 2), + ) + + # Bulk issue + bulk_module_issues = [] + for issue in issues: + module = modules[random.randint(0, len(modules) - 1)] + bulk_module_issues.append( + ModuleIssue( + module_id=module, + issue_id=issue, + project=project, + workspace=workspace, + ) + ) + # Issue assignees + ModuleIssue.objects.bulk_create( + bulk_module_issues, batch_size=1000, ignore_conflicts=True + ) + + +@shared_task +def create_dummy_data( + slug, + email, + members, + issue_count, + cycle_count, + module_count, + pages_count, + inbox_issue_count, +): + workspace = Workspace.objects.get(slug=slug) + + user = User.objects.get(email=email) + user_id = user.id + + # Create a project + project = create_project(workspace=workspace, user_id=user_id) + + # create project members + create_project_members( + workspace=workspace, project=project, members=members + ) + + # Create states + create_states(workspace=workspace, project=project, user_id=user_id) + + # Create labels + create_labels(workspace=workspace, project=project, user_id=user_id) + + # create cycles + create_cycles( + workspace=workspace, + project=project, + user_id=user_id, + cycle_count=cycle_count, + ) + + # create modules + create_modules( + workspace=workspace, + project=project, + user_id=user_id, + module_count=module_count, + ) + + # create pages + create_pages( + workspace=workspace, + project=project, + user_id=user_id, + pages_count=pages_count, + ) + + # create page labels + create_page_labels( + workspace=workspace, + project=project, + user_id=user_id, + pages_count=pages_count, + ) + + # create issues + create_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + + # create inbox issues + create_inbox_issues( + workspace=workspace, + project=project, + user_id=user_id, + inbox_issue_count=inbox_issue_count, + ) + + # create issue parent + create_issue_parent( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + + # create issue assignees + create_issue_assignees( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + + # create issue labels + create_issue_labels( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + + # create cycle issues + create_cycle_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + + # create module issues + create_module_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + + return diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index c3e6e214a..050f522c3 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,21 +1,29 @@ +import logging +import re from datetime import datetime + from bs4 import BeautifulSoup # Third party imports from celery import shared_task -from sentry_sdk import capture_exception +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string # 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.db.models import EmailNotificationLog, Issue, User from plane.license.utils.instance_value import get_email_configuration from plane.settings.redis import redis_instance +from plane.utils.exception_logger import log_exception + + +def remove_unwanted_characters(input_text): + # Keep only alphanumeric characters, spaces, and dashes. + processed_text = re.sub(r"[^a-zA-Z0-9 \-]", "", input_text) + return processed_text # acquire and delete redis lock @@ -69,7 +77,9 @@ def stack_email_notification(): receiver_notification.get("entity_identifier"), {} ).setdefault( str(receiver_notification.get("triggered_by_id")), [] - ).append(receiver_notification.get("data")) + ).append( + receiver_notification.get("data") + ) # append processed notifications processed_notifications.append(receiver_notification.get("id")) email_notification_ids.append(receiver_notification.get("id")) @@ -172,7 +182,16 @@ def send_email_notification( if acquire_lock(lock_id=lock_id): # get the redis instance ri = redis_instance() - base_api = ri.get(str(issue_id)).decode() + base_api = ( + ri.get(str(issue_id)).decode() + if ri.get(str(issue_id)) + else None + ) + + # Skip if base api is not present + if not base_api: + return + data = create_payload(notification_data=notification_data) # Get email configurations @@ -182,6 +201,7 @@ def send_email_notification( EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -251,9 +271,7 @@ def send_email_notification( summary = "Updates were made to the issue by" # Send the mail - subject = ( - f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" - ) + subject = f"{issue.project.identifier}-{issue.sequence_id} {remove_unwanted_characters(issue.name)}" context = { "data": template_data, "summary": summary, @@ -285,6 +303,7 @@ def send_email_notification( username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -296,7 +315,9 @@ def send_email_notification( ) msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email Sent Successfully") + # Update the logs EmailNotificationLog.objects.filter( pk__in=email_notification_ids ).update(sent_at=timezone.now()) @@ -305,15 +326,19 @@ def send_email_notification( release_lock(lock_id=lock_id) return except Exception as e: - capture_exception(e) + log_exception(e) # release the lock release_lock(lock_id=lock_id) return else: - print("Duplicate task recived. Skipping...") + logging.getLogger("plane").info( + "Duplicate email received skipping" + ) return - except (Issue.DoesNotExist, User.DoesNotExist) as e: - if settings.DEBUG: - print(e) + except (Issue.DoesNotExist, User.DoesNotExist): + release_lock(lock_id=lock_id) + return + except Exception as e: + log_exception(e) release_lock(lock_id=lock_id) return diff --git a/apiserver/plane/bgtasks/event_tracking_task.py b/apiserver/plane/bgtasks/event_tracking_task.py index 82a8281a9..135ae1dd1 100644 --- a/apiserver/plane/bgtasks/event_tracking_task.py +++ b/apiserver/plane/bgtasks/event_tracking_task.py @@ -1,13 +1,13 @@ -import uuid import os +import uuid # third party imports from celery import shared_task -from sentry_sdk import capture_exception from posthog import Posthog # module imports from plane.license.utils.instance_value import get_configuration_value +from plane.utils.exception_logger import log_exception def posthogConfiguration(): @@ -51,7 +51,8 @@ def auth_events(user, email, user_agent, ip, event_name, medium, first_time): }, ) except Exception as e: - capture_exception(e) + log_exception(e) + return @shared_task @@ -77,4 +78,5 @@ def workspace_invite_event( }, ) except Exception as e: - capture_exception(e) + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index f99e54215..c99836c83 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -2,21 +2,22 @@ import csv import io import json -import boto3 import zipfile +import boto3 +from botocore.client import Config + +# Third party imports +from celery import shared_task + # Django imports from django.conf import settings from django.utils import timezone - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception -from botocore.client import Config from openpyxl import Workbook # Module imports -from plane.db.models import Issue, ExporterHistory +from plane.db.models import ExporterHistory, Issue +from plane.utils.exception_logger import log_exception def dateTimeConverter(time): @@ -303,6 +304,7 @@ def issue_export_task( project_id__in=project_ids, project__project_projectmember__member=exporter_instance.initiated_by_id, project__project_projectmember__is_active=True, + project__archived_at__isnull=True, ) .select_related( "project", "workspace", "state", "parent", "created_by" @@ -403,8 +405,5 @@ def issue_export_task( exporter_instance.status = "failed" exporter_instance.reason = str(e) exporter_instance.save(update_fields=["status", "reason"]) - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 1d3b68477..b30c9311f 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,17 +1,17 @@ -# Python import +# Python imports +import logging + +# Third party imports +from celery import shared_task # Django imports 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 - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception @shared_task @@ -26,6 +26,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -49,6 +50,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -60,10 +62,8 @@ def forgot_password(first_name, email, uidb64, token, current_site): ) msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent successfully") return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 6aa6b6695..2d55d5579 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1,34 +1,36 @@ # Python imports import json + import requests +# Third Party imports +from celery import shared_task + # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone -# Third Party imports -from celery import shared_task -from sentry_sdk import capture_exception +from plane.app.serializers import IssueActivitySerializer +from plane.bgtasks.notification_task import notifications # Module imports from plane.db.models import ( - User, - Issue, - Project, - Label, - IssueActivity, - State, - Cycle, - Module, - IssueReaction, CommentReaction, + Cycle, + Issue, + IssueActivity, IssueComment, + IssueReaction, IssueSubscriber, + Label, + Module, + Project, + State, + User, ) -from plane.app.serializers import IssueActivitySerializer -from plane.bgtasks.notification_task import notifications from plane.settings.redis import redis_instance +from plane.utils.exception_logger import log_exception # Track Changes in name @@ -1551,6 +1553,46 @@ def delete_draft_issue_activity( ) +def create_inbox_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + 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 + ) + status_dict = { + -2: "Pending", + -1: "Rejected", + 0: "Snoozed", + 1: "Accepted", + 2: "Duplicate", + } + if requested_data.get("status") is not None: + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment="updated the inbox status", + field="inbox", + verb=requested_data.get("status"), + actor_id=actor_id, + epoch=epoch, + old_value=status_dict.get(current_instance.get("status")), + new_value=status_dict.get(requested_data.get("status")), + ) + ) + + # Receive message from room group @shared_task def issue_activity( @@ -1611,6 +1653,7 @@ def issue_activity( "issue_draft.activity.created": create_draft_issue_activity, "issue_draft.activity.updated": update_draft_issue_activity, "issue_draft.activity.deleted": delete_draft_issue_activity, + "inbox.activity.created": create_inbox_activity, } func = ACTIVITY_MAPPER.get(type) @@ -1647,7 +1690,7 @@ def issue_activity( headers=headers, ) except Exception as e: - capture_exception(e) + log_exception(e) if notification: notifications.delay( @@ -1668,8 +1711,5 @@ def issue_activity( return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 08c07b7b3..cdcdcd174 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -2,18 +2,17 @@ import json from datetime import timedelta -# Django imports -from django.utils import timezone -from django.db.models import Q -from django.conf import settings - # Third party imports from celery import shared_task -from sentry_sdk import capture_exception +from django.db.models import Q + +# Django imports +from django.utils import timezone # Module imports -from plane.db.models import Issue, Project, State from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import Issue, Project, State +from plane.utils.exception_logger import log_exception @shared_task @@ -96,9 +95,7 @@ def archive_old_issues(): ] return except Exception as e: - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return @@ -179,7 +176,5 @@ def close_old_issues(): ] return except Exception as e: - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 019f5b13c..4544e9889 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,17 +1,17 @@ # Python imports +import logging + +# Third party imports +from celery import shared_task # Django imports 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 - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception @shared_task @@ -23,6 +23,7 @@ def magic_link(email, key, token, current_site): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -41,6 +42,7 @@ def magic_link(email, key, token, current_site): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -52,11 +54,8 @@ def magic_link(email, key, token, current_site): ) msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent successfully.") return except Exception as e: - print(e) - capture_exception(e) - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py new file mode 100644 index 000000000..eceb3693e --- /dev/null +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -0,0 +1,76 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone + +# Third-party imports +from bs4 import BeautifulSoup + +# Module imports +from plane.db.models import Page, PageLog +from celery import shared_task + + +def extract_components(value, tag): + try: + mentions = [] + html = value.get("description_html") + soup = BeautifulSoup(html, "html.parser") + mention_tags = soup.find_all(tag) + + for mention_tag in mention_tags: + mention = { + "id": mention_tag.get("id"), + "entity_identifier": mention_tag.get("entity_identifier"), + "entity_name": mention_tag.get("entity_name"), + } + mentions.append(mention) + + return mentions + except Exception: + return [] + + +@shared_task +def page_transaction(new_value, old_value, page_id): + page = Page.objects.get(pk=page_id) + new_page_mention = PageLog.objects.filter(page_id=page_id).exists() + + old_value = json.loads(old_value) if old_value else {} + + new_transactions = [] + deleted_transaction_ids = set() + + # TODO - Add "issue-embed-component", "img", "todo" components + components = ["mention-component"] + for component in components: + old_mentions = extract_components(old_value, component) + new_mentions = extract_components(new_value, component) + + new_mentions_ids = {mention["id"] for mention in new_mentions} + old_mention_ids = {mention["id"] for mention in old_mentions} + deleted_transaction_ids.update(old_mention_ids - new_mentions_ids) + + new_transactions.extend( + PageLog( + transaction=mention["id"], + page_id=page_id, + entity_identifier=mention["entity_identifier"], + entity_name=mention["entity_name"], + workspace_id=page.workspace_id, + project_id=page.project_id, + created_at=timezone.now(), + updated_at=timezone.now(), + ) + for mention in new_mentions + if mention["id"] not in old_mention_ids or not new_page_mention + ) + + # Create new PageLog objects for new transactions + PageLog.objects.bulk_create( + new_transactions, batch_size=10, ignore_conflicts=True + ) + + # Delete the removed transactions + PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index d24db5ae9..b60c49da1 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -1,18 +1,18 @@ -# Python import +# Python imports +import logging + +# Third party imports +from celery import shared_task # Django imports 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 - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports -from plane.db.models import Project, User, ProjectMemberInvite +from plane.db.models import Project, ProjectMemberInvite, User from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception @shared_task @@ -52,6 +52,7 @@ def project_invitation(email, project_id, token, current_site, invitor): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -61,6 +62,7 @@ def project_invitation(email, project_id, token, current_site, invitor): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -73,12 +75,10 @@ def project_invitation(email, project_id, token, current_site, invitor): msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent successfully.") return except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist): return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 358fd7a85..5ee0244c7 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -1,44 +1,45 @@ -import requests -import uuid import hashlib -import json import hmac +import json +import logging +import uuid -# Django imports -from django.conf import settings -from django.core.serializers.json import DjangoJSONEncoder -from django.core.mail import EmailMultiAlternatives, get_connection -from django.template.loader import render_to_string -from django.utils.html import strip_tags +import requests # Third party imports from celery import shared_task -from sentry_sdk import capture_exception -from plane.db.models import ( - Webhook, - WebhookLog, - Project, - Issue, - Cycle, - Module, - ModuleIssue, - CycleIssue, - IssueComment, - User, -) -from plane.api.serializers import ( - ProjectSerializer, - CycleSerializer, - ModuleSerializer, - CycleIssueSerializer, - ModuleIssueSerializer, - IssueCommentSerializer, - IssueExpandSerializer, -) +# Django imports +from django.conf import settings +from django.core.mail import EmailMultiAlternatives, get_connection +from django.core.serializers.json import DjangoJSONEncoder +from django.template.loader import render_to_string +from django.utils.html import strip_tags # Module imports +from plane.api.serializers import ( + CycleIssueSerializer, + CycleSerializer, + IssueCommentSerializer, + IssueExpandSerializer, + ModuleIssueSerializer, + ModuleSerializer, + ProjectSerializer, +) +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + IssueComment, + Module, + ModuleIssue, + Project, + User, + Webhook, + WebhookLog, +) from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception SERIALIZER_MAPPER = { "project": ProjectSerializer, @@ -174,7 +175,7 @@ def webhook_task(self, webhook, slug, event, event_data, action, current_site): except Exception as e: if settings.DEBUG: print(e) - capture_exception(e) + log_exception(e) return @@ -201,16 +202,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site): if webhooks: if action in ["POST", "PATCH"]: if bulk and event in ["cycle_issue", "module_issue"]: - event_data = IssueExpandSerializer( - Issue.objects.filter( - pk__in=[ - str(event.get("issue")) for event in payload - ] - ).prefetch_related("issue_cycle", "issue_module"), - many=True, - ).data - event = "issue" - action = "PATCH" + return else: event_data = [ get_model_data( @@ -218,7 +210,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site): event_id=( payload.get("id") if isinstance(payload, dict) - else None + else kw.get("pk") ), many=False, ) @@ -241,7 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk, current_site): except Exception as e: if settings.DEBUG: print(e) - capture_exception(e) + log_exception(e) return @@ -256,6 +248,7 @@ def send_webhook_deactivation_email( EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -284,6 +277,7 @@ def send_webhook_deactivation_email( username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -295,8 +289,8 @@ def send_webhook_deactivation_email( ) msg.attach_alternative(html_content, "text/html") msg.send() - + logging.getLogger("plane").info("Email sent successfully.") return except Exception as e: - print(e) + log_exception(e) return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index cc3000bbb..c0b945e62 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,18 +1,18 @@ # Python imports +import logging + +# Third party imports +from celery import shared_task # Django imports 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 - -# Third party imports -from celery import shared_task -from sentry_sdk import capture_exception # Module imports -from plane.db.models import Workspace, WorkspaceMemberInvite, User +from plane.db.models import User, Workspace, WorkspaceMemberInvite from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception @shared_task @@ -37,6 +37,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -65,6 +66,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", ) msg = EmailMultiAlternatives( @@ -76,14 +78,12 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) msg.attach_alternative(html_content, "text/html") msg.send() + logging.getLogger("plane").info("Email sent succesfully") return - except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist): - print("Workspace or WorkspaceMember Invite Does not exists") + except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: + log_exception(e) return except Exception as e: - # Print logs if in DEBUG mode - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return diff --git a/apiserver/plane/db/management/commands/create_dummy_data.py b/apiserver/plane/db/management/commands/create_dummy_data.py new file mode 100644 index 000000000..dde1411fe --- /dev/null +++ b/apiserver/plane/db/management/commands/create_dummy_data.py @@ -0,0 +1,95 @@ +# Django imports +from typing import Any +from django.core.management.base import BaseCommand, CommandError + +# Module imports +from plane.db.models import User, Workspace, WorkspaceMember + + +class Command(BaseCommand): + help = "Create dump issues, cycles etc. for a project in a given workspace" + + def handle(self, *args: Any, **options: Any) -> str | None: + + try: + workspace_name = input("Workspace Name: ") + workspace_slug = input("Workspace slug: ") + + if workspace_slug == "": + raise CommandError("Workspace slug is required") + + if Workspace.objects.filter(slug=workspace_slug).exists(): + raise CommandError("Workspace already exists") + + creator = input("Your email: ") + + if ( + creator == "" + or not User.objects.filter(email=creator).exists() + ): + raise CommandError( + "User email is required and should have signed in plane" + ) + + user = User.objects.get(email=creator) + + members = input("Enter Member emails (comma separated): ") + members = members.split(",") if members != "" else [] + # Create workspace + workspace = Workspace.objects.create( + slug=workspace_slug, + name=workspace_name, + owner=user, + ) + # Create workspace member + WorkspaceMember.objects.create( + workspace=workspace, role=20, member=user + ) + user_ids = User.objects.filter(email__in=members) + + _ = WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace=workspace, + member=user_id, + role=20, + ) + for user_id in user_ids + ], + ignore_conflicts=True, + ) + + project_count = int(input("Number of projects to be created: ")) + + for i in range(project_count): + print(f"Please provide the following details for project {i+1}:") + issue_count = int(input("Number of issues to be created: ")) + cycle_count = int(input("Number of cycles to be created: ")) + module_count = int(input("Number of modules to be created: ")) + pages_count = int(input("Number of pages to be created: ")) + inbox_issue_count = int( + input("Number of inbox issues to be created: ") + ) + + from plane.bgtasks.dummy_data_task import create_dummy_data + + create_dummy_data.delay( + slug=workspace_slug, + email=creator, + members=members, + issue_count=issue_count, + cycle_count=cycle_count, + module_count=module_count, + pages_count=pages_count, + inbox_issue_count=inbox_issue_count, + ) + + self.stdout.write( + self.style.SUCCESS("Data is pushed to the queue") + ) + return + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Command errored out {str(e)}") + ) + return diff --git a/apiserver/plane/db/management/commands/create_instance_admin.py b/apiserver/plane/db/management/commands/create_instance_admin.py new file mode 100644 index 000000000..21f79c15e --- /dev/null +++ b/apiserver/plane/db/management/commands/create_instance_admin.py @@ -0,0 +1,48 @@ +# Django imports +from django.core.management.base import BaseCommand, CommandError + +# Module imports +from plane.license.models import Instance, InstanceAdmin +from plane.db.models import User + + +class Command(BaseCommand): + help = "Add a new instance admin" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument( + "admin_email", type=str, help="Instance Admin Email" + ) + + def handle(self, *args, **options): + + admin_email = options.get("admin_email", False) + + if not admin_email: + raise CommandError("Please provide the email of the admin.") + + user = User.objects.filter(email=admin_email).first() + if user is None: + raise CommandError("User with the provided email does not exist.") + + try: + # Get the instance + instance = Instance.objects.last() + + # Get or create an instance admin + _, created = InstanceAdmin.objects.get_or_create( + user=user, instance=instance, role=20 + ) + + if not created: + raise CommandError( + "The provided email is already an instance admin." + ) + + self.stdout.write( + self.style.SUCCESS("Successfully created the admin") + ) + except Exception as e: + print(e) + raise CommandError("Failed to create the instance admin.") diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py index d36a784d0..63b602518 100644 --- a/apiserver/plane/db/management/commands/test_email.py +++ b/apiserver/plane/db/management/commands/test_email.py @@ -15,7 +15,7 @@ class Command(BaseCommand): receiver_email = options.get("to_email") if not receiver_email: - raise CommandError("Reciever email is required") + raise CommandError("Receiver email is required") ( EMAIL_HOST, @@ -23,6 +23,7 @@ class Command(BaseCommand): EMAIL_HOST_PASSWORD, EMAIL_PORT, EMAIL_USE_TLS, + EMAIL_USE_SSL, EMAIL_FROM, ) = get_email_configuration() @@ -32,6 +33,7 @@ class Command(BaseCommand): username=EMAIL_HOST_USER, password=EMAIL_HOST_PASSWORD, use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", timeout=30, ) # Prepare email details @@ -52,7 +54,7 @@ class Command(BaseCommand): connection=connection, ) msg.send() - self.stdout.write(self.style.SUCCESS("Email succesfully sent")) + self.stdout.write(self.style.SUCCESS("Email successfully sent")) except Exception as e: self.stdout.write( self.style.ERROR( diff --git a/apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py b/apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py new file mode 100644 index 000000000..be3f9fc2a --- /dev/null +++ b/apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.7 on 2024-03-19 08:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0061_project_logo_props'), + ] + + operations = [ + migrations.AddField( + model_name="cycle", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="module", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="project", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="medium", + field=models.CharField( + choices=[ + ("Google", "google"), + ("Github", "github"), + ("Jira", "jira"), + ], + default=None, + max_length=20, + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py b/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py new file mode 100644 index 000000000..66303dfe6 --- /dev/null +++ b/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.10 on 2024-04-02 12:18 + +from django.db import migrations, models + + +def update_project_state_group(apps, schema_editor): + State = apps.get_model("db", "State") + + # Update states in bulk + State.objects.filter(group="backlog", name="Triage").update( + is_triage=True, group="triage" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0062_cycle_archived_at_module_archived_at_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="state", + name="is_triage", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="state", + name="group", + field=models.CharField( + choices=[ + ("backlog", "Backlog"), + ("unstarted", "Unstarted"), + ("started", "Started"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ("triage", "Triage"), + ], + default="backlog", + max_length=20, + ), + ), + migrations.RunPython(update_project_state_group), + ] diff --git a/apiserver/plane/db/migrations/0064_auto_20240409_1134.py b/apiserver/plane/db/migrations/0064_auto_20240409_1134.py new file mode 100644 index 000000000..53e5938af --- /dev/null +++ b/apiserver/plane/db/migrations/0064_auto_20240409_1134.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.10 on 2024-04-09 11:34 + +from django.db import migrations, models +import plane.db.models.page + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0063_state_is_triage_alter_state_group'), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="view_props", + field=models.JSONField( + default=plane.db.models.page.get_view_props + ), + ), + ] diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index d802dbc1e..15a8251d7 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -69,6 +69,7 @@ class Cycle(ProjectBaseModel): external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) progress_snapshot = models.JSONField(default=dict) + archived_at = models.DateTimeField(null=True) class Meta: verbose_name = "Cycle" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 5bd0b3397..01a43abca 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -91,6 +91,7 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__isnull=True) ) .exclude(archived_at__isnull=False) + .exclude(project__archived_at__isnull=False) .exclude(is_draft=True) ) @@ -170,14 +171,14 @@ class Issue(ProjectBaseModel): from plane.db.models import State default_state = State.objects.filter( - ~models.Q(name="Triage"), + ~models.Q(is_triage=True), project=self.project, default=True, ).first() # if there is no default state assign any random state if default_state is None: random_state = State.objects.filter( - ~models.Q(name="Triage"), project=self.project + ~models.Q(is_triage=True), project=self.project ).first() self.state = random_state else: diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 9af4e120e..b201e4d7f 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -92,6 +92,7 @@ class Module(ProjectBaseModel): sort_order = models.FloatField(default=65535) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + archived_at = models.DateTimeField(null=True) class Meta: unique_together = ["name", "project"] diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 6ed94798a..da7e050bb 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -9,6 +9,10 @@ from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +def get_view_props(): + return {"full_width": False} + + class Page(ProjectBaseModel): name = models.CharField(max_length=255) description = models.JSONField(default=dict, blank=True) @@ -35,6 +39,7 @@ class Page(ProjectBaseModel): ) archived_at = models.DateField(null=True) is_locked = models.BooleanField(default=False) + view_props = models.JSONField(default=get_view_props) class Meta: verbose_name = "Page" @@ -81,7 +86,7 @@ class PageLog(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.page.name} {self.type}" + return f"{self.page.name} {self.entity_name}" class PageBlock(ProjectBaseModel): diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index bb4885d14..db5ebf33b 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -114,6 +114,7 @@ class Project(BaseModel): null=True, related_name="default_state", ) + archived_at = models.DateTimeField(null=True) def __str__(self): """Return name of the project""" diff --git a/apiserver/plane/db/models/state.py b/apiserver/plane/db/models/state.py index ab9b780c8..28e3b25a1 100644 --- a/apiserver/plane/db/models/state.py +++ b/apiserver/plane/db/models/state.py @@ -21,10 +21,12 @@ class State(ProjectBaseModel): ("started", "Started"), ("completed", "Completed"), ("cancelled", "Cancelled"), + ("triage", "Triage") ), default="backlog", max_length=20, ) + is_triage = models.BooleanField(default=False) default = models.BooleanField(default=False) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index c9a8b4cb6..5f932d2ea 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -1,16 +1,17 @@ # Python imports -import uuid -import string import random +import string +import uuid + import pytz +from django.contrib.auth.models import ( + AbstractBaseUser, + PermissionsMixin, + UserManager, +) # 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.utils import timezone diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 9365f07c5..1bb103113 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -88,6 +88,12 @@ class Command(BaseCommand): "category": "SMTP", "is_encrypted": False, }, + { + "key": "EMAIL_USE_SSL", + "value": os.environ.get("EMAIL_USE_SSL", "0"), + "category": "SMTP", + "is_encrypted": False, + }, { "key": "OPENAI_API_KEY", "value": os.environ.get("OPENAI_API_KEY"), diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py index bc4fd5d21..4c191feda 100644 --- a/apiserver/plane/license/utils/instance_value.py +++ b/apiserver/plane/license/utils/instance_value.py @@ -64,6 +64,10 @@ def get_email_configuration(): "key": "EMAIL_USE_TLS", "default": os.environ.get("EMAIL_USE_TLS", "1"), }, + { + "key": "EMAIL_USE_SSL", + "default": os.environ.get("EMAIL_USE_SSL", "0"), + }, { "key": "EMAIL_FROM", "default": os.environ.get( diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 5c8947e73..06c6778d9 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -3,19 +3,20 @@ # Python imports import os import ssl -import certifi from datetime import timedelta from urllib.parse import urlparse -# Django imports -from django.core.management.utils import get_random_secret_key +import certifi # Third party imports import dj_database_url import sentry_sdk + +# Django imports +from django.core.management.utils import get_random_secret_key +from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration -from sentry_sdk.integrations.celery import CeleryIntegration BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -23,7 +24,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key()) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False +DEBUG = int(os.environ.get("DEBUG", "0")) # Allowed Hosts ALLOWED_HOSTS = ["*"] @@ -287,10 +288,13 @@ else: CELERY_BROKER_URL = REDIS_URL CELERY_IMPORTS = ( + # scheduled tasks "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", "plane.bgtasks.email_notification_task", + # management tasks + "plane.bgtasks.dummy_data_task", ) # Sentry Settings diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index a09a55ccf..b00684eae 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -7,8 +7,8 @@ from .common import * # noqa DEBUG = True # Debug Toolbar settings -INSTALLED_APPS += ("debug_toolbar",) -MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) +INSTALLED_APPS += ("debug_toolbar",) # noqa +MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa DEBUG_TOOLBAR_PATCH_SETTINGS = False @@ -18,7 +18,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, + "LOCATION": REDIS_URL, # noqa "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", }, @@ -28,7 +28,7 @@ CACHES = { INTERNAL_IPS = ("127.0.0.1",) MEDIA_URL = "/uploads/" -MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") +MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", @@ -36,3 +36,38 @@ CORS_ALLOWED_ORIGINS = [ "http://localhost:4000", "http://127.0.0.1:4000", ] + +LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa + +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "loggers": { + "django.request": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + "plane": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 5a9c3413d..c56222c67 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -1,6 +1,7 @@ """Production settings""" import os + from .common import * # noqa # SECURITY WARNING: don't run with debug turned on in production! @@ -9,7 +10,7 @@ DEBUG = int(os.environ.get("DEBUG", 0)) == 1 # Honor the 'X-Forwarded-Proto' header for request.is_secure() SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -INSTALLED_APPS += ("scout_apm.django",) +INSTALLED_APPS += ("scout_apm.django",) # noqa # Honor the 'X-Forwarded-Proto' header for request.is_secure() SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") @@ -18,3 +19,62 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" + +LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa + +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +# Logging configuration +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "json": { + "()": "pythonjsonlogger.jsonlogger.JsonFormatter", + "fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + "level": "INFO", + }, + "file": { + "class": "plane.utils.logging.SizedTimedRotatingFileHandler", + "filename": ( + os.path.join(BASE_DIR, "logs", "plane-debug.log") # noqa + if DEBUG + else os.path.join(BASE_DIR, "logs", "plane-error.log") # noqa + ), + "when": "s", + "maxBytes": 1024 * 1024 * 1, + "interval": 1, + "backupCount": 5, + "formatter": "json", + "level": "DEBUG" if DEBUG else "ERROR", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": True, + }, + "django.request": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + }, + "plane": { + "level": "DEBUG" if DEBUG else "ERROR", + "handlers": ["console", "file"], + "propagate": False, + }, + }, +} diff --git a/apiserver/plane/settings/test.py b/apiserver/plane/settings/test.py index 84153d37a..a86b044a3 100644 --- a/apiserver/plane/settings/test.py +++ b/apiserver/plane/settings/test.py @@ -7,6 +7,6 @@ DEBUG = True # Send it in a dummy outbox EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" -INSTALLED_APPS.append( +INSTALLED_APPS.append( # noqa "plane.tests", ) diff --git a/apiserver/plane/space/views/base.py b/apiserver/plane/space/views/base.py index 54dac080c..023f27bbc 100644 --- a/apiserver/plane/space/views/base.py +++ b/apiserver/plane/space/views/base.py @@ -1,25 +1,25 @@ # Python imports import zoneinfo +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.db import IntegrityError # Django imports from django.urls import resolve -from django.conf import settings from django.utils import timezone -from django.db import IntegrityError -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django_filters.rest_framework import DjangoFilterBackend # Third part imports from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response from rest_framework.exceptions import APIException -from rest_framework.views import APIView from rest_framework.filters import SearchFilter from rest_framework.permissions import IsAuthenticated -from sentry_sdk import capture_exception -from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet # Module imports +from plane.utils.exception_logger import log_exception from plane.utils.paginator import BasePaginator @@ -57,7 +57,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): try: return self.model.objects.all() except Exception as e: - capture_exception(e) + log_exception(e) raise APIException( "Please check the view", status.HTTP_400_BAD_REQUEST ) @@ -90,14 +90,13 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): ) if isinstance(e, KeyError): - capture_exception(e) + log_exception(e) return Response( {"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) + log_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -185,9 +184,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): status=status.HTTP_400_BAD_REQUEST, ) - if settings.DEBUG: - print(e) - capture_exception(e) + log_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 948eb1b91..cd57690c6 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -1,18 +1,18 @@ # Python imports -from itertools import groupby from datetime import timedelta +from itertools import groupby # 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 import Case, CharField, Count, F, Sum, Value, When from django.db.models.functions import ( Coalesce, + Concat, ExtractMonth, ExtractYear, - Concat, + TruncDate, ) +from django.utils import timezone # Module imports from plane.db.models import Issue @@ -115,11 +115,16 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): total_issues = queryset.total_issues if cycle_id: - # Get all dates between the two dates - date_range = [ - queryset.start_date + timedelta(days=x) - for x in range((queryset.end_date - queryset.start_date).days + 1) - ] + if queryset.end_date and queryset.start_date: + # Get all dates between the two dates + date_range = [ + queryset.start_date + timedelta(days=x) + for x in range( + (queryset.end_date - queryset.start_date).days + 1 + ) + ] + else: + date_range = [] chart_data = {str(date): 0 for date in date_range} diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index aece1d644..071051129 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -33,12 +33,12 @@ def cache_response(timeout=60 * 60, path=None, user=True): custom_path = path if path is not None else request.get_full_path() key = generate_cache_key(custom_path, auth_header) cached_result = cache.get(key) + if cached_result is not None: return Response( cached_result["data"], status=cached_result["status"] ) response = view_func(instance, request, *args, **kwargs) - if response.status_code == 200 and not settings.DEBUG: cache.set( key, @@ -53,34 +53,42 @@ def cache_response(timeout=60 * 60, path=None, user=True): return decorator -def invalidate_cache(path=None, url_params=False, user=True): - """invalidate cache per user""" +def invalidate_cache_directly( + path=None, url_params=False, user=True, request=None, multiple=False +): + if url_params and path: + path_with_values = path + # Assuming `kwargs` could be passed directly if needed, otherwise, skip this part + for key, value in request.resolver_match.kwargs.items(): + path_with_values = path_with_values.replace(f":{key}", str(value)) + custom_path = path_with_values + else: + custom_path = path if path is not None else request.get_full_path() + auth_header = ( + None + if request.user.is_anonymous + else str(request.user.id) if user else None + ) + key = generate_cache_key(custom_path, auth_header) + if multiple: + cache.delete_many(keys=cache.keys(f"*{key}*")) + else: + cache.delete(key) + + +def invalidate_cache(path=None, url_params=False, user=True, multiple=False): def decorator(view_func): @wraps(view_func) def _wrapped_view(instance, request, *args, **kwargs): - # Invalidate cache before executing the view function - if url_params: - path_with_values = path - for key, value in kwargs.items(): - path_with_values = path_with_values.replace( - f":{key}", str(value) - ) - - custom_path = path_with_values - else: - custom_path = ( - path if path is not None else request.get_full_path() - ) - - auth_header = ( - None - if request.user.is_anonymous - else str(request.user.id) if user else None + # invalidate the cache + invalidate_cache_directly( + path=path, + url_params=url_params, + user=user, + request=request, + multiple=multiple, ) - key = generate_cache_key(custom_path, auth_header) - cache.delete(key) - # Execute the view function return view_func(instance, request, *args, **kwargs) return _wrapped_view diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py new file mode 100644 index 000000000..f7bb50de2 --- /dev/null +++ b/apiserver/plane/utils/exception_logger.py @@ -0,0 +1,15 @@ +# Python imports +import logging + +# Third party imports +from sentry_sdk import capture_exception + + +def log_exception(e): + # Log the error + logger = logging.getLogger("plane") + logger.error(e) + + # Capture in sentry if configured + capture_exception(e) + return diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 2c4cbd471..531ef93ec 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -52,9 +52,9 @@ def string_date_filter( filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration) else: if offset == "fromnow": - filter[f"{date_filter}__lte"] = now + timedelta(days=duration) + filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration) else: - filter[f"{date_filter}__lte"] = now - timedelta(days=duration) + filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration) def date_filter(filter, date_term, queries): @@ -83,25 +83,25 @@ def date_filter(filter, date_term, queries): filter[f"{date_term}__lte"] = date_query[0] -def filter_state(params, filter, method): +def filter_state(params, filter, method, prefix=""): if method == "GET": 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 + filter[f"{prefix}state__in"] = states else: if ( params.get("state", None) and len(params.get("state")) and params.get("state") != "null" ): - filter["state__in"] = params.get("state") + filter[f"{prefix}state__in"] = params.get("state") return filter -def filter_state_group(params, filter, method): +def filter_state_group(params, filter, method, prefix=""): if method == "GET": state_group = [ item @@ -109,18 +109,18 @@ def filter_state_group(params, filter, method): if item != "null" ] if len(state_group) and "" not in state_group: - filter["state__group__in"] = state_group + filter[f"{prefix}state__group__in"] = state_group else: 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") + filter[f"{prefix}state__group__in"] = params.get("state_group") return filter -def filter_estimate_point(params, filter, method): +def filter_estimate_point(params, filter, method, prefix=""): if method == "GET": estimate_points = [ item @@ -128,18 +128,20 @@ def filter_estimate_point(params, filter, method): if item != "null" ] if len(estimate_points) and "" not in estimate_points: - filter["estimate_point__in"] = estimate_points + filter[f"{prefix}estimate_point__in"] = estimate_points else: 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") + filter[f"{prefix}estimate_point__in"] = params.get( + "estimate_point" + ) return filter -def filter_priority(params, filter, method): +def filter_priority(params, filter, method, prefix=""): if method == "GET": priorities = [ item @@ -147,47 +149,47 @@ def filter_priority(params, filter, method): if item != "null" ] if len(priorities) and "" not in priorities: - filter["priority__in"] = priorities + filter[f"{prefix}priority__in"] = priorities return filter -def filter_parent(params, filter, method): +def filter_parent(params, filter, method, prefix=""): if method == "GET": 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 + filter[f"{prefix}parent__in"] = parents else: if ( params.get("parent", None) and len(params.get("parent")) and params.get("parent") != "null" ): - filter["parent__in"] = params.get("parent") + filter[f"{prefix}parent__in"] = params.get("parent") return filter -def filter_labels(params, filter, method): +def filter_labels(params, filter, method, prefix=""): if method == "GET": 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 + filter[f"{prefix}labels__in"] = labels else: if ( params.get("labels", None) and len(params.get("labels")) and params.get("labels") != "null" ): - filter["labels__in"] = params.get("labels") + filter[f"{prefix}labels__in"] = params.get("labels") return filter -def filter_assignees(params, filter, method): +def filter_assignees(params, filter, method, prefix=""): if method == "GET": assignees = [ item @@ -196,18 +198,18 @@ def filter_assignees(params, filter, method): ] assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: - filter["assignees__in"] = assignees + filter[f"{prefix}assignees__in"] = assignees else: if ( params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != "null" ): - filter["assignees__in"] = params.get("assignees") + filter[f"{prefix}assignees__in"] = params.get("assignees") return filter -def filter_mentions(params, filter, method): +def filter_mentions(params, filter, method, prefix=""): if method == "GET": mentions = [ item @@ -216,18 +218,20 @@ def filter_mentions(params, filter, method): ] mentions = filter_valid_uuids(mentions) if len(mentions) and "" not in mentions: - filter["issue_mention__mention__id__in"] = mentions + filter[f"{prefix}issue_mention__mention__id__in"] = mentions else: if ( params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != "null" ): - filter["issue_mention__mention__id__in"] = params.get("mentions") + filter[f"{prefix}issue_mention__mention__id__in"] = params.get( + "mentions" + ) return filter -def filter_created_by(params, filter, method): +def filter_created_by(params, filter, method, prefix=""): if method == "GET": created_bys = [ item @@ -236,94 +240,98 @@ def filter_created_by(params, filter, method): ] created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: - filter["created_by__in"] = created_bys + filter[f"{prefix}created_by__in"] = created_bys else: 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") + filter[f"{prefix}created_by__in"] = params.get("created_by") return filter -def filter_name(params, filter, method): +def filter_name(params, filter, method, prefix=""): if params.get("name", "") != "": - filter["name__icontains"] = params.get("name") + filter[f"{prefix}name__icontains"] = params.get("name") return filter -def filter_created_at(params, filter, method): +def filter_created_at(params, filter, method, prefix=""): 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", + date_term=f"{prefix}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", + date_term=f"{prefix}created_at__date", queries=params.get("created_at", []), ) return filter -def filter_updated_at(params, filter, method): +def filter_updated_at(params, filter, method, prefix=""): 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", + date_term=f"{prefix}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", + date_term=f"{prefix}created_at__date", queries=params.get("updated_at", []), ) return filter -def filter_start_date(params, filter, method): +def filter_start_date(params, filter, method, prefix=""): 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 + filter=filter, + date_term=f"{prefix}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") + filter[f"{prefix}start_date"] = params.get("start_date") return filter -def filter_target_date(params, filter, method): +def filter_target_date(params, filter, method, prefix=""): 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 + filter=filter, + date_term=f"{prefix}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") + filter[f"{prefix}target_date"] = params.get("target_date") return filter -def filter_completed_at(params, filter, method): +def filter_completed_at(params, filter, method, prefix=""): 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", + date_term=f"{prefix}completed_at__date", queries=completed_ats, ) else: @@ -332,13 +340,13 @@ def filter_completed_at(params, filter, method): ): date_filter( filter=filter, - date_term="completed_at__date", + date_term=f"{prefix}completed_at__date", queries=params.get("completed_at", []), ) return filter -def filter_issue_state_type(params, filter, method): +def filter_issue_state_type(params, filter, method, prefix=""): type = params.get("type", "all") group = ["backlog", "unstarted", "started", "completed", "cancelled"] if type == "backlog": @@ -346,65 +354,67 @@ def filter_issue_state_type(params, filter, method): if type == "active": group = ["unstarted", "started"] - filter["state__group__in"] = group + filter[f"{prefix}state__group__in"] = group return filter -def filter_project(params, filter, method): +def filter_project(params, filter, method, prefix=""): if method == "GET": 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 + filter[f"{prefix}project__in"] = projects else: if ( params.get("project", None) and len(params.get("project")) and params.get("project") != "null" ): - filter["project__in"] = params.get("project") + filter[f"{prefix}project__in"] = params.get("project") return filter -def filter_cycle(params, filter, method): +def filter_cycle(params, filter, method, prefix=""): if method == "GET": 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 + filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles else: if ( params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != "null" ): - filter["issue_cycle__cycle_id__in"] = params.get("cycle") + filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle") return filter -def filter_module(params, filter, method): +def filter_module(params, filter, method, prefix=""): if method == "GET": 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 + filter[f"{prefix}issue_module__module_id__in"] = modules else: if ( params.get("module", None) and len(params.get("module")) and params.get("module") != "null" ): - filter["issue_module__module_id__in"] = params.get("module") + filter[f"{prefix}issue_module__module_id__in"] = params.get( + "module" + ) return filter -def filter_inbox_status(params, filter, method): +def filter_inbox_status(params, filter, method, prefix=""): if method == "GET": status = [ item @@ -412,30 +422,32 @@ def filter_inbox_status(params, filter, method): if item != "null" ] if len(status) and "" not in status: - filter["issue_inbox__status__in"] = status + filter[f"{prefix}issue_inbox__status__in"] = status else: 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") + filter[f"{prefix}issue_inbox__status__in"] = params.get( + "inbox_status" + ) return filter -def filter_sub_issue_toggle(params, filter, method): +def filter_sub_issue_toggle(params, filter, method, prefix=""): if method == "GET": sub_issue = params.get("sub_issue", "false") if sub_issue == "false": - filter["parent__isnull"] = True + filter[f"{prefix}parent__isnull"] = True else: sub_issue = params.get("sub_issue", "false") if sub_issue == "false": - filter["parent__isnull"] = True + filter[f"{prefix}parent__isnull"] = True return filter -def filter_subscribed_issues(params, filter, method): +def filter_subscribed_issues(params, filter, method, prefix=""): if method == "GET": subscribers = [ item @@ -444,28 +456,30 @@ def filter_subscribed_issues(params, filter, method): ] subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: - filter["issue_subscribers__subscriber_id__in"] = subscribers + filter[f"{prefix}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" + filter[f"{prefix}issue_subscribers__subscriber_id__in"] = ( + params.get("subscriber") ) return filter -def filter_start_target_date_issues(params, filter, method): +def filter_start_target_date_issues(params, filter, method, prefix=""): start_target_date = params.get("start_target_date", "false") if start_target_date == "true": - filter["target_date__isnull"] = False - filter["start_date__isnull"] = False + filter[f"{prefix}target_date__isnull"] = False + filter[f"{prefix}start_date__isnull"] = False return filter -def issue_filters(query_params, method): +def issue_filters(query_params, method, prefix=""): filter = {} ISSUE_FILTER = { @@ -497,6 +511,5 @@ def issue_filters(query_params, method): for key, value in ISSUE_FILTER.items(): if key in query_params: func = value - func(query_params, filter, method) - + func(query_params, filter, method, prefix) return filter diff --git a/apiserver/plane/utils/logging.py b/apiserver/plane/utils/logging.py new file mode 100644 index 000000000..8021689e9 --- /dev/null +++ b/apiserver/plane/utils/logging.py @@ -0,0 +1,46 @@ +import logging.handlers as handlers +import time + + +class SizedTimedRotatingFileHandler(handlers.TimedRotatingFileHandler): + """ + Handler for logging to a set of files, which switches from one file + to the next when the current file reaches a certain size, or at certain + timed intervals + """ + + def __init__( + self, + filename, + maxBytes=0, + backupCount=0, + encoding=None, + delay=0, + when="h", + interval=1, + utc=False, + ): + handlers.TimedRotatingFileHandler.__init__( + self, filename, when, interval, backupCount, encoding, delay, utc + ) + self.maxBytes = maxBytes + + def shouldRollover(self, record): + """ + Determine if rollover should occur. + + Basically, see if the supplied record would cause the file to exceed + the size limit we have. + """ + if self.stream is None: # delay was set... + self.stream = self._open() + if self.maxBytes > 0: # are we rolling over? + msg = "%s\n" % self.format(record) + # due to non-posix-compliant Windows feature + self.stream.seek(0, 2) + if self.stream.tell() + len(msg) >= self.maxBytes: + return 1 + t = int(time.time()) + if t >= self.rolloverAt: + return 1 + return 0 diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index db0ede6ad..8cc853370 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -134,7 +134,7 @@ class OffsetPaginator: results=results, next=next_cursor, prev=prev_cursor, - hits=None, + hits=count, max_hits=max_hits, ) @@ -217,6 +217,7 @@ class BasePaginator: "prev_page_results": cursor_result.prev.has_results, "count": cursor_result.__len__(), "total_pages": cursor_result.max_hits, + "total_results": cursor_result.hits, "extra_stats": extra_stats, "results": results, } diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index eb0f54201..2b7d383ba 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,6 +1,6 @@ # base requirements -Django==4.2.10 +Django==4.2.11 psycopg==3.1.12 djangorestframework==3.14.0 redis==4.6.0 @@ -27,6 +27,7 @@ psycopg-binary==3.1.12 psycopg-c==3.1.12 scout-apm==2.26.1 openpyxl==3.1.2 +python-json-logger==2.0.7 beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 diff --git a/apiserver/requirements/production.txt b/apiserver/requirements/production.txt index a0e9f8a17..bea44fcfe 100644 --- a/apiserver/requirements/production.txt +++ b/apiserver/requirements/production.txt @@ -1,3 +1,3 @@ -r base.txt -gunicorn==21.2.0 +gunicorn==22.0.0 diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index 424240cc0..cd0aac542 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.8 \ No newline at end of file +python-3.11.9 \ No newline at end of file diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apiserver/templates/emails/invitations/project_invitation.html index 630a5eab3..def576601 100644 --- a/apiserver/templates/emails/invitations/project_invitation.html +++ b/apiserver/templates/emails/invitations/project_invitation.html @@ -1,349 +1,1815 @@ - - - - - - - {{ first_name }} invited you to join {{ project_name }} on Plane - - - - - - - - + + + + - - - - - - + + + + + + diff --git a/deploy/1-click/README.md b/deploy/1-click/README.md deleted file mode 100644 index 9ed2323de..000000000 --- a/deploy/1-click/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# One-click deploy - -Deployment methods for Plane have improved significantly to make self-managing super-easy. One of those is a single-line-command installation of Plane. - -This short guide will guide you through the process, the background tasks that run with the command for the Community, One, and Enterprise editions, and the post-deployment configuration options available to you. - -### Requirements - -- Operating systems: Debian, Ubuntu, CentOS -- Supported CPU architectures: AMD64, ARM64, x86_64, AArch64 - -### Download the latest stable release - -Run ↓ on any CLI. - -``` -curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh - -``` - -### Download the Preview release - -`Preview` builds do not support ARM64, AArch64 CPU architectures - -Run ↓ on any CLI. - -``` -export BRANCH=preview -curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh - -``` - ---- - -### Successful installation - -You should see ↓ if there are no hitches. That output will also list the IP address you can use to access your Plane instance. - -![Install Output](images/install.png) - ---- - -### Manage your Plane instance - -Use `plane-app` [OPERATOR] to manage your Plane instance easily. Get a list of all operators with `plane-app ---help`. - -![Plane Help](images/help.png) - -1. Basic operators - - 1. `plane-app start` starts the Plane server. - 2. `plane-app restart` restarts the Plane server. - 3. `plane-app stop` stops the Plane server. - -2. Advanced operators - - `plane-app --configure` will show advanced configurators. - - - Change your proxy or listening port -
Default: 80 - - Change your domain name -
Default: Deployed server's public IP address - - File upload size -
Default: 5MB - - Specify external database address when using an external database -
Default: `Empty` -
`Default folder: /opt/plane/data/postgres` - - Specify external Redis URL when using external Redis -
Default: `Empty` -
`Default folder: /opt/plane/data/redis` - - Configure AWS S3 bucket -
Use only when you or your users want to use S3 -
`Default folder: /opt/plane/data/minio` - -3. Version operators - - 1. `plane-app --upgrade` gets the latest stable version of `docker-compose.yaml`, `.env`, and Docker images - 2. `plane-app --update-installer` updates the installer and the `plane-app` utility. - 3. `plane-app --uninstall` uninstalls the Plane application and all Docker containers from the server but leaves the data stored in - Postgres, Redis, and Minio alone. - 4. `plane-app --install` installs the Plane app again. diff --git a/deploy/1-click/images/help.png b/deploy/1-click/images/help.png deleted file mode 100644 index c14603a4b..000000000 Binary files a/deploy/1-click/images/help.png and /dev/null differ diff --git a/deploy/1-click/images/install.png b/deploy/1-click/images/install.png deleted file mode 100644 index c8ba1e5f8..000000000 Binary files a/deploy/1-click/images/install.png and /dev/null differ diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh deleted file mode 100644 index 9a0eac902..000000000 --- a/deploy/1-click/install.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -export GIT_REPO=makeplane/plane - -# Check if the user has sudo access -if command -v curl &> /dev/null; then - sudo curl -sSL \ - -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) -else - sudo wget -q \ - -O /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) -fi - -sudo chmod +x /usr/local/bin/plane-app -sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app -sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app - -plane-app -i #--help diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app deleted file mode 100644 index ace0a0b79..000000000 --- a/deploy/1-click/plane-app +++ /dev/null @@ -1,791 +0,0 @@ -#!/bin/bash - -function print_header() { -clear - -cat <<"EOF" ---------------------------------------- - ____ _ -| _ \| | __ _ _ __ ___ -| |_) | |/ _` | '_ \ / _ \ -| __/| | (_| | | | | __/ -|_| |_|\__,_|_| |_|\___| - ---------------------------------------- -Project management tool from the future ---------------------------------------- - -EOF -} -function update_env_file() { - 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 - sudo touch "$config_file" - fi - - # Check if the key already exists in the config file - if sudo grep "^$key=" "$config_file"; then - sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null - sudo mv "$config_file.tmp" "$config_file" &> /dev/null - else - # sudo echo "$key=$value" >> "$config_file" - echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null - 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 - sudo touch "$config_file" - fi - - # Check if the key already exists in the config file - if sudo grep -q "^$key=" "$config_file"; then - value=$(sudo 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_file $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_file $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 "$PACKAGE_MANAGER" update -y - # sudo "$PACKAGE_MANAGER" upgrade -y - - local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq") - - for tool in "${required_tools[@]}"; do - if ! command -v $tool &> /dev/null; then - sudo "$PACKAGE_MANAGER" install -y $tool - 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 - # curl -o- https://get.docker.com | bash - - - if [ "$PACKAGE_MANAGER" == "yum" ]; then - sudo $PACKAGE_MANAGER install -y yum-utils - sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null - elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then - # Add Docker's official GPG key: - sudo $PACKAGE_MANAGER update - sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null - sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null - sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null - sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null - - # Add the repository to Apt sources: - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - sudo tee /etc/apt/sources.list.d/docker.list > /dev/null - - sudo $PACKAGE_MANAGER update - fi - - sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y - - 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 - sudo curl -H 'Cache-Control: no-cache, no-store' \ - -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ - https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) - - sudo curl -H 'Cache-Control: no-cache, no-store' \ - -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ - https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) - - # if .env does not exists rename variables-upgrade.env to .env - if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then - sudo 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/$CODE_REPO.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 $DEPLOY_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 - - CURR_DIR=$(pwd) - - if [ "$DEPLOY_BRANCH" == "master" ]; then - update_env "APP_RELEASE" "latest" - export APP_RELEASE=latest - else - update_env "APP_RELEASE" "$DEPLOY_BRANCH" - export APP_RELEASE=$DEPLOY_BRANCH - fi - - if [ $USE_GLOBAL_IMAGES == 1 ]; then - # show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2 - export DOCKERHUB_USER=makeplane - update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" - update_env "PULL_POLICY" "always" - 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" "$DOCKERHUB_USER" - 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 - sudo 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_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=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) - OS_NAME=$(echo "$OS_NAME" | tr -d '"') - print_header - if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || - [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; 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 - - update_env "NGINX_PORT" "80" - update_env "DOMAIN_NAME" "$MY_IP" - update_env "WEB_URL" "http://$MY_IP" - update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP" - - update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')" - - show_message "Plane Installed Successfully ✅" - show_message "" - else - show_message "Download Failed ❌" - exit 1 - fi - else - show_message "Initialization Failed ❌" - exit 1 - fi - - else - OS_SUPPORTED=false - PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" - show_message "" - exit 1 - fi - else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" - show_message "" - exit 1 - fi -} -function upgrade() { - print_header - if [ "$(uname)" == "Linux" ]; then - OS="linux" - OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) - OS_NAME=$(echo "$OS_NAME" | tr -d '"') - if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || - [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then - - OS_SUPPORTED=true - show_message "******** Upgrading Plane ********" - show_message "" - - prepare_environment - - if [ $? -eq 0 ]; then - stop_server - download_plane - if [ $? -eq 0 ]; then - check_for_docker_images - upgrade_configuration - update_config "UPGRADE_DATE" "$(date)" - - start_server - - show_message "" - show_message "Plane Upgraded Successfully ✅" - show_message "" - printUsageInstructions - else - show_message "Download Failed ❌" - exit 1 - fi - else - show_message "Initialization Failed ❌" - exit 1 - fi - else - PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" - show_message "" - exit 1 - fi - else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" - show_message "" - exit 1 - fi -} -function uninstall() { - print_header - if [ "$(uname)" == "Linux" ]; then - OS="linux" - OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - OS_NAME=$(echo "$OS_NAME" | tr -d '"') - if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || - [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then - - OS_SUPPORTED=true - show_message "******** Uninstalling Plane ********" - show_message "" - - stop_server - - 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" --defaultno --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 docker images -q | xargs -r sudo docker rmi -f &> /dev/null - sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null - sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null - show_message "- Docker Uninstalled ✅" "replace_last_line" >&2 - fi - fi - - sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null - sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null - sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null - sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null - - # 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 Varient Detected : $OS_NAME ❌❌" - 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 ($APP_RELEASE) ✋" - sudo 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 ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do - sleep 1 - done - # wait for migrator container to exit with status 0 before starting the application - migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator") - - # if migrator container is running, wait for it to exit - if [ -n "$migrator_container_id" ]; then - while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do - show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2 - sleep 1 - done - fi - - # if migrator exit status is not 0, show error message and exit - if [ -n "$migrator_container_id" ]; then - migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) - if [ $migrator_exit_code -ne 0 ]; then - # show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2 - show_message "Plane Server failed to start ❌" "replace_last_line" >&2 - stop_server - exit 1 - fi - fi - - api_container_id=$(sudo docker container ls -q -f "name=plane-api") - while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete"; - do - show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2 - sleep 1 - done - show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 - show_message "---------------------------------------------------------------" >&2 - show_message "Access the Plane application at http://$MY_IP" >&2 - show_message "---------------------------------------------------------------" >&2 - - else - show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 - fi -} -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 ($APP_RELEASE) ✋" - sudo docker compose -f $docker_compose_file --env-file=$env_file down - show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2 - else - show_message "Plane Server not installed [Skipping] ✅" "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 ($APP_RELEASE) ✋" - sudo docker compose -f $docker_compose_file --env-file=$env_file restart - show_message "Plane Server Restarted ($APP_RELEASE) ✅" "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 - sudo curl -H 'Cache-Control: no-cache, no-store' \ - -s -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) - - sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null - show_message "Plane Installer Updated ✅" "replace_last_line" >&2 -} - -export DEPLOY_BRANCH=${BRANCH:-master} -export APP_RELEASE=$DEPLOY_BRANCH -export DOCKERHUB_USER=makeplane -export PULL_POLICY=always - -if [ "$DEPLOY_BRANCH" == "master" ]; then - export APP_RELEASE=latest -fi - -PLANE_INSTALL_DIR=/opt/plane -DATA_DIR=$PLANE_INSTALL_DIR/data -LOG_DIR=$PLANE_INSTALL_DIR/logs -CODE_REPO=${GIT_REPO:-makeplane/plane} -OS_SUPPORTED=false -CPU_ARCH=$(uname -m) -PROGRESS_MSG="" -USE_GLOBAL_IMAGES=0 -PACKAGE_MANAGER="" -MY_IP=$(curl -s ifconfig.me) - -if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then - USE_GLOBAL_IMAGES=1 -fi - -sudo mkdir -p $PLANE_INSTALL_DIR/{data,log} - -if command -v apt-get &> /dev/null; then - PACKAGE_MANAGER="apt-get" -elif command -v yum &> /dev/null; then - PACKAGE_MANAGER="yum" -elif command -v apk &> /dev/null; then - PACKAGE_MANAGER="apk" -fi - -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 - start_server - show_message "" >&2 - show_message "To view help, use plane-app --help " >&2 -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/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 3af4b8449..e62390987 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -1,15 +1,13 @@ -version: "3.8" - x-app-env: &app-env environment: - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - SENTRY_DSN=${SENTRY_DSN:-""} + - SENTRY_DSN=${SENTRY_DSN} - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS} # Gunicorn Workers - - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} + - GUNICORN_WORKERS=${GUNICORN_WORKERS:-1} #DB SETTINGS - PGHOST=${PGHOST:-plane-db} - PGDATABASE=${PGDATABASE:-plane} @@ -17,11 +15,11 @@ x-app-env: &app-env - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} - POSTGRES_DB=${POSTGRES_DB:-plane} - PGDATA=${PGDATA:-/var/lib/postgresql/data} - - DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}} + - DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} # REDIS SETTINGS - REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_PORT=${REDIS_PORT:-6379} - - REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/} + - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} # Application secret - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS @@ -39,7 +37,7 @@ x-app-env: &app-env services: web: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: /usr/local/bin/start.sh web/server.js web @@ -51,7 +49,7 @@ services: space: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: /usr/local/bin/start.sh space/server.js space @@ -64,22 +62,26 @@ services: api: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/takeoff deploy: replicas: ${API_REPLICAS:-1} + volumes: + - logs_api:/code/plane/logs depends_on: - plane-db - plane-redis worker: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/worker + volumes: + - logs_worker:/code/plane/logs depends_on: - api - plane-db @@ -87,10 +89,12 @@ services: beat-worker: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped command: ./bin/beat + volumes: + - logs_beat-worker:/code/plane/logs depends_on: - api - plane-db @@ -98,19 +102,21 @@ services: migrator: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: no command: > sh -c "python manage.py wait_for_db && python manage.py migrate" + volumes: + - logs_migrator:/code/plane/logs depends_on: - plane-db - plane-redis plane-db: <<: *app-env - image: postgres:15.2-alpine + image: postgres:15.5-alpine pull_policy: if_not_present restart: unless-stopped command: postgres -c 'max_connections=1000' @@ -118,7 +124,7 @@ services: - pgdata:/var/lib/postgresql/data plane-redis: <<: *app-env - image: redis:6.2.7-alpine + image: redis:7.2.4-alpine pull_policy: if_not_present restart: unless-stopped volumes: @@ -136,7 +142,7 @@ services: # Comment this if you already have a reverse proxy running proxy: <<: *app-env - image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest} + image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} ports: - ${NGINX_PORT}:80 @@ -149,3 +155,7 @@ volumes: pgdata: redisdata: uploads: + logs_api: + logs_worker: + logs_beat-worker: + logs_migrator: diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 16b6ea7c3..fdedf370e 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -17,16 +17,16 @@ function print_header() { clear cat <<"EOF" ---------------------------------------- - ____ _ -| _ \| | __ _ _ __ ___ -| |_) | |/ _` | '_ \ / _ \ -| __/| | (_| | | | | __/ -|_| |_|\__,_|_| |_|\___| - ---------------------------------------- +-------------------------------------------- + ____ _ ///////// +| _ \| | __ _ _ __ ___ ///////// +| |_) | |/ _` | '_ \ / _ \ ///// ///// +| __/| | (_| | | | | __/ ///// ///// +|_| |_|\__,_|_| |_|\___| //// + //// +-------------------------------------------- Project management tool from the future ---------------------------------------- +-------------------------------------------- EOF } @@ -66,7 +66,7 @@ function buildLocalImage() { cd $PLANE_TEMP_CODE_DIR if [ "$BRANCH" == "master" ]; then - export APP_RELEASE=latest + export APP_RELEASE=stable fi docker compose -f build.yml build --no-cache >&2 @@ -99,17 +99,17 @@ function download() { 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 [ -f "$PLANE_INSTALL_DIR/.env" ]; + if [ -f "$DOCKER_ENV_PATH" ]; then - cp $PLANE_INSTALL_DIR/.env $PLANE_INSTALL_DIR/archive/$TS.env + cp $DOCKER_ENV_PATH $PLANE_INSTALL_DIR/archive/$TS.env else - mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env + mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH fi if [ "$BRANCH" != "master" ]; then cp $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/temp.yaml - sed -e 's@${APP_RELEASE:-latest}@'"$BRANCH"'@g' \ + sed -e 's@${APP_RELEASE:-stable}@'"$BRANCH"'@g' \ $PLANE_INSTALL_DIR/temp.yaml > $PLANE_INSTALL_DIR/docker-compose.yaml rm $PLANE_INSTALL_DIR/temp.yaml @@ -131,9 +131,9 @@ function download() { fi echo "" - echo "Latest version is now available for you to use" + echo "Most recent Stable version is now available for you to use" echo "" - 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 "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in 'plane.env 'file." echo "" } @@ -144,7 +144,7 @@ function startServices() { if [ -n "$migrator_container_id" ]; then local idx=0 while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do - local message=">>> Waiting for Data Migration to finish" + local message=">> Waiting for Data Migration to finish" local dots=$(printf '%*s' $idx | tr ' ' '.') echo -ne "\r$message$dots" ((idx++)) @@ -152,13 +152,18 @@ function startServices() { done fi printf "\r\033[K" + echo "" + echo " Data Migration completed successfully ✅" # if migrator exit status is not 0, show error message and exit if [ -n "$migrator_container_id" ]; then local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) if [ $migrator_exit_code -ne 0 ]; then echo "Plane Server failed to start ❌" - stopServices + # stopServices + echo + echo "Please check the logs for the 'migrator' service and resolve the issue(s)." + echo "Stop the services by running the command: ./setup.sh stop" exit 1 fi fi @@ -167,26 +172,35 @@ function startServices() { local idx2=0 while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q "."; do - local message=">>> Waiting for API Service to Start" + local message=">> Waiting for API Service to Start" local dots=$(printf '%*s' $idx2 | tr ' ' '.') echo -ne "\r$message$dots" ((idx2++)) sleep 1 done printf "\r\033[K" + echo " API Service started successfully ✅" + source "${DOCKER_ENV_PATH}" + echo " Plane Server started successfully ✅" + echo "" + echo " You can access the application at $WEB_URL" + echo "" + } function stopServices() { docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down } function restartServices() { - docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart + # docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart + stopServices + startServices } function upgrade() { echo "***** STOPPING SERVICES ****" stopServices echo - echo "***** DOWNLOADING LATEST VERSION ****" + echo "***** DOWNLOADING STABLE VERSION ****" download echo "***** PLEASE VALIDATE AND START SERVICES ****" @@ -303,15 +317,15 @@ function askForAction() { elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ] then startServices - askForAction + # askForAction elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ] then stopServices - askForAction + # askForAction elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ] then restartServices - askForAction + # askForAction elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ] then upgrade @@ -343,7 +357,7 @@ fi if [ "$BRANCH" == "master" ]; then - export APP_RELEASE=latest + export APP_RELEASE=stable fi # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME @@ -354,7 +368,21 @@ fi mkdir -p $PLANE_INSTALL_DIR/archive DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml -DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env +DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env + +# BACKWARD COMPATIBILITY +OLD_DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env +if [ -f "$OLD_DOCKER_ENV_PATH" ]; +then + mv "$OLD_DOCKER_ENV_PATH" "$DOCKER_ENV_PATH" + OS_NAME=$(uname) + if [ "$OS_NAME" == "Darwin" ]; + then + sed -i '' -e 's@APP_RELEASE=latest@APP_RELEASE=stable@' "$DOCKER_ENV_PATH" + else + sed -i -e 's@APP_RELEASE=latest@APP_RELEASE=stable@' "$DOCKER_ENV_PATH" + fi +fi print_header askForAction $@ diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 9a755d012..e37350cf4 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -1,4 +1,4 @@ -APP_RELEASE=latest +APP_RELEASE=stable WEB_REPLICAS=1 SPACE_REPLICAS=1 @@ -18,12 +18,12 @@ POSTGRES_USER=plane POSTGRES_PASSWORD=plane POSTGRES_DB=plane PGDATA=/var/lib/postgresql/data -DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE} +DATABASE_URL= # REDIS SETTINGS REDIS_HOST=plane-redis REDIS_PORT=6379 -REDIS_URL=redis://${REDIS_HOST}:6379/ +REDIS_URL= # Secret Key SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 @@ -41,4 +41,4 @@ BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 # Gunicorn Workers -GUNICORN_WORKERS=2 +GUNICORN_WORKERS=1 diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 5f49e4897..a68a045dd 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -1,5 +1,3 @@ -version: "3.8" - networks: dev_env: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index e39f0d8d2..6efe0e0a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: web: container_name: web diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 4775dcbfa..780093b3b 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -30,7 +30,7 @@ http { } location /${BUCKET_NAME}/ { - proxy_pass http://plane-minio:9000/uploads/; + proxy_pass http://plane-minio:9000/${BUCKET_NAME}/; } } } diff --git a/package.json b/package.json index 9239a9b41..534bda24f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.16.0", + "version": "0.18.0", "license": "AGPL-3.0", "private": true, "workspaces": [ @@ -28,7 +28,7 @@ "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", - "turbo": "^1.11.3" + "turbo": "^1.13.2" }, "resolutions": { "@types/react": "18.2.42" diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 571fb8588..760f1d372 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.16.0", + "version": "0.18.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -28,13 +28,13 @@ "react-dom": "18.2.0" }, "dependencies": { + "@plane/ui": "*", "@tiptap/core": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13", - "@tiptap/extension-code-block-lowlight": "^2.1.13", - "@tiptap/extension-color": "^2.1.13", "@tiptap/extension-image": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", + "@tiptap/extension-placeholder": "^2.3.0", "@tiptap/extension-task-item": "^2.1.13", "@tiptap/extension-task-list": "^2.1.13", "@tiptap/extension-text-style": "^2.1.13", @@ -50,6 +50,7 @@ "linkifyjs": "^4.1.3", "lowlight": "^3.0.0", "lucide-react": "^0.294.0", + "prosemirror-codemark": "^0.4.2", "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", diff --git a/packages/editor/core/src/helpers/insert-content-at-cursor-position.ts b/packages/editor/core/src/helpers/insert-content-at-cursor-position.ts index 062acafcb..f17858d3b 100644 --- a/packages/editor/core/src/helpers/insert-content-at-cursor-position.ts +++ b/packages/editor/core/src/helpers/insert-content-at-cursor-position.ts @@ -7,11 +7,22 @@ export const insertContentAtSavedSelection = ( content: string, savedSelection: Selection ) => { - if (editorRef.current && savedSelection) { - editorRef.current - .chain() - .focus() - .insertContentAt(savedSelection?.anchor, content) - .run(); + if (!editorRef.current || editorRef.current.isDestroyed) { + console.error("Editor reference is not available or has been destroyed."); + return; + } + + if (!savedSelection) { + console.error("Saved selection is invalid."); + return; + } + + const docSize = editorRef.current.state.doc.content.size; + const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize)); + + try { + editorRef.current.chain().focus().insertContentAt(safePosition, content).run(); + } catch (error) { + console.error("An error occurred while inserting content at saved selection:", error); } }; diff --git a/packages/editor/core/src/helpers/scroll-to-node.ts b/packages/editor/core/src/helpers/scroll-to-node.ts new file mode 100644 index 000000000..65d32a7d2 --- /dev/null +++ b/packages/editor/core/src/helpers/scroll-to-node.ts @@ -0,0 +1,40 @@ +import { Editor } from "@tiptap/react"; + +export interface IMarking { + type: "heading"; + level: number; + text: string; + sequence: number; +} + +function findNthH1(editor: Editor, n: number, level: number): number { + let count = 0; + let pos = 0; + editor.state.doc.descendants((node, position) => { + if (node.type.name === "heading" && node.attrs.level === level) { + count++; + if (count === n) { + pos = position; + return false; + } + } + }); + return pos; +} + +function scrollToNode(editor: Editor, pos: number): void { + const headingNode = editor.state.doc.nodeAt(pos); + if (headingNode) { + const headingDOM = editor.view.nodeDOM(pos); + if (headingDOM instanceof HTMLElement) { + headingDOM.scrollIntoView({ behavior: "smooth" }); + } + } +} + +export function scrollSummary(editor: Editor, marking: IMarking) { + if (editor) { + const pos = findNthH1(editor, marking.sequence, marking.level); + scrollToNode(editor, pos); + } +} diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 7e6aa5912..647b79929 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -1,111 +1,215 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; -import { useImperativeHandle, useRef, MutableRefObject, useState } from "react"; +import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; import { CoreEditorProps } from "src/ui/props"; import { CoreEditorExtensions } from "src/ui/extensions"; import { EditorProps } from "@tiptap/pm/view"; import { getTrimmedHTML } from "src/lib/utils"; import { DeleteImage } from "src/types/delete-image"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; import { UploadImage } from "src/types/upload-image"; import { Selection } from "@tiptap/pm/state"; import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position"; +import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items"; +import { EditorRefApi } from "src/types/editor-ref-api"; +import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; interface CustomEditorProps { + id?: string; uploadFile: UploadImage; restoreFile: RestoreImage; - rerenderOnPropsChange?: { - id: string; - description_html: string; - }; deleteFile: DeleteImage; - cancelUploadImage?: () => any; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - setShouldShowAlert?: (showAlert: boolean) => void; - value: string; - debouncedUpdatesEnabled?: boolean; - onStart?: (json: any, html: string) => void; - onChange?: (json: any, html: string) => void; + cancelUploadImage?: () => void; + initialValue: string; + editorClassName: string; + // undefined when prop is not passed, null if intentionally passed to stop + // swr syncing + value: string | null | undefined; + onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; - forwardedRef?: any; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + forwardedRef?: MutableRefObject; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + handleEditorReady?: (value: boolean) => void; + placeholder?: string | ((isFocused: boolean) => string); + tabIndex?: number; } export const useEditor = ({ uploadFile, + id = "", deleteFile, cancelUploadImage, editorProps = {}, + initialValue, + editorClassName, value, - rerenderOnPropsChange, extensions = [], - onStart, onChange, - setIsSubmitting, forwardedRef, + tabIndex, restoreFile, - setShouldShowAlert, - mentionHighlights, - mentionSuggestions, + handleEditorReady, + mentionHandler, + placeholder, }: CustomEditorProps) => { - const editor = useCustomEditor( - { - editorProps: { - ...CoreEditorProps(uploadFile, setIsSubmitting), - ...editorProps, - }, - extensions: [ - ...CoreEditorExtensions( - { - mentionSuggestions: mentionSuggestions ?? [], - mentionHighlights: mentionHighlights ?? [], - }, + const editor = useCustomEditor({ + editorProps: { + ...CoreEditorProps(editorClassName), + ...editorProps, + }, + extensions: [ + ...CoreEditorExtensions({ + mentionConfig: { + mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), + mentionHighlights: mentionHandler.highlights ?? [], + }, + fileConfig: { deleteFile, restoreFile, - cancelUploadImage - ), - ...extensions, - ], - content: typeof value === "string" && value.trim() !== "" ? value : "

", - onCreate: async ({ editor }) => { - onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); - }, - onTransaction: async ({ editor }) => { - setSavedSelection(editor.state.selection); - }, - onUpdate: async ({ editor }) => { - setIsSubmitting?.("submitting"); - setShouldShowAlert?.(true); - onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); - }, + cancelUploadImage, + uploadFile, + }, + placeholder, + tabIndex, + }), + ...extensions, + ], + content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + onCreate: async () => { + handleEditorReady?.(true); }, - [rerenderOnPropsChange] - ); + onTransaction: async ({ editor }) => { + setSavedSelection(editor.state.selection); + }, + onUpdate: async ({ editor }) => { + onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML())); + }, + onDestroy: async () => { + handleEditorReady?.(false); + }, + }); const editorRef: MutableRefObject = useRef(null); - editorRef.current = editor; const [savedSelection, setSavedSelection] = useState(null); - useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); - }, - setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); - }, - setEditorValueAtCursorPosition: (content: string) => { - if (savedSelection) { - insertContentAtSavedSelection(editorRef, content, savedSelection); + // Inside your component or hook + const savedSelectionRef = useRef(savedSelection); + + // Update the ref whenever savedSelection changes + useEffect(() => { + savedSelectionRef.current = savedSelection; + }, [savedSelection]); + + // Effect for syncing SWR data + useEffect(() => { + // value is null when intentionally passed where syncing is not yet + // supported and value is undefined when the data from swr is not populated + if (value === null || value === undefined) return; + if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) { + try { + editor.commands.setContent(value); + const currentSavedSelection = savedSelectionRef.current; + if (currentSavedSelection) { + const docLength = editor.state.doc.content.size; + const relativePosition = Math.min(currentSavedSelection.from, docLength - 1); + editor.commands.setTextSelection(relativePosition); + } + } catch (error) { + console.error("Error syncing editor content with external value:", error); } - }, - })); + } + }, [editor, value, id]); + + useImperativeHandle( + forwardedRef, + () => ({ + clearEditor: () => { + editorRef.current?.commands.clearContent(); + }, + setEditorValue: (content: string) => { + editorRef.current?.commands.setContent(content); + }, + setEditorValueAtCursorPosition: (content: string) => { + if (savedSelection) { + insertContentAtSavedSelection(editorRef, content, savedSelection); + } + }, + executeMenuItemCommand: (itemName: EditorMenuItemNames) => { + const editorItems = getEditorMenuItems(editorRef.current, uploadFile); + + const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); + + const item = getEditorMenuItem(itemName); + if (item) { + if (item.name === "image") { + item.command(savedSelection); + } else { + item.command(); + } + } else { + console.warn(`No command found for item: ${itemName}`); + } + }, + isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { + const editorItems = getEditorMenuItems(editorRef.current, uploadFile); + + const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName); + const item = getEditorMenuItem(itemName); + return item ? item.isActive() : false; + }, + onStateChange: (callback: () => void) => { + // Subscribe to editor state changes + editorRef.current?.on("transaction", () => { + callback(); + }); + // Return a function to unsubscribe to the continuous transactions of + // the editor on unmounting the component that has subscribed to this + // method + return () => { + editorRef.current?.off("transaction"); + }; + }, + getMarkDown: (): string => { + const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); + return markdownOutput; + }, + scrollSummary: (marking: IMarking): void => { + if (!editorRef.current) return; + scrollSummary(editorRef.current, marking); + }, + setFocusAtPosition: (position: number) => { + if (!editorRef.current || editorRef.current.isDestroyed) { + console.error("Editor reference is not available or has been destroyed."); + return; + } + try { + const docSize = editorRef.current.state.doc.content.size; + const safePosition = Math.max(0, Math.min(position, docSize)); + editorRef.current + .chain() + .insertContentAt(safePosition, [{ type: "paragraph" }]) + .focus() + .run(); + } catch (error) { + console.error("An error occurred while setting focus at position:", error); + } + }, + }), + [editorRef, savedSelection, uploadFile] + ); if (!editor) { return null; } + // the editorRef is used to access the editor instance from outside the hook + // and should only be used after editor is initialized + editorRef.current = editor; + return editor; }; diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/core/src/hooks/use-read-only-editor.tsx index ecd49255c..9607586d8 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -1,53 +1,61 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; -import { useImperativeHandle, useRef, MutableRefObject } from "react"; +import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions"; import { CoreReadOnlyEditorProps } from "src/ui/read-only/props"; import { EditorProps } from "@tiptap/pm/view"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { EditorReadOnlyRefApi } from "src/types/editor-ref-api"; +import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; +import { IMentionHighlight } from "src/types/mention-suggestion"; interface CustomReadOnlyEditorProps { - value: string; - forwardedRef?: any; + initialValue: string; + editorClassName: string; + forwardedRef?: MutableRefObject; extensions?: any; editorProps?: EditorProps; - rerenderOnPropsChange?: { - id: string; - description_html: string; + handleEditorReady?: (value: boolean) => void; + mentionHandler: { + highlights: () => Promise; }; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; } export const useReadOnlyEditor = ({ - value, + initialValue, + editorClassName, forwardedRef, extensions = [], editorProps = {}, - rerenderOnPropsChange, - mentionHighlights, - mentionSuggestions, + handleEditorReady, + mentionHandler, }: CustomReadOnlyEditorProps) => { - const editor = useCustomEditor( - { - editable: false, - content: typeof value === "string" && value.trim() !== "" ? value : "

", - editorProps: { - ...CoreReadOnlyEditorProps, - ...editorProps, - }, - extensions: [ - ...CoreReadOnlyEditorExtensions({ - mentionSuggestions: mentionSuggestions ?? [], - mentionHighlights: mentionHighlights ?? [], - }), - ...extensions, - ], + const editor = useCustomEditor({ + editable: false, + content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + editorProps: { + ...CoreReadOnlyEditorProps(editorClassName), + ...editorProps, }, - [rerenderOnPropsChange] - ); + onCreate: async () => { + handleEditorReady?.(true); + }, + extensions: [ + ...CoreReadOnlyEditorExtensions({ + mentionHighlights: mentionHandler.highlights, + }), + ...extensions, + ], + onDestroy: () => { + handleEditorReady?.(false); + }, + }); + + // for syncing swr data on tab refocus etc + useEffect(() => { + if (initialValue === null || initialValue === undefined) return; + if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue); + }, [editor, initialValue]); const editorRef: MutableRefObject = useRef(null); - editorRef.current = editor; useImperativeHandle(forwardedRef, () => ({ clearEditor: () => { @@ -56,11 +64,20 @@ export const useReadOnlyEditor = ({ setEditorValue: (content: string) => { editorRef.current?.commands.setContent(content); }, + getMarkDown: (): string => { + const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); + return markdownOutput; + }, + scrollSummary: (marking: IMarking): void => { + if (!editorRef.current) return; + scrollSummary(editorRef.current, marking); + }, })); if (!editor) { return null; } + editorRef.current = editor; return editor; }; diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index c7e39d240..336daed43 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -26,6 +26,7 @@ export * from "src/lib/editor-commands"; // types export type { DeleteImage } from "src/types/delete-image"; export type { UploadImage } from "src/types/upload-image"; +export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api"; export type { RestoreImage } from "src/types/restore-image"; export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 7c3e7f11e..f0c6c85e0 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -1,21 +1,22 @@ import { Editor, Range } from "@tiptap/core"; import { startImageUpload } from "src/ui/plugins/upload-image"; import { findTableAncestor } from "src/lib/utils"; +import { Selection } from "@tiptap/pm/state"; import { UploadImage } from "src/types/upload-image"; export const toggleHeadingOne = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run(); - else editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(); + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + else editor.chain().focus().toggleHeading({ level: 1 }).run(); }; export const toggleHeadingTwo = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 2 }).run(); - else editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(); + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + else editor.chain().focus().toggleHeading({ level: 2 }).run(); }; export const toggleHeadingThree = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 3 }).run(); - else editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(); + if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + else editor.chain().focus().toggleHeading({ level: 3 }).run(); }; export const toggleBold = (editor: Editor, range?: Range) => { @@ -33,48 +34,98 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleUnderline().run(); }; -export const toggleCodeBlock = (editor: Editor, range?: Range) => { - // Check if code block is active then toggle code block - if (editor.isActive("codeBlock")) { - if (range) { - editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run(); - return; +const replaceCodeBlockWithContent = (editor: Editor) => { + try { + const { schema } = editor.state; + const { paragraph } = schema.nodes; + let replaced = false; + + const replaceCodeBlock = (from: number, to: number, textContent: string) => { + const docSize = editor.state.doc.content.size; + + if (from < 0 || to > docSize || from > to) { + console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize); + return; + } + + // split the textContent by new lines to handle each line as a separate paragraph + const lines = textContent.split(/\r?\n/); + + const tr = editor.state.tr; + + // Calculate the position for inserting the first paragraph + let insertPos = from; + + // Remove the code block first + tr.delete(from, to); + + // For each line, create a paragraph node and insert it + lines.forEach((line) => { + const paragraphNode = paragraph.create({}, schema.text(line)); + tr.insert(insertPos, paragraphNode); + // Update insertPos for the next insertion + insertPos += paragraphNode.nodeSize; + }); + + // Dispatch the transaction + editor.view.dispatch(tr); + replaced = true; + }; + + editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => { + if (node.type === schema.nodes.codeBlock) { + const startPos = pos; + const endPos = pos + node.nodeSize; + const textContent = node.textContent; + replaceCodeBlock(startPos, endPos, textContent); + return false; + } + }); + + if (!replaced) { + console.log("No code block to replace."); } - editor.chain().focus().clearNodes().toggleCodeBlock().run(); - return; + } catch (error) { + console.error("An error occurred while replacing code block content:", error); } +}; - // Check if user hasn't selected any text - const isSelectionEmpty = editor.state.selection.empty; - - if (isSelectionEmpty) { - if (range) { - editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run(); +export const toggleCodeBlock = (editor: Editor, range?: Range) => { + try { + if (editor.isActive("codeBlock")) { + replaceCodeBlockWithContent(editor); return; } - editor.chain().focus().clearNodes().toggleCodeBlock().run(); - } else { - if (range) { - editor.chain().focus().deleteRange(range).clearNodes().toggleCode().run(); - return; + + const { from, to } = range || editor.state.selection; + const text = editor.state.doc.textBetween(from, to, "\n"); + const isMultiline = text.includes("\n"); + + if (editor.state.selection.empty) { + editor.chain().focus().toggleCodeBlock().run(); + } else if (isMultiline) { + editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run(); + } else { + editor.chain().focus().toggleCode().run(); } - editor.chain().focus().clearNodes().toggleCode().run(); + } catch (error) { + console.error("An error occurred while toggling code block:", error); } }; export const toggleOrderedList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleOrderedList().run(); - else editor.chain().focus().clearNodes().toggleOrderedList().run(); + if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + else editor.chain().focus().toggleOrderedList().run(); }; export const toggleBulletList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBulletList().run(); - else editor.chain().focus().clearNodes().toggleBulletList().run(); + if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run(); + else editor.chain().focus().toggleBulletList().run(); }; export const toggleTaskList = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleTaskList().run(); - else editor.chain().focus().clearNodes().toggleTaskList().run(); + if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run(); + else editor.chain().focus().toggleTaskList().run(); }; export const toggleStrike = (editor: Editor, range?: Range) => { @@ -83,17 +134,19 @@ export const toggleStrike = (editor: Editor, range?: Range) => { }; export const toggleBlockquote = (editor: Editor, range?: Range) => { - if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBlockquote().run(); - else editor.chain().focus().clearNodes().toggleBlockquote().run(); + if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run(); + else editor.chain().focus().toggleBlockquote().run(); }; export const insertTableCommand = (editor: Editor, range?: Range) => { if (typeof window !== "undefined") { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return; + const selection = window.getSelection(); + if (selection) { + if (selection.rangeCount !== 0) { + const range = selection.getRangeAt(0); + if (findTableAncestor(range.startContainer)) { + return; + } } } } @@ -112,7 +165,7 @@ export const setLinkEditor = (editor: Editor, url: string) => { export const insertImageCommand = ( editor: Editor, uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, + savedSelection?: Selection | null, range?: Range ) => { if (range) editor.chain().focus().deleteRange(range).run(); @@ -122,8 +175,8 @@ export const insertImageCommand = ( input.onchange = async () => { if (input.files?.length) { const file = input.files[0]; - const pos = editor.view.state.selection.from; - startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting); + const pos = savedSelection?.anchor ?? editor.view.state.selection.from; + startImageUpload(editor, file, editor.view, pos, uploadFile); } }; input.click(); diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index c943d4c60..84ad7046e 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -4,15 +4,17 @@ import { twMerge } from "tailwind-merge"; interface EditorClassNames { noBorder?: boolean; borderOnFocus?: boolean; - customClassName?: string; + containerClassName?: string; } -export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => +export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassName }: EditorClassNames) => cn( - "relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md", - noBorder ? "" : "border border-custom-border-200", - borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0", - customClassName + "w-full max-w-full sm:rounded-lg focus:outline-none focus:border-0", + { + "border border-custom-border-200": !noBorder, + "focus:border border-custom-border-300": borderOnFocus, + }, + containerClassName ); export function cn(...inputs: ClassValue[]) { diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index dbbea671e..5868fce91 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -7,10 +7,17 @@ } /* block quotes */ +.ProseMirror blockquote { + font-style: normal; + font-weight: 400; + border-left: 3px solid rgb(var(--color-border-300)); +} + .ProseMirror blockquote p::before, .ProseMirror blockquote p::after { display: none; } +/* end block quotes */ .ProseMirror code::before, .ProseMirror code::after { @@ -28,8 +35,8 @@ /* Custom image styles */ .ProseMirror img { transition: filter 0.1s ease-in-out; - margin-top: 0 !important; - margin-bottom: 0 !important; + margin-top: 8px; + margin-bottom: 0; &:hover { cursor: pointer; @@ -37,22 +44,49 @@ } &.ProseMirror-selectednode { - outline: 3px solid #5abbf7; + outline: 3px solid rgba(var(--color-primary-100)); filter: brightness(90%); } } -.ProseMirror-gapcursor:after { +/* Custom gap cursor styles */ +.ProseMirror-gapcursor::after { border-top: 1px solid rgb(var(--color-text-100)) !important; } -/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ +/* to-do list */ +ul[data-type="taskList"] li { + font-size: 1rem; + line-height: 1.5; +} ul[data-type="taskList"] li > label { - margin-right: 0.2rem; + margin: 0.1rem 0.15rem 0 0; user-select: none; } +ul[data-type="taskList"] li > label input[type="checkbox"] { + border: 1px solid rgba(var(--color-border-300)) !important; + outline: none; + border-radius: 2px; + transform: scale(1.05); +} + +ul[data-type="taskList"] li > label input[type="checkbox"]:hover { + background-color: rgba(var(--color-background-80)) !important; +} + +ul[data-type="taskList"] li > label input[type="checkbox"]:checked { + background-color: rgba(var(--color-primary-100)) !important; + border-color: rgba(var(--color-primary-100)) !important; + color: white !important; +} + +ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover { + background-color: rgba(var(--color-primary-300)) !important; + border-color: rgba(var(--color-primary-300)) !important; +} + @media screen and (max-width: 768px) { ul[data-type="taskList"] li > label { margin-right: 0.5rem; @@ -60,6 +94,7 @@ ul[data-type="taskList"] li > label { } ul[data-type="taskList"] li > label input[type="checkbox"] { + position: relative; -webkit-appearance: none; appearance: none; background-color: rgb(var(--color-background-100)); @@ -71,8 +106,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { border: 1.5px solid rgb(var(--color-text-100)); margin-right: 0.2rem; margin-top: 0.15rem; - display: grid; - place-content: center; &:hover { background-color: rgb(var(--color-background-80)); @@ -82,27 +115,32 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { background-color: rgb(var(--color-background-90)); } + /* check sign */ &::before { content: ""; + position: absolute; + top: 50%; + left: 50%; width: 0.5em; height: 0.5em; transform: scale(0); + transform-origin: center; transition: 120ms transform ease-in-out; box-shadow: inset 1em 1em; - transform-origin: center; clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); } &:checked::before { - transform: scale(1); + transform: scale(1) translate(-50%, -50%); } } ul[data-type="taskList"] li[data-checked="true"] > div > p { - color: rgb(var(--color-text-200)); + color: rgb(var(--color-text-400)); text-decoration: line-through; text-decoration-thickness: 2px; } +/* end to-do list */ /* Overwrite tippy-box original max-width */ @@ -133,12 +171,12 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { -moz-appearance: textfield; } -.fadeIn { +.fade-in { opacity: 1; transition: opacity 0.3s ease-in; } -.fadeOut { +.fade-out { opacity: 0; transition: opacity 0.2s ease-out; } @@ -149,7 +187,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { margin-top: 0 !important; margin-bottom: 0 !important; - &:before { + &::before { content: ""; box-sizing: border-box; position: absolute; @@ -175,21 +213,13 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { cursor: col-resize; } -.ProseMirror table * p { - padding: 0px 1px; - margin: 6px 2px; -} - .ProseMirror table * .is-empty::before { opacity: 0; } .ProseMirror pre { - background: rgba(var(--color-background-80)); - border-radius: 0.5rem; - color: rgba(var(--color-text-100)); - font-family: "JetBrainsMono", monospace; - padding: 0.75rem 1rem; + font-family: JetBrainsMono, monospace; + tab-size: 2; } .ProseMirror pre code { @@ -206,7 +236,7 @@ div[data-type="horizontalRule"] { margin-bottom: 0; & > div { - border-bottom: 1px solid rgb(var(--color-text-100)); + border-bottom: 2px solid rgb(var(--color-border-200)); } } @@ -214,3 +244,107 @@ div[data-type="horizontalRule"] { .moveable-control-box { z-index: 10 !important; } + +/* Cursor styles for the inline code blocks */ +@keyframes blink { + 49% { + border-color: unset; + } + + 50% { + border-color: transparent; + } + + 99% { + border-color: transparent; + } +} + +.no-cursor { + caret-color: transparent; +} + +div:focus .fake-cursor, +span:focus .fake-cursor { + margin-right: -1px; + border-left-width: 1.5px; + border-left-style: solid; + animation: blink 1s; + animation-iteration-count: infinite; + position: relative; + z-index: 1; +} + +/* numbered, bulleted and to-do lists spacing */ +.prose ol:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)), +.prose + ul:not([data-type="taskList"]):where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)), +.prose ul[data-type="taskList"]:where(.prose > :first-child) { + margin-top: 0.25rem !important; + margin-bottom: 1px !important; +} + +.prose ol:not(:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))), +.prose + ul:not([data-type="taskList"]):not( + :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) + ), +.prose ul[data-type="taskList"]:not(:where(.prose > :first-child)) { + margin-top: calc(0.25rem + 3px) !important; + margin-bottom: 1px !important; +} + +ol ol, +ol ul:not([data-type="taskList"]), +ul:not([data-type="taskList"]) ul:not([data-type="taskList"]), +ul:not([data-type="taskList"]) ol { + margin-top: 0.45rem !important; +} + +ul[data-type="taskList"] ul[data-type="taskList"] { + margin-top: 0.6rem; +} +/* end numbered, bulleted and to-do lists spacing */ + +/* tailwind typography */ +.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 2rem; + margin-bottom: 4px; + font-size: 1.875rem; + font-weight: 700; + line-height: 1.3; +} + +.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 1.4rem; + margin-bottom: 1px; + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; +} + +.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 1rem; + margin-bottom: 1px; + font-size: 1.25rem; + line-height: 1.3; +} + +.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 0.25rem; + margin-bottom: 1px; + padding: 3px 2px; + font-size: 1rem; + line-height: 1.5; +} + +.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, +.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { + font-size: 1rem; + line-height: 1.5; +} + +.prose :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + margin-top: 0; +} +/* end tailwind typography */ diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/core/src/styles/table.css index 3ba17ee1b..d5adac9b5 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/core/src/styles/table.css @@ -1,23 +1,25 @@ -.tableWrapper { +.table-wrapper { overflow-x: auto; - padding: 2px; width: fit-content; max-width: 100%; } -.tableWrapper table { +.table-wrapper table { border-collapse: collapse; table-layout: fixed; - margin: 0; - margin-bottom: 1rem; - border: 2px solid rgba(var(--color-border-300)); + margin: 0.5rem 0 1rem 0; + border: 1px solid rgba(var(--color-border-200)); width: 100%; } -.tableWrapper table td, -.tableWrapper table th { +.table-wrapper table p { + font-size: 14px; +} + +.table-wrapper table td, +.table-wrapper table th { min-width: 1em; - border: 1px solid rgba(var(--color-border-300)); + border: 1px solid rgba(var(--color-border-200)); padding: 10px 15px; vertical-align: top; box-sizing: border-box; @@ -29,86 +31,34 @@ } } -.tableWrapper table td > *, -.tableWrapper table th > * { - margin: 0 !important; - padding: 0.25rem 0 !important; -} - -.tableWrapper table td.has-focus, -.tableWrapper table th.has-focus { - box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important; -} - -.tableWrapper table th { - font-weight: bold; +.table-wrapper table th { + font-weight: 500; text-align: left; - background-color: #d9e4ff; - color: #171717; + background-color: rgba(var(--color-background-90)); } -.tableWrapper table th * { - font-weight: 600; +.table-wrapper table .selectedCell { + outline: 0.5px solid rgba(var(--color-primary-100)); } -.tableWrapper table .selectedCell:after { - z-index: 2; - position: absolute; - content: ""; - left: 0; - right: 0; - top: 0; - bottom: 0; - background-color: rgba(var(--color-primary-300), 0.1); - pointer-events: none; -} - -.colorPicker { - display: grid; - padding: 8px 8px; - grid-template-columns: repeat(6, 1fr); - gap: 5px; -} - -.colorPickerLabel { - font-size: 0.85rem; - color: #6b7280; - padding: 8px 8px; - padding-bottom: 0px; -} - -.colorPickerItem { - margin: 2px 0px; - width: 24px; - height: 24px; - border-radius: 4px; - border: none; - cursor: pointer; -} - -.divider { - background-color: #e5e7eb; - height: 1px; - margin: 3px 0; -} - -.tableWrapper table .column-resize-handle { +/* table dropdown */ +.table-wrapper table .column-resize-handle { position: absolute; right: -2px; top: 0; - bottom: -2px; - width: 4px; + width: 2px; + height: 100%; z-index: 5; - background-color: #d9e4ff; + background-color: rgba(var(--color-primary-100)); pointer-events: none; } -.tableWrapper .tableControls { +.table-wrapper .table-controls { position: absolute; } -.tableWrapper .tableControls .columnsControl, -.tableWrapper .tableControls .rowsControl { +.table-wrapper .table-controls .columns-control, +.table-wrapper .table-controls .rows-control { transition: opacity ease-in 100ms; position: absolute; z-index: 5; @@ -117,124 +67,50 @@ align-items: center; } -.tableWrapper .tableControls .columnsControl { +.table-wrapper .table-controls .columns-control { height: 20px; transform: translateY(-50%); } -.tableWrapper .tableControls .columnsControl .columnsControlDiv { +.table-wrapper .table-controls .columns-control .columns-control-div { color: white; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E"); width: 30px; height: 15px; } -.tableWrapper .tableControls .rowsControl { +.table-wrapper .table-controls .rows-control { width: 20px; transform: translateX(-50%); } -.tableWrapper .tableControls .rowsControl .rowsControlDiv { +.table-wrapper .table-controls .rows-control .rows-control-div { color: white; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E"); height: 30px; width: 15px; } -.tableWrapper .tableControls .rowsControlDiv { - background-color: #d9e4ff; - border: 1px solid rgba(var(--color-border-200)); - border-radius: 2px; - background-size: 1.25rem; - background-repeat: no-repeat; - background-position: center; - transition: - transform ease-out 100ms, - background-color ease-out 100ms; - outline: none; - box-shadow: #000 0px 2px 4px; - cursor: pointer; -} - -.tableWrapper .tableControls .columnsControlDiv { - background-color: #d9e4ff; - border: 1px solid rgba(var(--color-border-200)); - border-radius: 2px; - background-size: 1.25rem; - background-repeat: no-repeat; - background-position: center; - transition: - transform ease-out 100ms, - background-color ease-out 100ms; - outline: none; - box-shadow: #000 0px 2px 4px; - cursor: pointer; -} -.tableWrapper .tableControls .tableToolbox, -.tableWrapper .tableControls .tableColorPickerToolbox { - border: 1px solid rgba(var(--color-border-300)); - background-color: rgba(var(--color-background-100)); - border-radius: 5px; - box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); - padding: 0.25rem; - display: flex; - flex-direction: column; - width: max-content; - gap: 0.25rem; -} - -.tableWrapper .tableControls .tableToolbox .toolboxItem, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem { - background-color: rgba(var(--color-background-100)); - display: flex; - align-items: center; - gap: 0.5rem; - border: none; - padding: 0.3rem 0.5rem 0.1rem 0.1rem; +.table-wrapper .table-controls .rows-control-div, +.table-wrapper .table-controls .columns-control-div { + background-color: rgba(var(--color-background-80)); + border: 0.5px solid rgba(var(--color-border-200)); border-radius: 4px; + background-size: 1.25rem; + background-repeat: no-repeat; + background-position: center; + transition: + transform ease-out 100ms, + background-color ease-out 100ms; + outline: none; + box-shadow: rgba(var(--color-shadow-2xs)); cursor: pointer; - transition: all 0.2s; } -.tableWrapper .tableControls .tableToolbox .toolboxItem:hover, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover { - background-color: rgba(var(--color-background-80), 0.6); -} - -.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer, -.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer { - padding: 4px 0px; - display: flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; -} - -.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, -.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { - width: 1rem; - height: 1rem; -} - -.tableToolbox { - background-color: rgba(var(--color-background-100)); -} - -.tableWrapper .tableControls .tableToolbox .toolboxItem .label, -.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label { - font-size: 0.85rem; - color: rgba(var(--color-text-300)); -} - -.resize-cursor .tableWrapper .tableControls .rowsControl, -.tableWrapper.controls--disabled .tableControls .rowsControl, -.resize-cursor .tableWrapper .tableControls .columnsControl, -.tableWrapper.controls--disabled .tableControls .columnsControl { +.resize-cursor .table-wrapper .table-controls .rows-control, +.table-wrapper.controls--disabled .table-controls .rows-control, +.resize-cursor .table-wrapper .table-controls .columns-control, +.table-wrapper.controls--disabled .table-controls .columns-control { opacity: 0; pointer-events: none; } diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts new file mode 100644 index 000000000..df5df2c7b --- /dev/null +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -0,0 +1,17 @@ +import { IMarking } from "src/helpers/scroll-to-node"; +import { EditorMenuItemNames } from "src/ui/menus/menu-items"; + +export type EditorReadOnlyRefApi = { + getMarkDown: () => string; + clearEditor: () => void; + setEditorValue: (content: string) => void; + scrollSummary: (marking: IMarking) => void; +}; + +export interface EditorRefApi extends EditorReadOnlyRefApi { + setEditorValueAtCursorPosition: (content: string) => void; + executeMenuItemCommand: (itemName: EditorMenuItemNames) => void; + isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; + onStateChange: (callback: () => void) => () => void; + setFocusAtPosition: (position: number) => void; +} diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts index dcaa3148d..aa2ad4ba2 100644 --- a/packages/editor/core/src/types/mention-suggestion.ts +++ b/packages/editor/core/src/types/mention-suggestion.ts @@ -1,10 +1,18 @@ +import { Editor, Range } from "@tiptap/react"; export type IMentionSuggestion = { id: string; type: string; + entity_name: string; + entity_identifier: string; avatar: string; title: string; subtitle: string; redirect_uri: string; }; +export type CommandProps = { + editor: Editor; + range: Range; +}; + export type IMentionHighlight = string; diff --git a/packages/editor/core/src/ui/components/editor-container.tsx b/packages/editor/core/src/ui/components/editor-container.tsx index 1b2504b58..cfa80da90 100644 --- a/packages/editor/core/src/ui/components/editor-container.tsx +++ b/packages/editor/core/src/ui/components/editor-container.tsx @@ -1,59 +1,68 @@ import { Editor } from "@tiptap/react"; import { FC, ReactNode } from "react"; +import { cn } from "src/lib/utils"; interface EditorContainerProps { editor: Editor | null; - editorClassNames: string; + editorContainerClassName: string; children: ReactNode; hideDragHandle?: () => void; } export const EditorContainer: FC = (props) => { - const { editor, editorClassNames, hideDragHandle, children } = props; + const { editor, editorContainerClassName, hideDragHandle, children } = props; const handleContainerClick = () => { if (!editor) return; if (!editor.isEditable) return; - if (editor.isFocused) return; // If editor is already focused, do nothing + try { + if (editor.isFocused) return; // If editor is already focused, do nothing - const { selection } = editor.state; - const currentNode = selection.$from.node(); + const { selection } = editor.state; + const currentNode = selection.$from.node(); - editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end + editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end - if ( - currentNode.content.size === 0 && // Check if the current node is empty - !( - editor.isActive("orderedList") || - editor.isActive("bulletList") || - editor.isActive("taskItem") || - editor.isActive("table") || - editor.isActive("blockquote") || - editor.isActive("codeBlock") - ) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block - ) { - return; + if ( + currentNode.content.size === 0 && // Check if the current node is empty + !( + editor.isActive("orderedList") || + editor.isActive("bulletList") || + editor.isActive("taskItem") || + editor.isActive("table") || + editor.isActive("blockquote") || + editor.isActive("codeBlock") + ) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block + ) { + return; + } + + // Insert a new paragraph at the end of the document + const endPosition = editor?.state.doc.content.size; + editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); + + // Focus the newly added paragraph for immediate editing + editor + .chain() + .setTextSelection(endPosition + 1) + .run(); + } catch (error) { + console.error("An error occurred while handling container click to insert new empty node at bottom:", error); } - - // Insert a new paragraph at the end of the document - const endPosition = editor?.state.doc.content.size; - editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); - - // Focus the newly added paragraph for immediate editing - editor - .chain() - .setTextSelection(endPosition + 1) - .run(); }; return (
{ - hideDragHandle?.(); - }} - className={`cursor-text ${editorClassNames}`} + onMouseLeave={hideDragHandle} + className={cn( + "cursor-text relative", + { + "active-editor": editor?.isFocused && editor?.isEditable, + }, + editorContainerClassName + )} > {children}
diff --git a/packages/editor/core/src/ui/components/editor-content.tsx b/packages/editor/core/src/ui/components/editor-content.tsx index 7a6ce30f7..a2427265c 100644 --- a/packages/editor/core/src/ui/components/editor-content.tsx +++ b/packages/editor/core/src/ui/components/editor-content.tsx @@ -4,22 +4,15 @@ import { ImageResizer } from "src/ui/extensions/image/image-resize"; interface EditorContentProps { editor: Editor | null; - editorContentCustomClassNames: string | undefined; children?: ReactNode; tabIndex?: number; } export const EditorContentWrapper: FC = (props) => { - const { editor, editorContentCustomClassNames = "", tabIndex, children } = props; + const { editor, tabIndex, children } = props; return ( -
{ - editor?.chain().focus(undefined, { scrollIntoView: false }).run(); - }} - > +
editor?.chain().focus(undefined, { scrollIntoView: false }).run()}> {editor?.isActive("image") && editor?.isEditable && } {children} diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/core/src/ui/extensions/code-inline/index.tsx index 1c5d34109..bc629160a 100644 --- a/packages/editor/core/src/ui/extensions/code-inline/index.tsx +++ b/packages/editor/core/src/ui/extensions/code-inline/index.tsx @@ -32,7 +32,8 @@ export const CustomCodeInlineExtension = Mark.create({ addOptions() { return { HTMLAttributes: { - class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000", + class: + "rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm", spellcheck: "false", }, }; diff --git a/packages/editor/core/src/ui/extensions/code/code-block-lowlight.ts b/packages/editor/core/src/ui/extensions/code/code-block-lowlight.ts new file mode 100644 index 000000000..ae44d83d6 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/code-block-lowlight.ts @@ -0,0 +1,30 @@ +// import CodeBlock, { CodeBlockOptions } from "@tiptap/extension-code-block"; + +import { CodeBlockOptions, CodeBlock } from "./code-block"; +import { LowlightPlugin } from "./lowlight-plugin"; + +export interface CodeBlockLowlightOptions extends CodeBlockOptions { + lowlight: any; + defaultLanguage: string | null | undefined; +} + +export const CodeBlockLowlight = CodeBlock.extend({ + addOptions() { + return { + ...this.parent?.(), + lowlight: {}, + defaultLanguage: null, + }; + }, + + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + LowlightPlugin({ + name: this.name, + lowlight: this.options.lowlight, + defaultLanguage: this.options.defaultLanguage, + }), + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx b/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx new file mode 100644 index 000000000..f7218986b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; +import { common, createLowlight } from "lowlight"; +import ts from "highlight.js/lib/languages/typescript"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { cn } from "src/lib/utils"; + +// we just have ts support for now +const lowlight = createLowlight(common); +lowlight.register("ts", ts); + +interface CodeBlockComponentProps { + node: ProseMirrorNode; +} + +export const CodeBlockComponent: React.FC = ({ node }) => { + const [copied, setCopied] = useState(false); + + const copyToClipboard = async (e: React.MouseEvent) => { + try { + await navigator.clipboard.writeText(node.textContent); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + } catch (error) { + setCopied(false); + } + e.preventDefault(); + e.stopPropagation(); + }; + + return ( + + + +
+        
+      
+
+ ); +}; diff --git a/packages/editor/core/src/ui/extensions/code/code-block.ts b/packages/editor/core/src/ui/extensions/code/code-block.ts new file mode 100644 index 000000000..b2218ee45 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/code-block.ts @@ -0,0 +1,346 @@ +import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export interface CodeBlockOptions { + /** + * Adds a prefix to language classes that are applied to code tags. + * Defaults to `'language-'`. + */ + languageClassPrefix: string; + /** + * Define whether the node should be exited on triple enter. + * Defaults to `true`. + */ + exitOnTripleEnter: boolean; + /** + * Define whether the node should be exited on arrow down if there is no node after it. + * Defaults to `true`. + */ + exitOnArrowDown: boolean; + /** + * Custom HTML attributes that should be added to the rendered HTML tag. + */ + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + codeBlock: { + /** + * Set a code block + */ + setCodeBlock: (attributes?: { language: string }) => ReturnType; + /** + * Toggle a code block + */ + toggleCodeBlock: (attributes?: { language: string }) => ReturnType; + }; + } +} + +export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; +export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; + +export const CodeBlock = Node.create({ + name: "codeBlock", + + addOptions() { + return { + languageClassPrefix: "language-", + exitOnTripleEnter: true, + exitOnArrowDown: true, + HTMLAttributes: {}, + }; + }, + content: "text*", + + marks: "", + + group: "block", + + code: true, + + defining: true, + + addAttributes() { + return { + language: { + default: null, + parseHTML: (element) => { + const { languageClassPrefix } = this.options; + // @ts-expect-error element is a DOM element + const classNames = [...(element.firstElementChild?.classList || [])]; + const languages = classNames + .filter((className) => className.startsWith(languageClassPrefix)) + .map((className) => className.replace(languageClassPrefix, "")); + const language = languages[0]; + + if (!language) { + return null; + } + + return language; + }, + rendered: false, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "pre", + preserveWhitespace: "full", + }, + ]; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + "pre", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + [ + "code", + { + class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null, + }, + 0, + ], + ]; + }, + + addCommands() { + return { + setCodeBlock: + (attributes) => + ({ commands }) => + commands.setNode(this.name, attributes), + toggleCodeBlock: + (attributes) => + ({ commands }) => + commands.toggleNode(this.name, "paragraph", attributes), + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(), + + // remove code block when at start of document or code block is empty + Backspace: () => { + try { + const { empty, $anchor } = this.editor.state.selection; + const isAtStart = $anchor.pos === 1; + + if (!empty || $anchor.parent.type.name !== this.name) { + return false; + } + + if (isAtStart || !$anchor.parent.textContent.length) { + return this.editor.commands.clearNodes(); + } + + return false; + } catch (error) { + console.error("Error handling Backspace in code block:", error); + return false; + } + }, + + // exit node on triple enter + Enter: ({ editor }) => { + try { + if (!this.options.exitOnTripleEnter) { + return false; + } + + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + const endsWithDoubleNewline = $from.parent.textContent.endsWith("\n\n"); + + if (!isAtEnd || !endsWithDoubleNewline) { + return false; + } + + return editor + .chain() + .command(({ tr }) => { + tr.delete($from.pos - 2, $from.pos); + + return true; + }) + .exitCode() + .run(); + } catch (error) { + console.error("Error handling Enter in code block:", error); + return false; + } + }, + + // exit node on arrow down + ArrowDown: ({ editor }) => { + try { + if (!this.options.exitOnArrowDown) { + return false; + } + + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + 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 false; + } + + return editor.commands.exitCode(); + } catch (error) { + console.error("Error handling ArrowDown in code block:", error); + return false; + } + }, + }; + }, + + addInputRules() { + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes: (match) => ({ + language: match[1], + }), + }), + textblockTypeInputRule({ + find: tildeInputRegex, + type: this.type, + getAttributes: (match) => ({ + language: match[1], + }), + }), + ]; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("codeBlockVSCodeHandlerCustom"), + props: { + handlePaste: (view, event) => { + try { + if (!event.clipboardData) { + return false; + } + + if (this.editor.isActive(this.type.name)) { + return false; + } + + if (this.editor.isActive("code")) { + // Check if it's an inline code block + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + + if (!text) { + console.error("Pasted text is empty."); + return false; + } + + const { tr } = view.state; + const { $from, $to } = tr.selection; + + if ($from.pos > $to.pos) { + console.error("Invalid selection range."); + return false; + } + + const docSize = tr.doc.content.size; + if ($from.pos < 0 || $to.pos > docSize) { + console.error("Selection range is out of document bounds."); + return false; + } + + // Extend the current selection to replace it with the pasted text + // wrapped in an inline code mark + const codeMark = view.state.schema.marks.code.create(); + tr.replaceWith($from.pos, $to.pos, view.state.schema.text(text, [codeMark])); + view.dispatch(tr); + return true; + } + + event.preventDefault(); + const text = event.clipboardData.getData("text/plain"); + const vscode = event.clipboardData.getData("vscode-editor-data"); + const vscodeData = vscode ? JSON.parse(vscode) : undefined; + const language = vscodeData?.mode; + + if (vscodeData && language) { + const { tr } = view.state; + const { $from } = tr.selection; + + // Check if the current line is empty + const isCurrentLineEmpty = !$from.parent.textContent.trim(); + + let insertPos; + + if (isCurrentLineEmpty) { + // If the current line is empty, use the current position + insertPos = $from.pos - 1; + } else { + // If the current line is not empty, insert below the current block node + insertPos = $from.end($from.depth) + 1; + } + + // Ensure insertPos is within document bounds + if (insertPos < 0 || insertPos > tr.doc.content.size) { + console.error("Invalid insert position."); + return false; + } + + // Create a new code block node with the pasted content + const textNode = view.state.schema.text(text.replace(/\r\n?/g, "\n")); + const codeBlock = this.type.create({ language }, textNode); + if (insertPos <= tr.doc.content.size) { + tr.insert(insertPos, codeBlock); + view.dispatch(tr); + return true; + } + + return false; + } else { + // TODO: complicated paste logic, to be handled later + return false; + } + } catch (error) { + console.error("Error handling paste in CodeBlock extension:", error); + return false; + } + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/code/index.tsx b/packages/editor/core/src/ui/extensions/code/index.tsx index 64a1740cb..206930a87 100644 --- a/packages/editor/core/src/ui/extensions/code/index.tsx +++ b/packages/editor/core/src/ui/extensions/code/index.tsx @@ -1,5 +1,3 @@ -import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; - import { common, createLowlight } from "lowlight"; import ts from "highlight.js/lib/languages/typescript"; @@ -7,90 +5,112 @@ const lowlight = createLowlight(common); lowlight.register("ts", ts); import { Selection } from "@tiptap/pm/state"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { CodeBlockComponent } from "./code-block-node-view"; +import { CodeBlockLowlight } from "./code-block-lowlight"; export const CustomCodeBlockExtension = CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockComponent); + }, + addKeyboardShortcuts() { return { Tab: ({ editor }) => { - const { state } = editor; - const { selection } = state; - const { $from, empty } = selection; + try { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; - if (!empty || $from.parent.type !== this.type) { + 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; + } catch (error) { + console.error("Error handling Tab in CustomCodeBlockExtension:", error); 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; + try { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; - if (!empty || $from.parent.type !== this.type) { + 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; + } catch (error) { + console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error); 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) { + try { + if (!this.options.exitOnArrowDown) { + return false; + } + + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + 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(); + } catch (error) { + console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error); return false; } - - const { state } = editor; - const { selection, doc } = state; - const { $from, empty } = selection; - - if (!empty || $from.parent.type !== this.type) { - return false; - } - - 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/code/lowlight-plugin.ts b/packages/editor/core/src/ui/extensions/code/lowlight-plugin.ts new file mode 100644 index 000000000..54aa431c5 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/lowlight-plugin.ts @@ -0,0 +1,153 @@ +import { findChildren } from "@tiptap/core"; +import { Node as ProsemirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import highlight from "highlight.js/lib/core"; + +function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] { + return nodes + .map((node) => { + const classes = [...className, ...(node.properties ? node.properties.className : [])]; + + if (node.children) { + return parseNodes(node.children, classes); + } + + return { + text: node.value, + classes, + }; + }) + .flat(); +} + +function getHighlightNodes(result: any) { + // `.value` for lowlight v1, `.children` for lowlight v2 + return result.value || result.children || []; +} + +function registered(aliasOrLanguage: string) { + return Boolean(highlight.getLanguage(aliasOrLanguage)); +} + +function getDecorations({ + doc, + name, + lowlight, + defaultLanguage, +}: { + doc: ProsemirrorNode; + name: string; + lowlight: any; + defaultLanguage: string | null | undefined; +}) { + const decorations: Decoration[] = []; + + findChildren(doc, (node) => node.type.name === name).forEach((block) => { + let from = block.pos + 1; + const language = block.node.attrs.language || defaultLanguage; + const languages = lowlight.listLanguages(); + + const nodes = + language && (languages.includes(language) || registered(language)) + ? getHighlightNodes(lowlight.highlight(language, block.node.textContent)) + : getHighlightNodes(lowlight.highlightAuto(block.node.textContent)); + + parseNodes(nodes).forEach((node) => { + const to = from + node.text.length; + + if (node.classes.length) { + const decoration = Decoration.inline(from, to, { + class: node.classes.join(" "), + }); + + decorations.push(decoration); + } + + from = to; + }); + }); + + return DecorationSet.create(doc, decorations); +} + +function isFunction(param: () => any) { + return typeof param === "function"; +} + +export function LowlightPlugin({ + name, + lowlight, + defaultLanguage, +}: { + name: string; + lowlight: any; + defaultLanguage: string | null | undefined; +}) { + if (!["highlight", "highlightAuto", "listLanguages"].every((api) => isFunction(lowlight[api]))) { + throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension"); + } + + const lowlightPlugin: Plugin = new Plugin({ + key: new PluginKey("lowlight"), + + state: { + init: (_, { doc }) => + getDecorations({ + doc, + name, + lowlight, + defaultLanguage, + }), + apply: (transaction, decorationSet, oldState, newState) => { + const oldNodeName = oldState.selection.$head.parent.type.name; + const newNodeName = newState.selection.$head.parent.type.name; + const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name); + const newNodes = findChildren(newState.doc, (node) => node.type.name === name); + + if ( + transaction.docChanged && + // Apply decorations if: + // selection includes named node, + ([oldNodeName, newNodeName].includes(name) || + // OR transaction adds/removes named node, + newNodes.length !== oldNodes.length || + // OR transaction has changes that completely encapsulte a node + // (for example, a transaction that affects the entire document). + // Such transactions can happen during collab syncing via y-prosemirror, for example. + transaction.steps.some( + (step) => + // @ts-ignore + step.from !== undefined && + // @ts-ignore + step.to !== undefined && + oldNodes.some( + (node) => + // @ts-ignore + node.pos >= step.from && + // @ts-ignore + node.pos + node.node.nodeSize <= step.to + ) + )) + ) { + return getDecorations({ + doc: transaction.doc, + name, + lowlight, + defaultLanguage, + }); + } + + return decorationSet.map(transaction.mapping, transaction.doc); + }, + }, + + props: { + decorations(state) { + return lowlightPlugin.getState(state); + }, + }, + }); + + return lowlightPlugin; +} diff --git a/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts b/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts new file mode 100644 index 000000000..3b3cfaab1 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts @@ -0,0 +1,9 @@ +import { Extension } from "@tiptap/core"; +import codemark from "prosemirror-codemark"; + +export const CustomCodeMarkPlugin = Extension.create({ + name: "codemarkPlugin", + addProseMirrorPlugins() { + return codemark({ markType: this.editor.schema.marks.code }); + }, +}); diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts new file mode 100644 index 000000000..330ebbc12 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts @@ -0,0 +1,363 @@ +import { EditorState } from "@tiptap/pm/state"; +import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core"; +import { Node, NodeType } from "@tiptap/pm/model"; + +const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { + const { $from } = state.selection; + const nodeType = getNodeType(typeOrName, state.schema); + + let currentNode = null; + let currentDepth = $from.depth; + let currentPos = $from.pos; + let targetDepth: number | null = null; + + while (currentDepth > 0 && targetDepth === null) { + currentNode = $from.node(currentDepth); + + if (currentNode.type === nodeType) { + targetDepth = currentDepth; + } else { + currentDepth -= 1; + currentPos -= 1; + } + } + + if (targetDepth === null) { + return null; + } + + return { $pos: state.doc.resolve(currentPos), depth: targetDepth }; +}; + +const nextListIsDeeper = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth > listItemPos.depth) { + return true; + } + + return false; +}; + +const getNextListDepth = (typeOrName: string, state: EditorState) => { + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos) { + return false; + } + + const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4); + + return depth; +}; + +const getPrevListDepth = (typeOrName: string, state: EditorState) => { + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos) { + return false; + } + + let depth = 0; + const pos = listItemPos.$pos; + + // Adjust the position to ensure we're within the list item, especially for edge cases + const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0)); + + // Traverse up the document structure from the adjusted position + for (let d = resolvedPos.depth; d > 0; d--) { + const node = resolvedPos.node(d); + if (node.type.name === "bulletList" || node.type.name === "orderedList") { + // Increment depth for each list ancestor found + depth++; + } + } + + // Subtract 1 from the calculated depth to get the parent list's depth + // This adjustment is necessary because the depth calculation includes the current list + // By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist + depth = depth > 0 ? depth - 1 : 0; + + // Double the depth value to get results as 2, 4, 6, 8, etc. + depth = depth * 2; + + return depth; +}; + +export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => { + // this is required to still handle the undo handling + if (editor.commands.undoInputRule()) { + return true; + } + // Check if a node range is selected, and if so, fall back to default backspace functionality + const { from, to } = editor.state.selection; + if (from !== to) { + // A range is selected, not just a cursor position; fall back to default behavior + return false; // Let the editor handle backspace by default + } + + // if the current item is NOT inside a list item & + // the previous item is a list (orderedList or bulletList) + // move the cursor into the list and delete the current item + if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) { + const { $anchor } = editor.state.selection; + + const $listPos = editor.state.doc.resolve($anchor.before() - 1); + + const listDescendants: Array<{ node: Node; pos: number }> = []; + + $listPos.node().descendants((node, pos) => { + if (node.type.name === name) { + listDescendants.push({ node, pos }); + } + }); + + const lastItem = listDescendants.at(-1); + + if (!lastItem) { + return false; + } + + const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1); + + // Check if positions are within the valid range + const startPos = $anchor.start() - 1; + const endPos = $anchor.end() + 1; + if (startPos < 0 || endPos > editor.state.doc.content.size) { + return false; // Invalid position, abort operation + } + + return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run(); + } + + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + // if the cursor is not at the start of a node + // do nothing and proceed + if (!isAtStartOfNode(editor.state)) { + return false; + } + const isParaSibling = isCurrentParagraphASibling(editor.state); + const isCurrentListItemSublist = prevListIsHigher(name, editor.state); + const listItemPos = findListItemPos(name, editor.state); + const nextListItemIsSibling = nextListIsSibling(name, editor.state); + + if (!listItemPos) { + return false; + } + + const currentNode = listItemPos.$pos.node(listItemPos.depth); + const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode); + + if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) { + return false; + } + + if (currentListItemHasSubList && isCurrentListItemSublist) { + editor.chain().liftListItem(name).run(); + return editor.commands.joinItemBackward(); + } + + if (isCurrentListItemSublist && nextListItemIsSibling) { + return false; + } + + if (isCurrentListItemSublist) { + return false; + } + + if (currentListItemHasSubList) { + return false; + } + + if (hasListItemBefore(name, editor.state)) { + return editor.chain().liftListItem(name).run(); + } + + if (!currentListItemHasSubList) { + return false; + } + + // otherwise in the end, a backspace should + // always just lift the list item if + // joining / merging is not possible + return editor.chain().liftListItem(name).run(); +}; + +export const handleDelete = (editor: Editor, name: string) => { + // if the cursor is not inside the current node type + // do nothing and proceed + if (!isNodeActive(editor.state, name)) { + return false; + } + + // if the cursor is not at the end of a node + // do nothing and proceed + if (!isAtEndOfNode(editor.state, name)) { + return false; + } + + // check if the next node is a list with a deeper depth + if (nextListIsDeeper(name, editor.state)) { + return editor + .chain() + .focus(editor.state.selection.from + 4) + .lift(name) + .joinBackward() + .run(); + } + + if (nextListIsHigher(name, editor.state)) { + return editor.chain().joinForward().joinBackward().run(); + } + + return editor.commands.joinItemForward(); +}; + +const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => { + const { $anchor } = editorState.selection; + + const previousNodePos = Math.max(0, $anchor.pos - 2); + + const previousNode = editorState.doc.resolve(previousNodePos).node(); + + if (!previousNode || !parentListTypes.includes(previousNode.type.name)) { + return false; + } + + return true; +}; + +const prevListIsHigher = (typeOrName: string, state: EditorState) => { + const listDepth = getPrevListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth < listItemPos.depth) { + return true; + } + + return false; +}; + +const nextListIsSibling = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth === listItemPos.depth) { + return true; + } + + return false; +}; + +export const nextListIsHigher = (typeOrName: string, state: EditorState) => { + const listDepth = getNextListDepth(typeOrName, state); + const listItemPos = findListItemPos(typeOrName, state); + + if (!listItemPos || !listDepth) { + return false; + } + + if (listDepth < listItemPos.depth) { + return true; + } + + return false; +}; + +const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => { + if (!node) { + return false; + } + + const nodeType = getNodeType(typeOrName, state.schema); + + let hasSubList = false; + + node.descendants((child) => { + if (child.type === nodeType) { + hasSubList = true; + } + }); + + return hasSubList; +}; + +const isCurrentParagraphASibling = (state: EditorState): boolean => { + const { $from } = state.selection; + const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item. + const currentParagraphNode = $from.parent; // Get the current node where the selection is. + + // Ensure we're in a paragraph and the parent is a list item. + if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") { + let paragraphNodesCount = 0; + listItemNode.forEach((child) => { + if (child.type.name === "paragraph") { + paragraphNodesCount++; + } + }); + + // If there are more than one paragraph nodes, the current paragraph is a sibling. + return paragraphNodesCount > 1; + } + + return false; +}; + +export function isCursorInSubList(editor: Editor) { + const { selection } = editor.state; + const { $from } = selection; + + // Check if the current node is a list item + const listItem = editor.schema.nodes.listItem; + + // Traverse up the document tree from the current position + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type === listItem) { + // If the parent of the list item is also a list, it's a sub-list + const parent = $from.node(depth - 1); + if ( + parent && + (parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList) + ) { + return true; + } + } + } + + return false; +} + +const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => { + const { $anchor } = state.selection; + + const $targetPos = state.doc.resolve($anchor.pos - 2); + + if ($targetPos.index() === 0) { + return false; + } + + if ($targetPos.nodeBefore?.type.name !== typeOrName) { + return false; + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts deleted file mode 100644 index 3bbfd9c93..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getNodeType } from "@tiptap/core"; -import { NodeType } from "@tiptap/pm/model"; -import { EditorState } from "@tiptap/pm/state"; - -export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { - const { $from } = state.selection; - const nodeType = getNodeType(typeOrName, state.schema); - - let currentNode = null; - let currentDepth = $from.depth; - let currentPos = $from.pos; - let targetDepth: number | null = null; - - while (currentDepth > 0 && targetDepth === null) { - currentNode = $from.node(currentDepth); - - if (currentNode.type === nodeType) { - targetDepth = currentDepth; - } else { - currentDepth -= 1; - currentPos -= 1; - } - } - - if (targetDepth === null) { - return null; - } - - return { $pos: state.doc.resolve(currentPos), depth: targetDepth }; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts deleted file mode 100644 index 2e4f5fbaa..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getNodeAtPosition } from "@tiptap/core"; -import { EditorState } from "@tiptap/pm/state"; - -import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; - -export const getNextListDepth = (typeOrName: string, state: EditorState) => { - const listItemPos = findListItemPos(typeOrName, state); - - if (!listItemPos) { - return false; - } - - const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4); - - return depth; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts deleted file mode 100644 index a4f2d5db9..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core"; -import { Node } from "@tiptap/pm/model"; - -import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; -import { hasListBefore } from "src/ui/extensions/custom-list-keymap/list-helpers/has-list-before"; - -export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => { - // this is required to still handle the undo handling - if (editor.commands.undoInputRule()) { - return true; - } - - // if the cursor is not at the start of a node - // do nothing and proceed - if (!isAtStartOfNode(editor.state)) { - return false; - } - - // if the current item is NOT inside a list item & - // the previous item is a list (orderedList or bulletList) - // move the cursor into the list and delete the current item - if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) { - const { $anchor } = editor.state.selection; - - const $listPos = editor.state.doc.resolve($anchor.before() - 1); - - const listDescendants: Array<{ node: Node; pos: number }> = []; - - $listPos.node().descendants((node, pos) => { - if (node.type.name === name) { - listDescendants.push({ node, pos }); - } - }); - - const lastItem = listDescendants.at(-1); - - if (!lastItem) { - return false; - } - - const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1); - - return editor - .chain() - .cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end()) - .joinForward() - .run(); - } - - // if the cursor is not inside the current node type - // do nothing and proceed - if (!isNodeActive(editor.state, name)) { - return false; - } - - const listItemPos = findListItemPos(name, editor.state); - - if (!listItemPos) { - return false; - } - - // if current node is a list item and cursor it at start of a list node, - // simply lift the list item i.e. remove it as a list item (task/bullet/ordered) - // irrespective of above node being a list or not - return editor.chain().liftListItem(name).run(); -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts deleted file mode 100644 index 9179e0f20..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core"; - -import { nextListIsDeeper } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper"; -import { nextListIsHigher } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher"; - -export const handleDelete = (editor: Editor, name: string) => { - // if the cursor is not inside the current node type - // do nothing and proceed - if (!isNodeActive(editor.state, name)) { - return false; - } - - // if the cursor is not at the end of a node - // do nothing and proceed - if (!isAtEndOfNode(editor.state, name)) { - return false; - } - - // check if the next node is a list with a deeper depth - if (nextListIsDeeper(name, editor.state)) { - return editor - .chain() - .focus(editor.state.selection.from + 4) - .lift(name) - .joinBackward() - .run(); - } - - if (nextListIsHigher(name, editor.state)) { - return editor.chain().joinForward().joinBackward().run(); - } - - return editor.commands.joinItemForward(); -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts deleted file mode 100644 index fb6b95b6a..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { EditorState } from "@tiptap/pm/state"; - -export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => { - const { $anchor } = editorState.selection; - - const previousNodePos = Math.max(0, $anchor.pos - 2); - - const previousNode = editorState.doc.resolve(previousNodePos).node(); - - if (!previousNode || !parentListTypes.includes(previousNode.type.name)) { - return false; - } - - return true; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts deleted file mode 100644 index 4e538ac47..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { EditorState } from "@tiptap/pm/state"; - -export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => { - const { $anchor } = state.selection; - - const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2); - - if ($targetPos.index() === $targetPos.parent.childCount - 1) { - return false; - } - - if ($targetPos.nodeAfter?.type.name !== typeOrName) { - return false; - } - - return true; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts deleted file mode 100644 index 91fda9bf4..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { EditorState } from "@tiptap/pm/state"; - -export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => { - const { $anchor } = state.selection; - - const $targetPos = state.doc.resolve($anchor.pos - 2); - - if ($targetPos.index() === 0) { - return false; - } - - if ($targetPos.nodeBefore?.type.name !== typeOrName) { - return false; - } - - return true; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts deleted file mode 100644 index 644953b92..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./find-list-item-pos"; -export * from "./get-next-list-depth"; -export * from "./handle-backspace"; -export * from "./handle-delete"; -export * from "./has-list-before"; -export * from "./has-list-item-after"; -export * from "./has-list-item-before"; -export * from "./next-list-is-deeper"; -export * from "./next-list-is-higher"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts deleted file mode 100644 index 7cd1a63f7..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { EditorState } from "@tiptap/pm/state"; - -import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; -import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth"; - -export const nextListIsDeeper = (typeOrName: string, state: EditorState) => { - const listDepth = getNextListDepth(typeOrName, state); - const listItemPos = findListItemPos(typeOrName, state); - - if (!listItemPos || !listDepth) { - return false; - } - - if (listDepth > listItemPos.depth) { - return true; - } - - return false; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts deleted file mode 100644 index 3364c3b87..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { EditorState } from "@tiptap/pm/state"; - -import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos"; -import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth"; - -export const nextListIsHigher = (typeOrName: string, state: EditorState) => { - const listDepth = getNextListDepth(typeOrName, state); - const listItemPos = findListItemPos(typeOrName, state); - - if (!listItemPos || !listDepth) { - return false; - } - - if (listDepth < listItemPos.depth) { - return true; - } - - return false; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts index aabd836d2..db1264f57 100644 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts @@ -9,86 +9,120 @@ export type ListKeymapOptions = { }>; }; -export const ListKeymap = Extension.create({ - name: "listKeymap", +export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => + Extension.create({ + name: "listKeymap", - addOptions() { - return { - listTypes: [ - { - itemName: "listItem", - wrapperNames: ["bulletList", "orderedList"], + addOptions() { + return { + listTypes: [ + { + itemName: "listItem", + wrapperNames: ["bulletList", "orderedList"], + }, + { + itemName: "taskItem", + wrapperNames: ["taskList"], + }, + ], + }; + }, + + addKeyboardShortcuts() { + return { + Tab: () => { + if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) { + if (this.editor.commands.sinkListItem("listItem")) { + return true; + } else if (this.editor.commands.sinkListItem("taskItem")) { + return true; + } + return true; + } + // if tabIndex is set, we don't want to handle Tab key + if (tabIndex !== undefined && tabIndex !== null) { + return false; + } + return true; }, - { - itemName: "taskItem", - wrapperNames: ["taskList"], + "Shift-Tab": () => { + if (this.editor.commands.liftListItem("listItem")) { + return true; + } else if (this.editor.commands.liftListItem("taskItem")) { + return true; + } + return true; }, - ], - }; - }, + Delete: ({ editor }) => { + try { + let handled = false; - addKeyboardShortcuts() { - return { - Delete: ({ editor }) => { - let handled = false; + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } - this.options.listTypes.forEach(({ itemName }) => { - if (editor.state.schema.nodes[itemName] === undefined) { - return; + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + } catch (e) { + console.log("error in handling Backspac:", e); + return false; } + }, + "Mod-Delete": ({ editor }) => { + let handled = false; - if (handleDelete(editor, itemName)) { - handled = true; + this.options.listTypes.forEach(({ itemName }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleDelete(editor, itemName)) { + handled = true; + } + }); + + return handled; + }, + Backspace: ({ editor }) => { + try { + let handled = false; + + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } + + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); + + return handled; + } catch (e) { + console.log("error in handling Backspac:", e); + return false; } - }); + }, + "Mod-Backspace": ({ editor }) => { + let handled = false; - return handled; - }, - "Mod-Delete": ({ editor }) => { - let handled = false; + this.options.listTypes.forEach(({ itemName, wrapperNames }) => { + if (editor.state.schema.nodes[itemName] === undefined) { + return; + } - this.options.listTypes.forEach(({ itemName }) => { - if (editor.state.schema.nodes[itemName] === undefined) { - return; - } + if (handleBackspace(editor, itemName, wrapperNames)) { + handled = true; + } + }); - if (handleDelete(editor, itemName)) { - handled = true; - } - }); - - return handled; - }, - Backspace: ({ editor }) => { - let handled = false; - - this.options.listTypes.forEach(({ itemName, wrapperNames }) => { - if (editor.state.schema.nodes[itemName] === undefined) { - return; - } - - if (handleBackspace(editor, itemName, wrapperNames)) { - handled = true; - } - }); - - return handled; - }, - "Mod-Backspace": ({ editor }) => { - let handled = false; - - this.options.listTypes.forEach(({ itemName, wrapperNames }) => { - if (editor.state.schema.nodes[itemName] === undefined) { - return; - } - - if (handleBackspace(editor, itemName, wrapperNames)) { - handled = true; - } - }); - - return handled; - }, - }; - }, -}); + return handled; + }, + }; + }, + }); diff --git a/packages/editor/core/src/ui/extensions/drop.tsx b/packages/editor/core/src/ui/extensions/drop.tsx new file mode 100644 index 000000000..ed206bc42 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/drop.tsx @@ -0,0 +1,45 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { UploadImage } from "src/types/upload-image"; +import { startImageUpload } from "../plugins/upload-image"; + +export const DropHandlerExtension = (uploadFile: UploadImage) => + Extension.create({ + name: "dropHandler", + priority: 1000, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("drop-handler-plugin"), + props: { + handlePaste: (view, event) => { + if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { + event.preventDefault(); + const file = event.clipboardData.files[0]; + const pos = view.state.selection.from; + startImageUpload(this.editor, file, view, pos, uploadFile); + return true; + } + return false; + }, + handleDrop: (view, event, _slice, moved) => { + if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + if (coordinates) { + startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile); + } + return true; + } + return false; + }, + }, + }), + ]; + }, + }); diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts b/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts index 2af845b7a..b9be1a314 100644 --- a/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts +++ b/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts @@ -28,11 +28,22 @@ export const CustomHorizontalRule = Node.create({ group: "block", parseHTML() { - return [{ tag: "hr" }]; + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + { tag: "hr" }, + ]; }, renderHTML({ HTMLAttributes }) { - return ["hr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + "data-type": this.name, + }), + ["div", {}], + ]; }, addCommands() { diff --git a/packages/editor/core/src/ui/extensions/image/image-resize.tsx b/packages/editor/core/src/ui/extensions/image/image-resize.tsx index 400938785..7f61cc9cb 100644 --- a/packages/editor/core/src/ui/extensions/image/image-resize.tsx +++ b/packages/editor/core/src/ui/extensions/image/image-resize.tsx @@ -7,10 +7,19 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => { const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; if (imageInfo) { const selection = editor.state.selection; + + // Use the style width/height if available, otherwise fall back to the element's natural width/height + const width = imageInfo.style.width + ? Number(imageInfo.style.width.replace("px", "")) + : imageInfo.getAttribute("width"); + const height = imageInfo.style.height + ? Number(imageInfo.style.height.replace("px", "")) + : imageInfo.getAttribute("height"); + editor.commands.setImage({ src: imageInfo.src, - width: Number(imageInfo.style.width.replace("px", "")), - height: Number(imageInfo.style.height.replace("px", "")), + width: width, + height: height, } as any); editor.commands.setNodeSelection(selection.from); } @@ -21,7 +30,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => { return ( <> { setAspectRatio(originalWidth / originalHeight); } }} - onResize={({ target, width, height, delta }: any) => { - if (delta[0]) { - const newWidth = Math.max(width, 100); - const newHeight = newWidth / aspectRatio; - target!.style.width = `${newWidth}px`; - target!.style.height = `${newHeight}px`; - } - if (delta[1]) { - const newHeight = Math.max(height, 100); - const newWidth = newHeight * aspectRatio; - target!.style.height = `${newHeight}px`; - target!.style.width = `${newWidth}px`; + onResize={({ target, width, height, delta }) => { + if (delta[0] || delta[1]) { + let newWidth, newHeight; + if (delta[0]) { + // Width change detected + newWidth = Math.max(width, 100); + newHeight = newWidth / aspectRatio; + } else if (delta[1]) { + // Height change detected + newHeight = Math.max(height, 100); + newWidth = newHeight * aspectRatio; + } + target.style.width = `${newWidth}px`; + target.style.height = `${newHeight}px`; } }} onResizeEnd={() => { updateMediaSize(); }} scalable - renderDirections={["w", "e"]} - onScale={({ target, transform }: any) => { - target!.style.transform = transform; + renderDirections={["se"]} + onScale={({ target, transform }) => { + target.style.transform = transform; }} /> diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index 1431b7755..b85100fe5 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -18,7 +18,7 @@ interface ImageNode extends ProseMirrorNode { const deleteKey = new PluginKey("delete-image"); const IMAGE_NODE_TYPE = "image"; -export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) => +export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => void) => ImageExt.extend({ addKeyboardShortcuts() { return { @@ -28,7 +28,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma }, addProseMirrorPlugins() { return [ - UploadImagesPlugin(cancelUploadImage), + UploadImagesPlugin(this.editor, cancelUploadImage), new Plugin({ key: deleteKey, appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { @@ -124,6 +124,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma addStorage() { return { images: new Map(), + uploadInProgress: false, }; }, diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts index a18576b46..205ec96b9 100644 --- a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts +++ b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts @@ -2,44 +2,51 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { KeyboardShortcutCommand } from "@tiptap/core"; export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => { - const { selection, doc } = editor.state; - const { $from, $to } = selection; + try { + const { selection, doc } = editor.state; + const { $from, $to } = selection; - let imageNode: ProseMirrorNode | null = null; - let imagePos: number | null = null; + let imageNode: ProseMirrorNode | null = null; + let imagePos: number | null = null; - // Check if the selection itself is an image node - doc.nodesBetween($from.pos, $to.pos, (node, pos) => { - if (node.type.name === "image") { - imageNode = node; - imagePos = pos; - return false; // Stop iterating once an image node is found - } - return true; - }); + // Check if the selection itself is an image node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === "image") { + imageNode = node; + imagePos = pos; + return false; // Stop iterating once an image node is found + } + return true; + }); - if (imageNode === null || imagePos === null) return false; + if (imageNode === null || imagePos === null) return false; - // Since we want to insert above the image, we use the imagePos directly - const insertPos = imagePos; + // Since we want to insert above the image, we use the imagePos directly + const insertPos = imagePos; - if (insertPos < 0) return false; + const docSize = editor.state.doc.content.size; - // Check for an existing node immediately before the image - if (insertPos === 0) { - // If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there - editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run(); - editor.chain().setTextSelection(insertPos).run(); - } else { - const prevNode = doc.nodeAt(insertPos); + if (insertPos < 0 || insertPos > docSize) return false; - if (prevNode && prevNode.type.name === "paragraph") { - // If the previous node is a paragraph, move the cursor there + // Check for an existing node immediately before the image + if (insertPos === 0) { + // If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there + editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run(); editor.chain().setTextSelection(insertPos).run(); } else { - return false; - } - } + const prevNode = doc.nodeAt(insertPos); - return true; + if (prevNode && prevNode.type.name === "paragraph") { + // If the previous node is a paragraph, move the cursor there + editor.chain().setTextSelection(insertPos).run(); + } else { + return false; + } + } + + return true; + } catch (error) { + console.error("An error occurred while inserting a line above the image:", error); + return false; + } }; diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts index e998c728b..fe06ea0d9 100644 --- a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts +++ b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts @@ -2,45 +2,50 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { KeyboardShortcutCommand } from "@tiptap/core"; export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => { - const { selection, doc } = editor.state; - const { $from, $to } = selection; + try { + const { selection, doc } = editor.state; + const { $from, $to } = selection; - let imageNode: ProseMirrorNode | null = null; - let imagePos: number | null = null; + let imageNode: ProseMirrorNode | null = null; + let imagePos: number | null = null; - // Check if the selection itself is an image node - doc.nodesBetween($from.pos, $to.pos, (node, pos) => { - if (node.type.name === "image") { - imageNode = node; - imagePos = pos; - return false; // Stop iterating once an image node is found + // Check if the selection itself is an image node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === "image") { + imageNode = node; + imagePos = pos; + return false; // Stop iterating once an image node is found + } + return true; + }); + + if (imageNode === null || imagePos === null) return false; + + const guaranteedImageNode: ProseMirrorNode = imageNode; + const nextNodePos = imagePos + guaranteedImageNode.nodeSize; + + // Check for an existing node immediately after the image + const nextNode = doc.nodeAt(nextNodePos); + + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is a paragraph, move the cursor there + const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there + editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(nextNodePos + 1) + .run(); + } else { + // If the next node is not a paragraph, do not proceed + return false; } + return true; - }); - - if (imageNode === null || imagePos === null) return false; - - const guaranteedImageNode: ProseMirrorNode = imageNode; - const nextNodePos = imagePos + guaranteedImageNode.nodeSize; - - // Check for an existing node immediately after the image - const nextNode = doc.nodeAt(nextNodePos); - - if (nextNode && nextNode.type.name === "paragraph") { - // If the next node is a paragraph, move the cursor there - const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; - editor.chain().setTextSelection(endOfParagraphPos).run(); - } else if (!nextNode) { - // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there - editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); - editor - .chain() - .setTextSelection(nextNodePos + 1) - .run(); - } else { - // If the next node is not a paragraph, do not proceed + } catch (error) { + console.error("An error occurred while inserting a line below the image:", error); return false; } - - return true; }; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 1a932d6d5..f4dbaee3b 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -1,8 +1,8 @@ -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 Placeholder from "@tiptap/extension-placeholder"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; @@ -22,36 +22,51 @@ 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 { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; import { CustomTypographyExtension } from "src/ui/extensions/typography"; import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; +import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin"; +import { UploadImage } from "src/types/upload-image"; +import { DropHandlerExtension } from "src/ui/extensions/drop"; -export const CoreEditorExtensions = ( +type TArguments = { mentionConfig: { - mentionSuggestions: IMentionSuggestion[]; - mentionHighlights: string[]; - }, - deleteFile: DeleteImage, - restoreFile: RestoreImage, - cancelUploadImage?: () => any -) => [ + mentionSuggestions?: () => Promise; + mentionHighlights?: () => Promise; + }; + fileConfig: { + deleteFile: DeleteImage; + restoreFile: RestoreImage; + cancelUploadImage?: () => void; + uploadFile: UploadImage; + }; + placeholder?: string | ((isFocused: boolean) => string); + tabIndex?: number; +}; + +export const CoreEditorExtensions = ({ + mentionConfig, + fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile }, + placeholder, + tabIndex, +}: TArguments) => [ StarterKit.configure({ bulletList: { HTMLAttributes: { - class: "list-disc list-outside leading-3 -mt-2", + class: "list-disc pl-7 space-y-2", }, }, orderedList: { HTMLAttributes: { - class: "list-decimal list-outside leading-3 -mt-2", + class: "list-decimal pl-7 space-y-2", }, }, listItem: { HTMLAttributes: { - class: "leading-normal -mb-2", + class: "not-prose space-y-2", }, }, code: false, @@ -60,17 +75,18 @@ export const CoreEditorExtensions = ( blockquote: false, dropcursor: { color: "rgba(var(--color-text-100))", - width: 2, + width: 1, }, }), - CustomQuoteExtension.configure({ - HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, - }), + CustomQuoteExtension, + DropHandlerExtension(uploadFile), CustomHorizontalRule.configure({ - HTMLAttributes: { class: "mt-4 mb-4" }, + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, }), CustomKeymap, - ListKeymap, + ListKeymap({ tabIndex }), CustomLinkExtension.configure({ openOnClick: true, autolink: true, @@ -85,33 +101,57 @@ export const CoreEditorExtensions = ( CustomTypographyExtension, ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ HTMLAttributes: { - class: "rounded-lg border border-custom-border-300", + class: "rounded-md", }, }), TiptapUnderline, TextStyle, - Color, TaskList.configure({ HTMLAttributes: { - class: "not-prose pl-2", + class: "not-prose pl-2 space-y-2", }, }), TaskItem.configure({ HTMLAttributes: { - class: "flex items-start my-4", + class: "flex", }, nested: true, }), - CustomCodeBlockExtension, + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeMarkPlugin, CustomCodeInlineExtension, Markdown.configure({ html: true, - transformCopiedText: true, transformPastedText: true, }), Table, TableHeader, TableCell, TableRow, - Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), + Mentions({ + mentionSuggestions: mentionConfig.mentionSuggestions, + mentionHighlights: mentionConfig.mentionHighlights, + readonly: false, + }), + Placeholder.configure({ + placeholder: ({ editor, node }) => { + if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + + const shouldHidePlaceholder = + editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + if (shouldHidePlaceholder) return ""; + + if (placeholder) { + if (typeof placeholder === "string") return placeholder; + else return placeholder(editor.isFocused); + } + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), ]; diff --git a/packages/editor/core/src/ui/extensions/keymap.tsx b/packages/editor/core/src/ui/extensions/keymap.tsx index 0caa194cd..2e0bdd1fe 100644 --- a/packages/editor/core/src/ui/extensions/keymap.tsx +++ b/packages/editor/core/src/ui/extensions/keymap.tsx @@ -1,4 +1,7 @@ import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { canJoin } from "@tiptap/pm/transform"; +import { NodeType } from "@tiptap/pm/model"; declare module "@tiptap/core" { // eslint-disable-next-line no-unused-vars @@ -12,6 +15,51 @@ declare module "@tiptap/core" { } } +function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { + if (!tr.isGeneric) return false; + + // Find all ranges where we might want to join. + const ranges: Array = []; + for (let i = 0; i < tr.mapping.maps.length; i++) { + const map = tr.mapping.maps[i]; + for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]); + map.forEach((_s, _e, from, to) => ranges.push(from, to)); + } + + // Figure out which joinable points exist inside those ranges, + // by checking all node boundaries in their parent nodes. + const joinable = []; + for (let i = 0; i < ranges.length; i += 2) { + const from = ranges[i], + to = ranges[i + 1]; + const $from = tr.doc.resolve(from), + depth = $from.sharedDepth(to), + parent = $from.node(depth); + for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) { + const after = parent.maybeChild(index); + if (!after) break; + if (index && joinable.indexOf(pos) == -1) { + const before = parent.child(index - 1); + if (before.type == after.type && before.type === nodeType) joinable.push(pos); + } + pos += after.nodeSize; + } + } + + let joined = false; + + // Join the joinable points + joinable.sort((a, b) => a - b); + for (let i = joinable.length - 1; i >= 0; i--) { + if (canJoin(tr.doc, joinable[i])) { + newTr.join(joinable[i]); + joined = true; + } + } + + return joined; +} + export const CustomKeymap = Extension.create({ name: "CustomKeymap", @@ -32,6 +80,42 @@ export const CustomKeymap = Extension.create({ }; }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("ordered-list-merging"), + appendTransaction(transactions, oldState, newState) { + // Create a new transaction. + const newTr = newState.tr; + + let joined = false; + for (const transaction of transactions) { + const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]); + joined = anotherJoin || joined; + } + if (joined) { + return newTr; + } + }, + }), + new Plugin({ + key: new PluginKey("unordered-list-merging"), + appendTransaction(transactions, oldState, newState) { + // Create a new transaction. + const newTr = newState.tr; + + let joined = false; + for (const transaction of transactions) { + const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]); + joined = anotherJoin || joined; + } + if (joined) { + return newTr; + } + }, + }), + ]; + }, addKeyboardShortcuts() { return { "Mod-a": ({ editor }) => { diff --git a/packages/editor/core/src/ui/extensions/quote/index.tsx b/packages/editor/core/src/ui/extensions/quote/index.tsx index 9dcae6ad7..4ae81ffe4 100644 --- a/packages/editor/core/src/ui/extensions/quote/index.tsx +++ b/packages/editor/core/src/ui/extensions/quote/index.tsx @@ -4,21 +4,26 @@ export const CustomQuoteExtension = Blockquote.extend({ addKeyboardShortcuts() { return { Enter: () => { - const { $from, $to, $head } = this.editor.state.selection; - const parent = $head.node(-1); + try { + const { $from, $to, $head } = this.editor.state.selection; + const parent = $head.node(-1); - if (!parent) return false; + if (!parent) return false; - if (parent.type.name !== "blockquote") { + if (parent.type.name !== "blockquote") { + return false; + } + if ($from.pos !== $to.pos) return false; + // if ($head.parentOffset < $head.parent.content.size) return false; + + // this.editor.commands.insertContentAt(parent.ne); + this.editor.chain().splitBlock().lift(this.name).run(); + + return true; + } catch (error) { + console.error("Error handling Enter in blockquote:", error); return false; } - if ($from.pos !== $to.pos) return false; - // if ($head.parentOffset < $head.parent.content.size) return false; - - // this.editor.commands.insertContentAt(parent.ne); - this.editor.chain().splitBlock().lift(this.name).run(); - - return true; }, }; }, diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts index f73c55c09..51c778411 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,10 +1,10 @@ export const icons = { colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` `, - toggleColumnHeader: ``, - toggleRowHeader: ``, + toggleColumnHeader: ``, + toggleRowHeader: ``, insertBottomTableIcon: ` { const pluginState = key.getState(view.state); - if (!(event.target as HTMLElement).closest(".tableWrapper") && pluginState.values.hoveredTable) { + if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) { return view.dispatch( view.state.tr.setMeta(key, { setHoveredTable: null, @@ -34,7 +34,7 @@ export function tableControls() { top: event.clientY, }); - if (!pos) return; + if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return; const table = findParentNode((node) => node.type.name === "table")( TextSelection.create(view.state.doc, pos.pos) 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 2941179c7..d4dfcf5c7 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 @@ -177,7 +177,7 @@ const rowsToolboxItems: ToolboxItem[] = [ action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { - label: "Delete Row", + label: "Delete row", icon: icons.deleteRow, action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(), }, @@ -202,38 +202,44 @@ function createToolbox({ const toolbox = tippy(triggerButton, { content: h( "div", - { className: "tableToolbox" }, - items.map((item, index) => { + { + className: + "rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg min-w-[12rem] whitespace-nowrap", + }, + items.map((item) => { if (item.label === "Pick color") { return h("div", { className: "flex flex-col" }, [ - h("div", { className: "divider" }), - h("div", { className: "colorPickerLabel" }, item.label), + h("hr", { className: "my-2 border-custom-border-200" }), + h("div", { className: "text-custom-text-200 text-sm" }, item.label), h( "div", - { className: "colorPicker grid" }, + { className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" }, Object.entries(colors).map(([colorName, colorValue]) => h("div", { - className: "colorPickerItem flex items-center justify-center", - style: `background-color: ${colorValue.backgroundColor}; - color: ${colorValue.textColor || "inherit"};`, + className: "grid place-items-center size-6 rounded cursor-pointer", + style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`, innerHTML: colorValue.icon ?? `A`, onClick: () => onSelectColor(colorValue), }) ) ), - h("div", { className: "divider" }), + h("hr", { className: "my-2 border-custom-border-200" }), ]); } else { return h( "div", { - className: "toolboxItem", + className: + "flex items-center gap-2 px-1 py-1.5 bg-custom-background-100 hover:bg-custom-background-80 text-sm text-custom-text-200 rounded cursor-pointer", itemType: "div", onClick: () => onClickItem(item), }, [ - h("div", { className: "iconContainer", innerHTML: item.icon }), + h("span", { + className: "h-3 w-3 flex-shrink-0", + innerHTML: item.icon, + }), h("div", { className: "label" }, item.label), ] ); @@ -290,27 +296,27 @@ export class TableView implements NodeView { if (editor.isEditable) { this.rowsControl = h( "div", - { className: "rowsControl" }, + { className: "rows-control" }, h("div", { itemType: "button", - className: "rowsControlDiv", + className: "rows-control-div", onClick: () => this.selectRow(), }) ); this.columnsControl = h( "div", - { className: "columnsControl" }, + { className: "columns-control" }, h("div", { itemType: "button", - className: "columnsControlDiv", + className: "columns-control-div", onClick: () => this.selectColumn(), }) ); this.controls = h( "div", - { className: "tableControls", contentEditable: "false" }, + { className: "table-controls", contentEditable: "false" }, this.rowsControl, this.columnsControl ); @@ -331,7 +337,7 @@ export class TableView implements NodeView { }; this.columnsToolbox = createToolbox({ - triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), + triggerButton: this.columnsControl.querySelector(".columns-control-div"), items: columnsToolboxItems, colors: columnColors, onSelectColor: (color) => setCellsBackgroundColor(this.editor, color), @@ -380,7 +386,7 @@ export class TableView implements NodeView { this.root = h( "div", { - className: "tableWrapper controls--disabled", + className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled", }, this.controls, this.table diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts index d61d21c5b..865bce8b7 100644 --- a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -5,46 +5,51 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) // Check if the current selection or the closest node is a table if (!editor.isActive("table")) return false; - // Get the current selection - const { selection } = editor.state; + try { + // Get the current selection + const { selection } = editor.state; - // Find the table node and its position - const tableNode = findParentNodeOfType(selection, "table"); - if (!tableNode) return false; + // Find the table node and its position + const tableNode = findParentNodeOfType(selection, "table"); + if (!tableNode) return false; - const tablePos = tableNode.pos; + const tablePos = tableNode.pos; - // Determine if the selection is in the first row of the table - const firstRow = tableNode.node.child(0); - const selectionPath = (selection.$anchor as any).path; - const selectionInFirstRow = selectionPath.includes(firstRow); + // Determine if the selection is in the first row of the table + const firstRow = tableNode.node.child(0); + const selectionPath = (selection.$anchor as any).path; + const selectionInFirstRow = selectionPath.includes(firstRow); - if (!selectionInFirstRow) return false; + if (!selectionInFirstRow) return false; - // Check if the table is at the very start of the document or its parent node - if (tablePos === 0) { - // The table is at the start, so just insert a paragraph at the current position - editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run(); - editor - .chain() - .setTextSelection(tablePos + 1) - .run(); - } else { - // The table is not at the start, check for the node immediately before the table - const prevNodePos = tablePos - 1; - - if (prevNodePos <= 0) return false; - - const prevNode = editor.state.doc.nodeAt(prevNodePos - 1); - - if (prevNode && prevNode.type.name === "paragraph") { - // If there's a paragraph before the table, move the cursor to the end of that paragraph - const endOfParagraphPos = tablePos - prevNode.nodeSize; - editor.chain().setTextSelection(endOfParagraphPos).run(); + // Check if the table is at the very start of the document or its parent node + if (tablePos === 0) { + // The table is at the start, so just insert a paragraph at the current position + editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(tablePos + 1) + .run(); } else { - return false; - } - } + // The table is not at the start, check for the node immediately before the table + const prevNodePos = tablePos - 1; - return true; + if (prevNodePos <= 0) return false; + + const prevNode = editor.state.doc.nodeAt(prevNodePos - 1); + + if (prevNode && prevNode.type.name === "paragraph") { + // If there's a paragraph before the table, move the cursor to the end of that paragraph + const endOfParagraphPos = tablePos - prevNode.nodeSize; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else { + return false; + } + } + + return true; + } catch (e) { + console.error("failed to insert line above table", e); + return false; + } }; diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts index 28b46084a..6ce0fa4c4 100644 --- a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -5,44 +5,49 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) // Check if the current selection or the closest node is a table if (!editor.isActive("table")) return false; - // Get the current selection - const { selection } = editor.state; + try { + // Get the current selection + const { selection } = editor.state; - // Find the table node and its position - const tableNode = findParentNodeOfType(selection, "table"); - if (!tableNode) return false; + // Find the table node and its position + const tableNode = findParentNodeOfType(selection, "table"); + if (!tableNode) return false; - const tablePos = tableNode.pos; - const table = tableNode.node; + const tablePos = tableNode.pos; + const table = tableNode.node; - // Determine if the selection is in the last row of the table - const rowCount = table.childCount; - const lastRow = table.child(rowCount - 1); - const selectionPath = (selection.$anchor as any).path; - const selectionInLastRow = selectionPath.includes(lastRow); + // Determine if the selection is in the last row of the table + const rowCount = table.childCount; + const lastRow = table.child(rowCount - 1); + const selectionPath = (selection.$anchor as any).path; + const selectionInLastRow = selectionPath.includes(lastRow); - if (!selectionInLastRow) return false; + if (!selectionInLastRow) return false; - // Calculate the position immediately after the table - const nextNodePos = tablePos + table.nodeSize; + // Calculate the position immediately after the table + const nextNodePos = tablePos + table.nodeSize; - // Check for an existing node immediately after the table - const nextNode = editor.state.doc.nodeAt(nextNodePos); + // Check for an existing node immediately after the table + const nextNode = editor.state.doc.nodeAt(nextNodePos); - if (nextNode && nextNode.type.name === "paragraph") { - // If the next node is an paragraph, move the cursor there - const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; - editor.chain().setTextSelection(endOfParagraphPos).run(); - } else if (!nextNode) { - // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there - editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); - editor - .chain() - .setTextSelection(nextNodePos + 1) - .run(); - } else { + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is an paragraph, move the cursor there + const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there + editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(nextNodePos + 1) + .run(); + } else { + return false; + } + + return true; + } catch (e) { + console.error("failed to insert line above table", e); return false; } - - return true; }; diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index e723ca0d7..8bab79666 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -5,7 +5,7 @@ import { MentionNodeView } from "src/ui/mentions/mention-node-view"; import { IMentionHighlight } from "src/types/mention-suggestion"; export interface CustomMentionOptions extends MentionOptions { - mentionHighlights: IMentionHighlight[]; + mentionHighlights: () => Promise; readonly?: boolean; } @@ -32,6 +32,12 @@ export const CustomMention = Mention.extend({ redirect_uri: { default: "/", }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, }; }, @@ -43,17 +49,6 @@ export const CustomMention = Mention.extend({ return [ { tag: "mention-component", - getAttrs: (node: string | HTMLElement) => { - if (typeof node === "string") { - return null; - } - return { - id: node.getAttribute("data-mention-id") || "", - target: node.getAttribute("data-mention-target") || "", - label: node.innerText.slice(1) || "", - redirect_uri: node.getAttribute("redirect_uri"), - }; - }, }, ]; }, diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx index f6d3e5b1f..838622cbb 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -1,15 +1,90 @@ -// @ts-nocheck - -import { Suggestion } from "src/ui/mentions/suggestion"; import { CustomMention } from "src/ui/mentions/custom"; -import { IMentionHighlight } from "src/types/mention-suggestion"; +import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; -export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => +import { MentionList } from "src/ui/mentions/mention-list"; + +export const Mentions = ({ + mentionHighlights, + mentionSuggestions, + readonly, +}: { + mentionSuggestions?: () => Promise; + mentionHighlights?: () => Promise; + readonly: boolean; +}) => CustomMention.configure({ HTMLAttributes: { class: "mention", }, readonly: readonly, mentionHighlights: mentionHighlights, - suggestion: Suggestion(mentionSuggestions), + suggestion: { + // @ts-expect-error - Tiptap types are incorrect + render: () => { + if (!mentionSuggestions) return; + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + if (!props.clientRect) { + return; + } + component = new ReactRenderer(MentionList, { + props: { ...props, mentionSuggestions }, + editor: props.editor, + }); + props.editor.storage.mentionsOpen = true; + // @ts-expect-error - Tippy types are incorrect + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"), + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup && + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + // @ts-expect-error - Tippy types are incorrect + component?.ref?.onKeyDown(props); + event?.stopPropagation(); + return true; + } + return false; + }, + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; + popup?.[0].destroy(); + component?.destroy(); + }, + }; + }, + }, }); diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/core/src/ui/mentions/mention-list.tsx index afbf10970..b9ac11d13 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/core/src/ui/mentions/mention-list.tsx @@ -1,36 +1,106 @@ import { Editor } from "@tiptap/react"; -import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { cn } from "src/lib/utils"; import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { v4 as uuidv4 } from "uuid"; +import { Avatar } from "@plane/ui"; interface MentionListProps { - items: IMentionSuggestion[]; - command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void; + command: (item: { + id: string; + label: string; + entity_name: string; + entity_identifier: string; + target: string; + redirect_uri: string; + }) => void; + query: string; editor: Editor; + mentionSuggestions: () => Promise; } -// eslint-disable-next-line react/display-name export const MentionList = forwardRef((props: MentionListProps, ref) => { + const { query, mentionSuggestions } = props; + const [items, setItems] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchSuggestions = async () => { + setIsLoading(true); + try { + const suggestions = await mentionSuggestions(); + const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { + const transactionId = uuidv4(); + return { + ...suggestion, + id: transactionId, + }; + }); + + const filteredSuggestions = mappedSuggestions.filter((suggestion) => + suggestion.title.toLowerCase().startsWith(query.toLowerCase()) + ); + + setItems(filteredSuggestions); + } catch (error) { + console.error("Failed to fetch suggestions:", error); + } finally { + setIsLoading(false); + } + }; + + fetchSuggestions(); + }, [query, mentionSuggestions]); const selectItem = (index: number) => { - const item = props.items[index]; + try { + const item = items[index]; - if (item) { - props.command({ - id: item.id, - label: item.title, - target: "users", - redirect_uri: item.redirect_uri, - }); + if (item) { + props.command({ + id: item.id, + label: item.title, + entity_identifier: item.entity_identifier, + entity_name: item.entity_name, + target: "users", + redirect_uri: item.redirect_uri, + }); + } + } catch (error) { + console.error("Error selecting item:", error); } }; + const commandListContainer = useRef(null); + + useLayoutEffect(() => { + const container = commandListContainer?.current; + + const item = container?.children[selectedIndex] as HTMLElement; + + if (item && container) updateScrollView(container, item); + }, [selectedIndex]); + + const updateScrollView = (container: HTMLElement, item: HTMLElement) => { + const containerHeight = container.offsetHeight; + const itemHeight = item ? item.offsetHeight : 0; + + const top = item.offsetTop; + const bottom = top + itemHeight; + + if (top < container.scrollTop) { + container.scrollTop -= container.scrollTop - top + 5; + } else if (bottom > containerHeight + container.scrollTop) { + container.scrollTop += bottom - containerHeight - container.scrollTop + 5; + } + }; const upHandler = () => { - setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length); + setSelectedIndex((selectedIndex + items.length - 1) % items.length); }; const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items.length); + setSelectedIndex((selectedIndex + 1) % items.length); }; const enterHandler = () => { @@ -39,7 +109,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => { useEffect(() => { setSelectedIndex(0); - }, [props.items]); + }, [items]); useImperativeHandle(ref, () => ({ onKeyDown: ({ event }: { event: KeyboardEvent }) => { @@ -62,38 +132,33 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => { }, })); - return props.items && props.items.length !== 0 ? ( -
- {props.items.length ? ( - props.items.map((item, index) => ( + return ( +
+ {isLoading ? ( +
Loading...
+ ) : items.length ? ( + items.map((item, index) => (
selectItem(index)} > -
- {item.avatar && item.avatar.trim() !== "" ? ( - {item.title} - ) : ( -
- {item.title[0]} -
- )} -
-
-

{item.title}

- {/*

{item.subtitle}

*/} -
+ + {item.title}
)) ) : ( -
No result
+
No results
)}
- ) : ( - <> ); }); diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/core/src/ui/mentions/mention-node-view.tsx index 1c3755f6c..0a1f1b5e0 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/core/src/ui/mentions/mention-node-view.tsx @@ -4,11 +4,21 @@ import { NodeViewWrapper } from "@tiptap/react"; import { cn } from "src/lib/utils"; import { useRouter } from "next/router"; import { IMentionHighlight } from "src/types/mention-suggestion"; +import { useEffect, useState } from "react"; // eslint-disable-next-line import/no-anonymous-default-export export const MentionNodeView = (props) => { const router = useRouter(); - const highlights = props.extension.options.mentionHighlights as IMentionHighlight[]; + const [highlightsState, setHighlightsState] = useState(); + + useEffect(() => { + if (!props.extension.options.mentionHighlights) return; + const hightlights = async () => { + const userId = await props.extension.options.mentionHighlights(); + setHighlightsState(userId); + }; + hightlights(); + }, [props.extension.options]); const handleClick = () => { if (!props.extension.options.readonly) { @@ -20,13 +30,12 @@ export const MentionNodeView = (props) => { @{props.node.attrs.label} diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts index 40e75a1e3..8eaf0982f 100644 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -1,66 +1,17 @@ -import { ReactRenderer } from "@tiptap/react"; -import { Editor } from "@tiptap/core"; -import tippy from "tippy.js"; - -import { MentionList } from "src/ui/mentions/mention-list"; +import { v4 as uuidv4 } from "uuid"; import { IMentionSuggestion } from "src/types/mention-suggestion"; -export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ - items: ({ query }: { query: string }) => - suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5), - render: () => { - let reactRenderer: ReactRenderer | null = null; - let popup: any | null = null; - - return { - onStart: (props: { editor: Editor; clientRect: DOMRect }) => { - props.editor.storage.mentionsOpen = true; - reactRenderer = new ReactRenderer(MentionList, { - props, - editor: props.editor, - }); - // @ts-ignore - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector("#editor-container"), - content: reactRenderer.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { - reactRenderer?.updateProps(props); - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; - - if (navigationKeys.includes(props.event.key)) { - // @ts-ignore - reactRenderer?.ref?.onKeyDown(props); - event?.stopPropagation(); - return true; - } - return false; - }, - onExit: (props: { editor: Editor; event: KeyboardEvent }) => { - props.editor.storage.mentionsOpen = false; - popup?.[0].destroy(); - reactRenderer?.destroy(); - }, - }; - }, -}); +export const getSuggestionItems = + (suggestions: IMentionSuggestion[]) => + ({ query }: { query: string }) => { + const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { + const transactionId = uuidv4(); + return { + ...suggestion, + id: transactionId, + }; + }); + return mappedSuggestions + .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) + .slice(0, 5); + }; 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 f60febc59..66736e0ea 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -33,6 +33,7 @@ import { } from "src/lib/editor-commands"; import { LucideIconType } from "src/types/lucide-icon"; import { UploadImage } from "src/types/upload-image"; +import { Selection } from "@tiptap/pm/state"; export interface EditorMenuItem { name: string; @@ -41,104 +42,142 @@ export interface EditorMenuItem { icon: LucideIconType; } -export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({ - name: "H1", - isActive: () => editor.isActive("heading", { level: 1 }), - command: () => toggleHeadingOne(editor), - icon: Heading1, -}); +export const HeadingOneItem = (editor: Editor) => + ({ + name: "H1", + isActive: () => editor.isActive("heading", { level: 1 }), + command: () => toggleHeadingOne(editor), + icon: Heading1, + }) as const satisfies EditorMenuItem; -export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({ - name: "H2", - isActive: () => editor.isActive("heading", { level: 2 }), - command: () => toggleHeadingTwo(editor), - icon: Heading2, -}); +export const HeadingTwoItem = (editor: Editor) => + ({ + name: "H2", + isActive: () => editor.isActive("heading", { level: 2 }), + command: () => toggleHeadingTwo(editor), + icon: Heading2, + }) as const satisfies EditorMenuItem; -export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({ - name: "H3", - isActive: () => editor.isActive("heading", { level: 3 }), - command: () => toggleHeadingThree(editor), - icon: Heading3, -}); +export const HeadingThreeItem = (editor: Editor) => + ({ + name: "H3", + isActive: () => editor.isActive("heading", { level: 3 }), + command: () => toggleHeadingThree(editor), + icon: Heading3, + }) as const satisfies EditorMenuItem; -export const BoldItem = (editor: Editor): EditorMenuItem => ({ - name: "bold", - isActive: () => editor?.isActive("bold"), - command: () => toggleBold(editor), - icon: BoldIcon, -}); +export const BoldItem = (editor: Editor) => + ({ + name: "bold", + isActive: () => editor?.isActive("bold"), + command: () => toggleBold(editor), + icon: BoldIcon, + }) as const satisfies EditorMenuItem; -export const ItalicItem = (editor: Editor): EditorMenuItem => ({ - name: "italic", - isActive: () => editor?.isActive("italic"), - command: () => toggleItalic(editor), - icon: ItalicIcon, -}); +export const ItalicItem = (editor: Editor) => + ({ + name: "italic", + isActive: () => editor?.isActive("italic"), + command: () => toggleItalic(editor), + icon: ItalicIcon, + }) as const satisfies EditorMenuItem; -export const UnderLineItem = (editor: Editor): EditorMenuItem => ({ - name: "underline", - isActive: () => editor?.isActive("underline"), - command: () => toggleUnderline(editor), - icon: UnderlineIcon, -}); +export const UnderLineItem = (editor: Editor) => + ({ + name: "underline", + isActive: () => editor?.isActive("underline"), + command: () => toggleUnderline(editor), + icon: UnderlineIcon, + }) as const satisfies EditorMenuItem; -export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({ - name: "strike", - isActive: () => editor?.isActive("strike"), - command: () => toggleStrike(editor), - icon: StrikethroughIcon, -}); +export const StrikeThroughItem = (editor: Editor) => + ({ + name: "strike", + isActive: () => editor?.isActive("strike"), + command: () => toggleStrike(editor), + icon: StrikethroughIcon, + }) as const satisfies EditorMenuItem; -export const BulletListItem = (editor: Editor): EditorMenuItem => ({ - name: "bullet-list", - isActive: () => editor?.isActive("bulletList"), - command: () => toggleBulletList(editor), - icon: ListIcon, -}); +export const BulletListItem = (editor: Editor) => + ({ + name: "bullet-list", + isActive: () => editor?.isActive("bulletList"), + command: () => toggleBulletList(editor), + icon: ListIcon, + }) as const satisfies EditorMenuItem; -export const TodoListItem = (editor: Editor): EditorMenuItem => ({ - name: "To-do List", - isActive: () => editor.isActive("taskItem"), - command: () => toggleTaskList(editor), - icon: CheckSquare, -}); +export const TodoListItem = (editor: Editor) => + ({ + name: "To-do List", + isActive: () => editor.isActive("taskItem"), + command: () => toggleTaskList(editor), + icon: CheckSquare, + }) as const satisfies EditorMenuItem; -export const CodeItem = (editor: Editor): EditorMenuItem => ({ - name: "code", - isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), - command: () => toggleCodeBlock(editor), - icon: CodeIcon, -}); +export const CodeItem = (editor: Editor) => + ({ + name: "code", + isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"), + command: () => toggleCodeBlock(editor), + icon: CodeIcon, + }) as const satisfies EditorMenuItem; -export const NumberedListItem = (editor: Editor): EditorMenuItem => ({ - name: "ordered-list", - isActive: () => editor?.isActive("orderedList"), - command: () => toggleOrderedList(editor), - icon: ListOrderedIcon, -}); +export const NumberedListItem = (editor: Editor) => + ({ + name: "ordered-list", + isActive: () => editor?.isActive("orderedList"), + command: () => toggleOrderedList(editor), + icon: ListOrderedIcon, + }) as const satisfies EditorMenuItem; -export const QuoteItem = (editor: Editor): EditorMenuItem => ({ - name: "quote", - isActive: () => editor?.isActive("blockquote"), - command: () => toggleBlockquote(editor), - icon: QuoteIcon, -}); +export const QuoteItem = (editor: Editor) => + ({ + name: "quote", + isActive: () => editor?.isActive("blockquote"), + command: () => toggleBlockquote(editor), + icon: QuoteIcon, + }) as const satisfies EditorMenuItem; -export const TableItem = (editor: Editor): EditorMenuItem => ({ - name: "table", - isActive: () => editor?.isActive("table"), - command: () => insertTableCommand(editor), - icon: TableIcon, -}); +export const TableItem = (editor: Editor) => + ({ + name: "table", + isActive: () => editor?.isActive("table"), + command: () => insertTableCommand(editor), + icon: TableIcon, + }) as const satisfies EditorMenuItem; -export const ImageItem = ( - editor: Editor, - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -): EditorMenuItem => ({ - name: "image", - isActive: () => editor?.isActive("image"), - command: () => insertImageCommand(editor, uploadFile, setIsSubmitting), - icon: ImageIcon, -}); +export const ImageItem = (editor: Editor, uploadFile: UploadImage) => + ({ + name: "image", + isActive: () => editor?.isActive("image"), + command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection), + icon: ImageIcon, + }) as const; + +export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) { + if (!editor) { + return []; + } + return [ + HeadingOneItem(editor), + HeadingTwoItem(editor), + HeadingThreeItem(editor), + BoldItem(editor), + ItalicItem(editor), + UnderLineItem(editor), + StrikeThroughItem(editor), + BulletListItem(editor), + TodoListItem(editor), + CodeItem(editor), + NumberedListItem(editor), + QuoteItem(editor), + TableItem(editor), + ImageItem(editor, uploadFile), + ]; +} + +export type EditorMenuItemNames = ReturnType extends (infer U)[] + ? U extends { name: infer N } + ? N + : never + : never; diff --git a/packages/editor/core/src/ui/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx index afe13730a..03b4dbd10 100644 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ b/packages/editor/core/src/ui/plugins/delete-image.tsx @@ -57,10 +57,7 @@ export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { try { const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - const resStatus = await deleteImage(assetUrlWithWorkspaceId); - if (resStatus === 204) { - console.log("Image deleted successfully"); - } + await deleteImage(assetUrlWithWorkspaceId); } catch (error) { console.error("Error deleting image: ", error); } @@ -69,10 +66,7 @@ export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Prom export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { try { const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - const resStatus = await restoreImage(assetUrlWithWorkspaceId); - if (resStatus === 204) { - console.log("Image restored successfully"); - } + await restoreImage(assetUrlWithWorkspaceId); } catch (error) { console.error("Error restoring image: ", error); } diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx index 738653d71..af56d5382 100644 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ b/packages/editor/core/src/ui/plugins/upload-image.tsx @@ -1,12 +1,22 @@ +import { Editor } from "@tiptap/core"; import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; import { UploadImage } from "src/types/upload-image"; const uploadKey = new PluginKey("upload-image"); -export const UploadImagesPlugin = (cancelUploadImage?: () => any) => - new Plugin({ +export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { + let currentView: EditorView | null = null; + return new Plugin({ key: uploadKey, + view(editorView) { + currentView = editorView; + return { + destroy() { + currentView = null; + }, + }; + }, state: { init() { return DecorationSet.empty; @@ -21,19 +31,23 @@ export const UploadImagesPlugin = (cancelUploadImage?: () => any) => const placeholder = document.createElement("div"); placeholder.setAttribute("class", "img-placeholder"); const image = document.createElement("img"); - image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300"); + image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300"); image.src = src; placeholder.appendChild(image); // Create cancel button const cancelButton = document.createElement("button"); + cancelButton.type = "button"; cancelButton.style.position = "absolute"; cancelButton.style.right = "3px"; cancelButton.style.top = "3px"; cancelButton.setAttribute("class", "opacity-90 rounded-lg"); cancelButton.onclick = () => { - cancelUploadImage?.(); + if (currentView) { + cancelUploadImage?.(); + removePlaceholder(editor, currentView, id); + } }; // Create an SVG element from the SVG string @@ -59,6 +73,7 @@ export const UploadImagesPlugin = (cancelUploadImage?: () => any) => }, }, }); +}; function findPlaceholder(state: EditorState, id: {}) { const decos = uploadKey.getState(state); @@ -66,32 +81,38 @@ function findPlaceholder(state: EditorState, id: {}) { return found.length ? found[0].from : null; } -const removePlaceholder = (view: EditorView, id: {}) => { +const removePlaceholder = (editor: Editor, view: EditorView, id: {}) => { const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id }, }); view.dispatch(removePlaceholderTr); + editor.storage.image.uploadInProgress = false; }; export async function startImageUpload( + editor: Editor, file: File, view: EditorView, pos: number, - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void + uploadFile: UploadImage ) { + editor.storage.image.uploadInProgress = true; + if (!file) { alert("No file selected. Please select a file to upload."); + editor.storage.image.uploadInProgress = false; return; } if (!file.type.includes("image/")) { alert("Invalid file type. Please select an image file."); + editor.storage.image.uploadInProgress = false; return; } if (file.size > 5 * 1024 * 1024) { alert("File size too large. Please select a file smaller than 5MB."); + editor.storage.image.uploadInProgress = false; return; } @@ -116,27 +137,31 @@ export async function startImageUpload( // Handle FileReader errors reader.onerror = (error) => { console.error("FileReader error: ", error); - removePlaceholder(view, id); + removePlaceholder(editor, view, id); return; }; - setIsSubmitting?.("submitting"); + // setIsSubmitting?.("submitting"); try { const src = await UploadImageHandler(file, uploadFile); const { schema } = view.state; pos = findPlaceholder(view.state, id); - if (pos == null) return; + if (pos == null) { + editor.storage.image.uploadInProgress = false; + return; + } const imageSrc = typeof src === "object" ? reader.result : src; const node = schema.nodes.image.create({ src: imageSrc }); const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } }); view.dispatch(transaction); + view.focus(); + editor.storage.image.uploadInProgress = false; } catch (error) { - console.error("Upload error: ", error); - removePlaceholder(view, id); + removePlaceholder(editor, view, id); } } diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 1846efe47..3d46b5840 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -1,15 +1,13 @@ import { EditorProps } from "@tiptap/pm/view"; -import { findTableAncestor } from "src/lib/utils"; -import { UploadImage } from "src/types/upload-image"; -import { startImageUpload } from "src/ui/plugins/upload-image"; +import { cn } from "src/lib/utils"; -export function CoreEditorProps( - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -): EditorProps { +export function CoreEditorProps(editorClassName: string): EditorProps { return { attributes: { - class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, + class: cn( + "prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none", + editorClassName + ), }, handleDOMEvents: { keydown: (_view, event) => { @@ -17,45 +15,12 @@ export function CoreEditorProps( if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { + console.log("registered"); return true; } } }, }, - handlePaste: (view, event) => { - if (typeof window !== "undefined") { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return; - } - } - } - if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { - event.preventDefault(); - const file = event.clipboardData.files[0]; - const pos = view.state.selection.from; - startImageUpload(file, view, pos, uploadFile, setIsSubmitting); - return true; - } - return false; - }, - handleDrop: (view, event, _slice, moved) => { - if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { - event.preventDefault(); - const file = event.dataTransfer.files[0]; - const coordinates = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - if (coordinates) { - startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting); - } - return true; - } - return false; - }, transformPastedHTML(html) { return html.replace(//g, ""); }, diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 93e1b3887..33853e9b1 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -1,7 +1,6 @@ import StarterKit from "@tiptap/starter-kit"; 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 { Markdown } from "tiptap-markdown"; @@ -14,7 +13,7 @@ import { TableRow } from "src/ui/extensions/table/table-row/table-row"; 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 { IMentionHighlight } from "src/types/mention-suggestion"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; import { CustomQuoteExtension } from "src/ui/extensions/quote"; @@ -23,23 +22,22 @@ import { CustomCodeBlockExtension } from "src/ui/extensions/code"; import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { - mentionSuggestions: IMentionSuggestion[]; - mentionHighlights: string[]; + mentionHighlights?: () => Promise; }) => [ StarterKit.configure({ bulletList: { HTMLAttributes: { - class: "list-disc list-outside leading-3 -mt-2", + class: "list-disc pl-7 space-y-2", }, }, orderedList: { HTMLAttributes: { - class: "list-decimal list-outside leading-3 -mt-2", + class: "list-decimal pl-7 space-y-2", }, }, listItem: { HTMLAttributes: { - class: "leading-normal -mb-2", + class: "not-prose space-y-2", }, }, code: false, @@ -49,11 +47,11 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { dropcursor: false, gapcursor: false, }), - CustomQuoteExtension.configure({ - HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, - }), + CustomQuoteExtension, CustomHorizontalRule.configure({ - HTMLAttributes: { class: "mt-4 mb-4" }, + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, }), CustomLinkExtension.configure({ openOnClick: true, @@ -69,24 +67,27 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { CustomTypographyExtension, ReadOnlyImageExtension.configure({ HTMLAttributes: { - class: "rounded-lg border border-custom-border-300", + class: "rounded-md", }, }), TiptapUnderline, TextStyle, - Color, TaskList.configure({ HTMLAttributes: { - class: "not-prose pl-2", + class: "not-prose pl-2 space-y-2", }, }), TaskItem.configure({ HTMLAttributes: { - class: "flex items-start my-4", + class: "flex pointer-events-none", }, nested: true, }), - CustomCodeBlockExtension, + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), CustomCodeInlineExtension, Markdown.configure({ html: true, @@ -96,5 +97,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { TableHeader, TableCell, TableRow, - Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true), + Mentions({ + mentionHighlights: mentionConfig.mentionHighlights, + readonly: true, + }), ]; diff --git a/packages/editor/core/src/ui/read-only/props.tsx b/packages/editor/core/src/ui/read-only/props.tsx index 79f9fcb0d..bd9b6713b 100644 --- a/packages/editor/core/src/ui/read-only/props.tsx +++ b/packages/editor/core/src/ui/read-only/props.tsx @@ -1,7 +1,11 @@ import { EditorProps } from "@tiptap/pm/view"; +import { cn } from "src/lib/utils"; -export const CoreReadOnlyEditorProps: EditorProps = { +export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({ attributes: { - class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`, + class: cn( + "prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none", + editorClassName + ), }, -}; +}); diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index 870d5edd9..3cde34825 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.16.0", + "version": "0.18.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -34,7 +34,6 @@ "@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", "lucide-react": "^0.309.0", diff --git a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx b/packages/editor/document-editor/src/hooks/use-editor-markings.tsx index 1eb72eaab..88f125a26 100644 --- a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx +++ b/packages/editor/document-editor/src/hooks/use-editor-markings.tsx @@ -1,33 +1,30 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { IMarking } from "src/types/editor-types"; export const useEditorMarkings = () => { const [markings, setMarkings] = useState([]); - const updateMarkings = (json: any) => { - const nodes = json.content as any[]; + const updateMarkings = useCallback((html: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const headings = doc.querySelectorAll("h1, h2, h3"); const tempMarkings: IMarking[] = []; let h1Sequence: number = 0; let h2Sequence: number = 0; let h3Sequence: number = 0; - if (nodes) { - nodes.forEach((node) => { - if ( - node.type === "heading" && - (node.attrs.level === 1 || node.attrs.level === 2 || node.attrs.level === 3) && - node.content - ) { - tempMarkings.push({ - type: "heading", - level: node.attrs.level, - text: node.content[0].text, - sequence: node.attrs.level === 1 ? ++h1Sequence : node.attrs.level === 2 ? ++h2Sequence : ++h3Sequence, - }); - } + + headings.forEach((heading) => { + const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3 + tempMarkings.push({ + type: "heading", + level: level, + text: heading.textContent || "", + sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence, }); - } + }); + setMarkings(tempMarkings); - }; + }, []); return { updateMarkings, diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index c074009f4..f8eea14ce 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -1,3 +1,9 @@ export { DocumentEditor, DocumentEditorWithRef } from "src/ui"; export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly"; -export { FixedMenu } from "src/ui/menu/fixed-menu"; + +// hooks +export { useEditorMarkings } from "src/hooks/use-editor-markings"; + +export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; + +export type { IMarking } from "src/types/editor-types"; diff --git a/packages/editor/document-editor/src/types/editor-types.ts b/packages/editor/document-editor/src/types/editor-types.ts index 5a28daf9e..476642103 100644 --- a/packages/editor/document-editor/src/types/editor-types.ts +++ b/packages/editor/document-editor/src/types/editor-types.ts @@ -1,10 +1,3 @@ -export interface DocumentDetails { - title: string; - created_by: string; - created_on: Date; - last_updated_by: string; - last_updated_at: Date; -} export interface IMarking { type: "heading"; level: number; diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx deleted file mode 100644 index 69b6dd02d..000000000 --- a/packages/editor/document-editor/src/ui/components/alert-label.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { LucideIconType } from "@plane/editor-core"; - -interface IAlertLabelProps { - Icon?: LucideIconType; - backgroundColor: string; - textColor?: string; - label: string; -} -export const AlertLabel = (props: IAlertLabelProps) => { - const { Icon, backgroundColor, textColor, label } = props; - - return ( -
- {Icon && } - {label} -
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx deleted file mode 100644 index 926d9a53d..000000000 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { HeadingComp, HeadingThreeComp, SubheadingComp } from "src/ui/components/heading-component"; -import { IMarking } from "src/types/editor-types"; -import { Editor } from "@tiptap/react"; -import { scrollSummary } from "src/utils/editor-summary-utils"; - -interface ContentBrowserProps { - editor: Editor; - markings: IMarking[]; - setSidePeekVisible?: (sidePeekState: boolean) => void; -} - -export const ContentBrowser = (props: ContentBrowserProps) => { - const { editor, markings, setSidePeekVisible } = props; - - const handleOnClick = (marking: IMarking) => { - scrollSummary(editor, marking); - if (setSidePeekVisible) setSidePeekVisible(false); - }; - - return ( -
-

Outline

-
- {markings.length !== 0 ? ( - markings.map((marking) => - marking.level === 1 ? ( - handleOnClick(marking)} heading={marking.text} /> - ) : marking.level === 2 ? ( - handleOnClick(marking)} subHeading={marking.text} /> - ) : ( - handleOnClick(marking)} /> - ) - ) - ) : ( -

Headings will be displayed here for navigation

- )} -
-
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx deleted file mode 100644 index a322ddddc..000000000 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { Archive, RefreshCw, Lock } from "lucide-react"; -import { IMarking, DocumentDetails } from "src/types/editor-types"; -import { FixedMenu } from "src/ui/menu"; -import { UploadImage } from "@plane/editor-core"; -import { AlertLabel } from "src/ui/components/alert-label"; -import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "src/ui/components/vertical-dropdown-menu"; -import { SummaryPopover } from "src/ui/components/summary-popover"; -import { InfoPopover } from "src/ui/components/info-popover"; - -interface IEditorHeader { - editor: Editor; - KanbanMenuOptions: IVerticalDropdownItemProps[]; - sidePeekVisible: boolean; - setSidePeekVisible: (sidePeekState: boolean) => void; - markings: IMarking[]; - isLocked: boolean; - isArchived: boolean; - archivedAt?: Date; - readonly: boolean; - uploadFile?: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - documentDetails: DocumentDetails; - isSubmitting?: "submitting" | "submitted" | "saved"; -} - -export const EditorHeader = (props: IEditorHeader) => { - const { - documentDetails, - archivedAt, - editor, - sidePeekVisible, - readonly, - setSidePeekVisible, - markings, - uploadFile, - setIsSubmitting, - KanbanMenuOptions, - isArchived, - isLocked, - isSubmitting, - } = props; - - return ( -
-
- -
- -
- {!readonly && uploadFile && ( - - )} -
- -
- {isLocked && ( - - )} - {isArchived && archivedAt && ( - - )} - - {!isLocked && !isArchived ? ( -
- {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( - - )} - - {isSubmitting === "submitting" ? "Saving..." : "Saved"} - -
- ) : null} - {!isArchived && } - -
-
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/packages/editor/document-editor/src/ui/components/heading-component.tsx deleted file mode 100644 index ce3489418..000000000 --- a/packages/editor/document-editor/src/ui/components/heading-component.tsx +++ /dev/null @@ -1,47 +0,0 @@ -export const HeadingComp = ({ - heading, - onClick, -}: { - heading: string; - onClick: (event: React.MouseEvent) => void; -}) => ( -

- {heading} -

-); - -export const SubheadingComp = ({ - subHeading, - onClick, -}: { - subHeading: string; - onClick: (event: React.MouseEvent) => void; -}) => ( -

- {subHeading} -

-); - -export const HeadingThreeComp = ({ - heading, - onClick, -}: { - heading: string; - onClick: (event: React.MouseEvent) => void; -}) => ( -

- {heading} -

-); diff --git a/packages/editor/document-editor/src/ui/components/index.ts b/packages/editor/document-editor/src/ui/components/index.ts index 1496a3cf4..4d2d76baa 100644 --- a/packages/editor/document-editor/src/ui/components/index.ts +++ b/packages/editor/document-editor/src/ui/components/index.ts @@ -1,9 +1 @@ -export * from "./alert-label"; -export * from "./content-browser"; -export * from "./editor-header"; -export * from "./heading-component"; -export * from "./info-popover"; export * from "./page-renderer"; -export * from "./summary-popover"; -export * from "./summary-side-bar"; -export * from "./vertical-dropdown-menu"; diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx index 971915439..0cee059df 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -115,11 +115,6 @@ export const LinkEditView = ({ 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(); }; 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 index ff3fd0263..0bb719d93 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx @@ -12,21 +12,11 @@ export const LinkPreview = ({ 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(); }; 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 index f1d22a68e..9befc514c 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-view.tsx @@ -11,11 +11,6 @@ export interface LinkViewProps { 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 }) => { 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 7c2717e80..a6b5eb5e8 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -1,9 +1,8 @@ +import { useCallback, useRef, useState } from "react"; import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; 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, @@ -15,40 +14,22 @@ import { useFloating, useInteractions, } from "@floating-ui/react"; +import BlockMenu from "../menu//block-menu"; type IPageRenderer = { - documentDetails: DocumentDetails; - updatePageTitle: (title: string) => void; editor: Editor; - onActionCompleteHandler: (action: { - title: string; - message: string; - type: "success" | "error" | "warning" | "info"; - }) => void; - editorClassNames: string; - editorContentCustomClassNames?: string; + editorContainerClassName: string; hideDragHandle?: () => void; - readonly: boolean; tabIndex?: number; }; export const PageRenderer = (props: IPageRenderer) => { - const { - documentDetails, - tabIndex, - editor, - editorClassNames, - editorContentCustomClassNames, - updatePageTitle, - readonly, - hideDragHandle, - } = props; - - const [pageTitle, setPagetitle] = useState(documentDetails.title); - + const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props; + // states const [linkViewProps, setLinkViewProps] = useState(); const [isOpen, setIsOpen] = useState(false); const [coordinates, setCoordinates] = useState<{ x: number; y: number }>(); + const [cleanup, setCleanup] = useState(() => () => {}); const { refs, floatingStyles, context } = useFloating({ open: isOpen, @@ -63,18 +44,9 @@ export const PageRenderer = (props: IPageRenderer) => { const { getFloatingProps } = useInteractions([dismiss]); - const handlePageTitleChange = (title: string) => { - setPagetitle(title); - updatePageTitle(title); - }; - - const [cleanup, setcleanup] = useState(() => () => {}); - const floatingElementRef = useRef(null); - const closeLinkView = () => { - setIsOpen(false); - }; + const closeLinkView = () => setIsOpen(false); const handleLinkHover = useCallback( (event: React.MouseEvent) => { @@ -137,7 +109,6 @@ export const PageRenderer = (props: IPageRenderer) => { setCoordinates({ x: x - 300, y: y - 50 }); setIsOpen(true); setLinkViewProps({ - onActionCompleteHandler: props.onActionCompleteHandler, closeLinkView: closeLinkView, view: "LinkPreview", url: href, @@ -148,45 +119,32 @@ export const PageRenderer = (props: IPageRenderer) => { }); }); - setcleanup(cleanupFunc); + setCleanup(cleanupFunc); }, [editor, cleanup] ); return ( -
- {!readonly ? ( - handlePageTitleChange(e.target.value)} - className="-mt-2 w-full break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none" - value={pageTitle} - /> - ) : ( - handlePageTitleChange(e.target.value)} - className="-mt-2 w-full overflow-x-clip break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none" - value={pageTitle} - disabled - /> - )} -
- - + <> +
+ + + {editor && editor.isEditable && }
{isOpen && linkViewProps && coordinates && (
)} -
+ ); }; diff --git a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx deleted file mode 100644 index 44ede3e8d..000000000 --- a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { IMarking } from "src/types/editor-types"; -import { ContentBrowser } from "src/ui/components/content-browser"; - -interface ISummarySideBarProps { - editor: Editor; - markings: IMarking[]; - sidePeekVisible: boolean; -} - -export const SummarySideBar = ({ editor, markings, sidePeekVisible }: ISummarySideBarProps) => ( -
- -
-); diff --git a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx deleted file mode 100644 index 43843e507..000000000 --- a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { LucideIconType } from "@plane/editor-core"; -import { CustomMenu } from "@plane/ui"; -import { MoreVertical } from "lucide-react"; - -type TMenuItems = - | "archive_page" - | "unarchive_page" - | "lock_page" - | "unlock_page" - | "copy_markdown" - | "close_page" - | "copy_page_link" - | "duplicate_page"; - -export interface IVerticalDropdownItemProps { - key: number; - type: TMenuItems; - Icon: LucideIconType; - label: string; - action: () => Promise | void; -} - -export interface IVerticalDropdownMenuProps { - items: IVerticalDropdownItemProps[]; -} - -const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => ( - - -
{label}
-
-); - -export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => ( - } - > - {items.map((item) => ( - - ))} - -); diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index cedc3ed80..b2816974e 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -1,28 +1,15 @@ -import Placeholder from "@tiptap/extension-placeholder"; import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget"; import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; import { UploadImage } from "@plane/editor-core"; -export const DocumentEditorExtensions = ( - uploadFile: UploadImage, - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void -) => [ - 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 ""; - } +type TArguments = { + uploadFile: UploadImage; + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; +}; - return "Press '/' for commands..."; - }, - includeChildren: true, - }), +export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ + SlashCommand(uploadFile), + DragAndDrop(setHideDragHandle), IssueWidgetPlaceholder(), ]; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index eb54a204b..3c36ed11c 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,187 +1,104 @@ -"use client"; import React, { useState } from "react"; -import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core"; +import { + UploadImage, + DeleteImage, + RestoreImage, + getEditorClassNames, + useEditor, + EditorRefApi, + IMentionHighlight, + IMentionSuggestion, +} from "@plane/editor-core"; import { DocumentEditorExtensions } from "src/ui/extensions"; -import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; -import { EditorHeader } from "src/ui/components/editor-header"; -import { useEditorMarkings } from "src/hooks/use-editor-markings"; -import { SummarySideBar } from "src/ui/components/summary-side-bar"; -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 { FixedMenu } from "src"; interface IDocumentEditor { - // document info - documentDetails: DocumentDetails; - value: string; - rerenderOnPropsChange?: { - id: string; - description_html: string; + initialValue: string; + value?: string; + fileHandler: { + cancel: () => void; + delete: DeleteImage; + upload: UploadImage; + restore: RestoreImage; + }; + handleEditorReady?: (value: boolean) => void; + containerClassName?: string; + editorClassName?: string; + onChange: (json: object, html: string) => void; + forwardedRef?: React.MutableRefObject; + mentionHandler: { + highlights: () => Promise; + suggestions: () => Promise; }; - - // file operations - uploadFile: UploadImage; - deleteFile: DeleteImage; - restoreFile: RestoreImage; - cancelUploadImage: () => any; - - // editor state managers - onActionCompleteHandler: (action: { - title: string; - message: string; - type: "success" | "error" | "warning" | "info"; - }) => void; - customClassName?: string; - editorContentCustomClassNames?: string; - onChange: (json: any, html: string) => void; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - setShouldShowAlert?: (showAlert: boolean) => void; - forwardedRef?: any; - updatePageTitle: (title: string) => void; - debouncedUpdatesEnabled?: boolean; - isSubmitting: "submitting" | "submitted" | "saved"; - - // embed configuration - duplicationConfig?: IDuplicationConfig; - pageLockConfig?: IPageLockConfig; - pageArchiveConfig?: IPageArchiveConfig; - tabIndex?: number; -} -interface DocumentEditorProps extends IDocumentEditor { - forwardedRef?: React.Ref; + placeholder?: string | ((isFocused: boolean) => string); } -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; - setEditorValueAtCursorPosition: (content: string) => void; -} - -const DocumentEditor = ({ - documentDetails, - onChange, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, - editorContentCustomClassNames, - value, - uploadFile, - deleteFile, - restoreFile, - isSubmitting, - customClassName, - forwardedRef, - duplicationConfig, - pageLockConfig, - pageArchiveConfig, - updatePageTitle, - cancelUploadImage, - onActionCompleteHandler, - rerenderOnPropsChange, - tabIndex, -}: IDocumentEditor) => { - const { markings, updateMarkings } = useEditorMarkings(); - const [sidePeekVisible, setSidePeekVisible] = useState(true); - const router = useRouter(); - - const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); +const DocumentEditor = (props: IDocumentEditor) => { + const { + onChange, + initialValue, + value, + fileHandler, + containerClassName, + editorClassName = "", + mentionHandler, + handleEditorReady, + forwardedRef, + tabIndex, + placeholder, + } = props; + // states + const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = 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); }; - + // use editor const editor = useEditor({ onChange(json, html) { - updateMarkings(json); onChange(json, html); }, - onStart(json) { - updateMarkings(json); - }, - debouncedUpdatesEnabled, - restoreFile, - setIsSubmitting, - setShouldShowAlert, + editorClassName, + restoreFile: fileHandler.restore, + uploadFile: fileHandler.upload, + deleteFile: fileHandler.delete, + cancelUploadImage: fileHandler.cancel, + initialValue, value, - uploadFile, - deleteFile, - cancelUploadImage, - rerenderOnPropsChange, + handleEditorReady, forwardedRef, - extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting), + mentionHandler, + extensions: DocumentEditorExtensions({ + uploadFile: fileHandler.upload, + setHideDragHandle: setHideDragHandleFunction, + }), + placeholder, + tabIndex, }); - if (!editor) { - return null; - } - - const KanbanMenuOptions = getMenuOptions({ - editor: editor, - router: router, - duplicationConfig: duplicationConfig, - pageLockConfig: pageLockConfig, - pageArchiveConfig: pageArchiveConfig, - onActionCompleteHandler, - }); - - const editorClassNames = getEditorClassNames({ + const editorContainerClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, - customClassName, + containerClassName, }); if (!editor) return null; return ( -
- setSidePeekVisible(val)} - markings={markings} - uploadFile={uploadFile} - setIsSubmitting={setIsSubmitting} - isLocked={!pageLockConfig ? false : pageLockConfig.is_locked} - isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived} - archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at} - documentDetails={documentDetails} - isSubmitting={isSubmitting} - /> -
- {uploadFile && } -
-
-
- -
-
- -
-
-
-
+ ); }; -const DocumentEditorWithRef = React.forwardRef((props, ref) => ( - +const DocumentEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; diff --git a/packages/editor/document-editor/src/ui/menu/block-menu.tsx b/packages/editor/document-editor/src/ui/menu/block-menu.tsx new file mode 100644 index 000000000..6fc9a87fe --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/block-menu.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useRef } from "react"; +import tippy, { Instance } from "tippy.js"; +import { Copy, LucideIcon, Trash2 } from "lucide-react"; +import { Editor } from "@tiptap/react"; + +interface BlockMenuProps { + editor: Editor; +} + +export default function BlockMenu(props: BlockMenuProps) { + const { editor } = props; + const menuRef = useRef(null); + const popup = useRef(null); + + const handleClickDragHandle = useCallback((event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) { + event.preventDefault(); + + popup.current?.setProps({ + getReferenceClientRect: () => target.getBoundingClientRect(), + }); + + popup.current?.show(); + return; + } + + popup.current?.hide(); + return; + }, []); + + useEffect(() => { + if (menuRef.current) { + menuRef.current.remove(); + menuRef.current.style.visibility = "visible"; + + // @ts-expect-error - tippy types are incorrect + popup.current = tippy(document.body, { + getReferenceClientRect: null, + content: menuRef.current, + appendTo: () => document.querySelector(".frame-renderer"), + trigger: "manual", + interactive: true, + arrow: false, + placement: "left-start", + animation: "shift-away", + maxWidth: 500, + hideOnClick: true, + onShown: () => { + menuRef.current?.focus(); + }, + }); + } + + return () => { + popup.current?.destroy(); + popup.current = null; + }; + }, []); + + useEffect(() => { + const handleKeyDown = () => { + popup.current?.hide(); + }; + + const handleScroll = () => { + popup.current?.hide(); + }; + document.addEventListener("click", handleClickDragHandle); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("scroll", handleScroll, true); // Using capture phase + + return () => { + document.removeEventListener("click", handleClickDragHandle); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("scroll", handleScroll, true); + }; + }, [handleClickDragHandle]); + + const MENU_ITEMS: { + icon: LucideIcon; + key: string; + label: string; + onClick: (e: React.MouseEvent) => void; + isDisabled?: boolean; + }[] = [ + { + icon: Trash2, + key: "delete", + label: "Delete", + onClick: (e) => { + editor.chain().deleteSelection().focus().run(); + popup.current?.hide(); + e.preventDefault(); + e.stopPropagation(); + }, + }, + { + icon: Copy, + key: "duplicate", + label: "Duplicate", + isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image", + onClick: (e) => { + e.preventDefault(); + e.stopPropagation(); + + try { + const { state } = editor; + const { selection } = state; + const firstChild = selection.content().content.firstChild; + const docSize = state.doc.content.size; + + if (!firstChild) { + throw new Error("No content selected or content is not duplicable."); + } + + // Directly use selection.to as the insertion position + const insertPos = selection.to; + + // Ensure the insertion position is within the document's bounds + if (insertPos < 0 || insertPos > docSize) { + throw new Error("The insertion position is invalid or outside the document."); + } + + const contentToInsert = firstChild.toJSON(); + + // Insert the content at the calculated position + editor + .chain() + .insertContentAt(insertPos, contentToInsert, { + updateSelection: true, + }) + .focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false }) + .run(); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } + } + + popup.current?.hide(); + }, + }, + ]; + + return ( +
+ {MENU_ITEMS.map((item) => { + // Skip rendering the button if it should be disabled + if (item.isDisabled && item.key === "duplicate") { + return null; + } + + return ( + + ); + })} +
+ ); +} diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx deleted file mode 100644 index 397e8c576..000000000 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { - BoldItem, - BulletListItem, - isCellSelection, - cn, - CodeItem, - ImageItem, - ItalicItem, - NumberedListItem, - QuoteItem, - StrikeThroughItem, - TableItem, - UnderLineItem, - HeadingOneItem, - HeadingTwoItem, - HeadingThreeItem, - findTableAncestor, - EditorMenuItem, - UploadImage, -} from "@plane/editor-core"; - -export type BubbleMenuItem = EditorMenuItem; - -type EditorBubbleMenuProps = { - editor: Editor; - uploadFile: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; -}; - -export const FixedMenu = (props: EditorBubbleMenuProps) => { - const { editor, uploadFile, setIsSubmitting } = props; - - const basicMarkItems: BubbleMenuItem[] = [ - HeadingOneItem(editor), - HeadingTwoItem(editor), - HeadingThreeItem(editor), - BoldItem(editor), - ItalicItem(editor), - UnderLineItem(editor), - StrikeThroughItem(editor), - ]; - - const listItems: BubbleMenuItem[] = [BulletListItem(editor), NumberedListItem(editor)]; - - const userActionItems: BubbleMenuItem[] = [QuoteItem(editor), CodeItem(editor)]; - - function getComplexItems(): BubbleMenuItem[] { - const items: BubbleMenuItem[] = [TableItem(editor)]; - - items.push(ImageItem(editor, uploadFile, setIsSubmitting)); - return items; - } - - const complexItems: BubbleMenuItem[] = getComplexItems(); - - return ( -
-
- {basicMarkItems.map((item) => ( - - ))} -
-
- {listItems.map((item) => ( - - ))} -
-
- {userActionItems.map((item) => ( - - ))} -
-
- {complexItems.map((item) => ( - - ))} -
-
- ); -}; diff --git a/packages/editor/document-editor/src/ui/menu/index.tsx b/packages/editor/document-editor/src/ui/menu/index.tsx deleted file mode 100644 index 1c411fabf..000000000 --- a/packages/editor/document-editor/src/ui/menu/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { FixedMenu } from "./fixed-menu"; diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 22099281e..0e75c2db4 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -1,132 +1,53 @@ -import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; -import { useRouter } from "next/router"; -import { useState, forwardRef, useEffect } from "react"; -import { EditorHeader } from "src/ui/components/editor-header"; +import { forwardRef, MutableRefObject } from "react"; +import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core"; +// components import { PageRenderer } from "src/ui/components/page-renderer"; -import { SummarySideBar } from "src/ui/components/summary-side-bar"; -import { useEditorMarkings } from "src/hooks/use-editor-markings"; -import { DocumentDetails } from "src/types/editor-types"; -import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions"; -import { getMenuOptions } from "src/utils/menu-options"; import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget"; interface IDocumentReadOnlyEditor { - value: string; - rerenderOnPropsChange?: { - id: string; - description_html: string; - }; - noBorder: boolean; - borderOnFocus: boolean; - customClassName: string; - documentDetails: DocumentDetails; - pageLockConfig?: IPageLockConfig; - pageArchiveConfig?: IPageArchiveConfig; - pageDuplicationConfig?: IDuplicationConfig; - onActionCompleteHandler: (action: { - title: string; - message: string; - type: "success" | "error" | "warning" | "info"; - }) => void; + initialValue: string; + containerClassName: string; + editorClassName?: string; tabIndex?: number; + handleEditorReady?: (value: boolean) => void; + mentionHandler: { + highlights: () => Promise; + }; + forwardedRef?: React.MutableRefObject; } -interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { - forwardedRef?: React.Ref; -} - -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; -} - -const DocumentReadOnlyEditor = ({ - noBorder, - borderOnFocus, - customClassName, - value, - documentDetails, - forwardedRef, - pageDuplicationConfig, - pageLockConfig, - pageArchiveConfig, - rerenderOnPropsChange, - onActionCompleteHandler, - tabIndex, -}: DocumentReadOnlyEditorProps) => { - const router = useRouter(); - const [sidePeekVisible, setSidePeekVisible] = useState(true); - const { markings, updateMarkings } = useEditorMarkings(); - - const editor = useReadOnlyEditor({ - value, +const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { + const { + containerClassName, + editorClassName = "", + initialValue, forwardedRef, - rerenderOnPropsChange, + tabIndex, + handleEditorReady, + mentionHandler, + } = props; + const editor = useReadOnlyEditor({ + initialValue, + editorClassName, + mentionHandler, + forwardedRef, + handleEditorReady, extensions: [IssueWidgetPlaceholder()], }); - useEffect(() => { - if (editor) { - updateMarkings(editor.getJSON()); - } - }, [editor]); - if (!editor) { return null; } - const editorClassNames = getEditorClassNames({ - noBorder, - borderOnFocus, - customClassName, + const editorContainerClassName = getEditorClassNames({ + containerClassName, }); - const KanbanMenuOptions = getMenuOptions({ - editor: editor, - router: router, - pageArchiveConfig: pageArchiveConfig, - pageLockConfig: pageLockConfig, - duplicationConfig: pageDuplicationConfig, - onActionCompleteHandler, - }); - - return ( -
- -
-
- -
-
- Promise.resolve()} - readonly - editor={editor} - editorClassNames={editorClassNames} - documentDetails={documentDetails} - /> -
-
-
-
- ); + return ; }; -const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( - +const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( + } /> )); DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef"; diff --git a/packages/editor/document-editor/src/utils/date-utils.ts b/packages/editor/document-editor/src/utils/date-utils.ts new file mode 100644 index 000000000..63c20a974 --- /dev/null +++ b/packages/editor/document-editor/src/utils/date-utils.ts @@ -0,0 +1,26 @@ +function isNumber(value: any) { + return typeof value === "number"; +} + +/** + * This method returns a date from string of type yyyy-mm-dd + * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets + * @param date + * @returns date or undefined + */ +export const getDate = (date: string | Date | undefined | null): Date | undefined => { + try { + if (!date || date === "") return; + + if (typeof date !== "string" && !(date instanceof String)) return date; + const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); + const year = parseInt(yearString); + const month = parseInt(monthString); + const day = parseInt(dayString); + if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return; + + return new Date(year, month - 1, day); + } catch (e) { + return undefined; + } +}; diff --git a/packages/editor/document-editor/src/utils/menu-actions.ts b/packages/editor/document-editor/src/utils/menu-actions.ts deleted file mode 100644 index 24eda5a05..000000000 --- a/packages/editor/document-editor/src/utils/menu-actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Editor } from "@tiptap/core"; - -export const copyMarkdownToClipboard = (editor: Editor | null) => { - const markdownOutput = editor?.storage.markdown.getMarkdown(); - navigator.clipboard.writeText(markdownOutput); -}; - -export const CopyPageLink = () => { - if (window) { - navigator.clipboard.writeText(window.location.toString()); - } -}; diff --git a/packages/editor/document-editor/src/utils/menu-options.ts b/packages/editor/document-editor/src/utils/menu-options.ts deleted file mode 100644 index befed424d..000000000 --- a/packages/editor/document-editor/src/utils/menu-options.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { Archive, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock } from "lucide-react"; -import { NextRouter } from "next/router"; -import { IVerticalDropdownItemProps } from "src/ui/components/vertical-dropdown-menu"; -import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions"; -import { copyMarkdownToClipboard, CopyPageLink } from "src/utils/menu-actions"; - -export interface MenuOptionsProps { - editor: Editor; - router: NextRouter; - duplicationConfig?: IDuplicationConfig; - pageLockConfig?: IPageLockConfig; - pageArchiveConfig?: IPageArchiveConfig; - onActionCompleteHandler: (action: { - title: string; - message: string; - type: "success" | "error" | "warning" | "info"; - }) => void; -} - -export const getMenuOptions = ({ - editor, - router, - duplicationConfig, - pageLockConfig, - pageArchiveConfig, - onActionCompleteHandler, -}: MenuOptionsProps) => { - const KanbanMenuOptions: IVerticalDropdownItemProps[] = [ - { - key: 1, - type: "copy_markdown", - Icon: ClipboardIcon, - action: () => { - onActionCompleteHandler({ - title: "Markdown Copied", - message: "Page Copied as Markdown", - type: "success", - }); - copyMarkdownToClipboard(editor); - }, - label: "Copy markdown", - }, - // { - // key: 2, - // type: "close_page", - // Icon: XCircle, - // action: () => router.back(), - // label: "Close page", - // }, - { - key: 3, - type: "copy_page_link", - Icon: Link, - action: () => { - onActionCompleteHandler({ - title: "Link Copied", - message: "Link to the page has been copied to clipboard", - type: "success", - }); - CopyPageLink(); - }, - label: "Copy page link", - }, - ]; - - // If duplicateConfig is given, page duplication will be allowed - if (duplicationConfig) { - KanbanMenuOptions.push({ - key: KanbanMenuOptions.length++, - type: "duplicate_page", - Icon: Copy, - action: () => { - duplicationConfig - .action() - .then(() => { - onActionCompleteHandler({ - title: "Page Copied", - message: "Page has been copied as 'Copy of' followed by page title", - type: "success", - }); - }) - .catch(() => { - onActionCompleteHandler({ - title: "Copy Failed", - message: "Sorry, page cannot be copied, please try again later.", - type: "error", - }); - }); - }, - label: "Make a copy", - }); - } - // If Lock Configuration is given then, lock page option will be available in the kanban menu - if (pageLockConfig) { - KanbanMenuOptions.push({ - key: KanbanMenuOptions.length++, - type: pageLockConfig.is_locked ? "unlock_page" : "lock_page", - Icon: pageLockConfig.is_locked ? Unlock : Lock, - label: pageLockConfig.is_locked ? "Unlock page" : "Lock page", - action: () => { - const state = pageLockConfig.is_locked ? "Unlocked" : "Locked"; - pageLockConfig - .action() - .then(() => { - onActionCompleteHandler({ - title: `Page ${state}`, - message: `Page has been ${state}, no one will be able to change the state of lock except you.`, - type: "success", - }); - }) - .catch(() => { - onActionCompleteHandler({ - title: `Page cannot be ${state}`, - message: `Sorry, page cannot be ${state}, please try again later`, - type: "error", - }); - }); - }, - }); - } - - // Archiving will be visible in the menu bar config once the pageArchiveConfig is given. - if (pageArchiveConfig) { - KanbanMenuOptions.push({ - key: KanbanMenuOptions.length++, - type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page", - Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive, - label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page", - action: () => { - const state = pageArchiveConfig.is_archived ? "Unarchived" : "Archived"; - pageArchiveConfig - .action() - .then(() => { - onActionCompleteHandler({ - title: `Page ${state}`, - message: `Page has been ${state}, you can checkout all archived tab and can restore the page later.`, - type: "success", - }); - }) - .catch(() => { - onActionCompleteHandler({ - title: `Page cannot be ${state}`, - message: `Sorry, page cannot be ${state}, please try again later.`, - type: "success", - }); - }); - }, - }); - } - - return KanbanMenuOptions; -}; diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index f95aa4d7e..7dee7fb14 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.16.0", + "version": "0.18.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", @@ -29,6 +29,7 @@ }, "dependencies": { "@plane/editor-core": "*", + "@plane/ui": "*", "@tiptap/core": "^2.1.13", "@tiptap/pm": "^2.1.13", "@tiptap/react": "^2.1.13", diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index ce4088413..e9ef9c06e 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -1,9 +1,18 @@ import { Extension } from "@tiptap/core"; -import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state"; -// @ts-ignore +import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { Fragment, Slice, Node } from "@tiptap/pm/model"; +// @ts-expect-error __serializeForClipboard's is not exported import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; -import React from "react"; + +export interface DragHandleOptions { + dragHandleWidth: number; + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; + scrollThreshold: { + up: number; + down: number; + }; +} function createDragHandleElement(): HTMLElement { const dragHandleElement = document.createElement("div"); @@ -29,13 +38,8 @@ function createDragHandleElement(): HTMLElement { return dragHandleElement; } -export interface DragHandleOptions { - dragHandleWidth: number; - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; -} - function absoluteRect(node: Element) { - const data = node?.getBoundingClientRect(); + const data = node.getBoundingClientRect(); return { top: data.top, @@ -54,34 +58,27 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) { [ "li", "p:not(:first-child)", - "pre", + ".code-block", "blockquote", "h1, h2, h3", + "table", "[data-type=horizontalRule]", - ".tableWrapper", ].join(", ") ) ); } -function nodePosAtDOM(node: Element, view: EditorView) { - const boundingRect = node?.getBoundingClientRect(); +function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) { + const boundingRect = node.getBoundingClientRect(); - if (node.nodeName === "IMG") { - return view.posAtCoords({ - left: boundingRect.left + 1, - top: boundingRect.top + 1, - })?.pos; - } + return view.posAtCoords({ + left: boundingRect.left + 50 + options.dragHandleWidth, + top: boundingRect.top + 1, + })?.inside; +} - if (node.nodeName === "PRE") { - return ( - view.posAtCoords({ - left: boundingRect.left + 1, - top: boundingRect.top + 1, - })?.pos! - 1 - ); - } +function nodePosAtDOMForBlockquotes(node: Element, view: EditorView) { + const boundingRect = node.getBoundingClientRect(); return view.posAtCoords({ left: boundingRect.left + 1, @@ -89,23 +86,67 @@ function nodePosAtDOM(node: Element, view: EditorView) { })?.inside; } +function calcNodePos(pos: number, view: EditorView) { + const maxPos = view.state.doc.content.size; + const safePos = Math.max(0, Math.min(pos, maxPos)); + const $pos = view.state.doc.resolve(safePos); + + if ($pos.depth > 1) { + const newPos = $pos.before($pos.depth); + return Math.max(0, Math.min(newPos, maxPos)); + } + return safePos; +} + function DragHandle(options: DragHandleOptions) { + let listType = ""; function handleDragStart(event: DragEvent, view: EditorView) { view.focus(); if (!event.dataTransfer) return; const node = nodeDOMAtCoords({ - x: event.clientX + options.dragHandleWidth + 50, + x: event.clientX + 50 + options.dragHandleWidth, y: event.clientY, }); if (!(node instanceof Element)) return; - const nodePos = nodePosAtDOM(node, view); - if (nodePos === null || nodePos === undefined || nodePos < 0) return; + let draggedNodePos = nodePosAtDOM(node, view, options); + if (draggedNodePos == null || draggedNodePos < 0) return; + draggedNodePos = calcNodePos(draggedNodePos, view); - view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))); + const { from, to } = view.state.selection; + const diff = from - to; + + const fromSelectionPos = calcNodePos(from, view); + let differentNodeSelected = false; + + const nodePos = view.state.doc.resolve(fromSelectionPos); + + // Check if nodePos points to the top level node + if (nodePos.node().type.name === "doc") differentNodeSelected = true; + else { + const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before()); + // Check if the node where the drag event started is part of the current selection + differentNodeSelected = !( + draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos + ); + } + + if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) { + const endSelection = NodeSelection.create(view.state.doc, to - 1); + const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos); + view.dispatch(view.state.tr.setSelection(multiNodeSelection)); + } else { + const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + + // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL + if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { + listType = node.parentElement!.tagName; + } const slice = view.state.selection.content(); const { dom, text } = __serializeForClipboard(view, slice); @@ -123,8 +164,6 @@ function DragHandle(options: DragHandleOptions) { function handleClick(event: MouseEvent, view: EditorView) { view.focus(); - view.dom.classList.remove("dragging"); - const node = nodeDOMAtCoords({ x: event.clientX + 50 + options.dragHandleWidth, y: event.clientY, @@ -132,11 +171,32 @@ function DragHandle(options: DragHandleOptions) { if (!(node instanceof Element)) return; - const nodePos = nodePosAtDOM(node, view); + if (node.matches("blockquote")) { + let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view); + if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return; - if (nodePos === null || nodePos === undefined || nodePos < 0) return; + const docSize = view.state.doc.content.size; + nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize)); - view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))); + if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) { + const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + return; + } + + let nodePos = nodePosAtDOM(node, view, options); + + if (nodePos === null || nodePos === undefined) return; + + // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied + nodePos = calcNodePos(nodePos, view); + + // Use NodeSelection to select the node at the calculated position + const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + + // Dispatch the transaction to update the selection + view.dispatch(view.state.tr.setSelection(nodeSelection)); } let dragHandleElement: HTMLElement | null = null; @@ -166,11 +226,15 @@ function DragHandle(options: DragHandleOptions) { handleClick(e, view); }); - dragHandleElement.addEventListener("dragstart", (e) => { - handleDragStart(e, view); - }); - dragHandleElement.addEventListener("click", (e) => { - handleClick(e, view); + dragHandleElement.addEventListener("drag", (e) => { + hideDragHandle(); + const a = document.querySelector(".frame-renderer"); + if (!a) return; + if (e.clientY < options.scrollThreshold.up) { + a.scrollBy({ top: -70, behavior: "smooth" }); + } else if (window.innerHeight - e.clientY < options.scrollThreshold.down) { + a.scrollBy({ top: 70, behavior: "smooth" }); + } }); hideDragHandle(); @@ -192,11 +256,11 @@ function DragHandle(options: DragHandleOptions) { } const node = nodeDOMAtCoords({ - x: event.clientX + options.dragHandleWidth, + x: event.clientX + 50 + options.dragHandleWidth, y: event.clientY, }); - if (!(node instanceof Element)) { + if (!(node instanceof Element) || node.matches("ul, ol")) { hideDragHandle(); return; } @@ -207,32 +271,76 @@ function DragHandle(options: DragHandleOptions) { const rect = absoluteRect(node); - rect.top += (lineHeight - 24) / 2; + rect.top += (lineHeight - 20) / 2; rect.top += paddingTop; + // Li markers if (node.matches("ul:not([data-type=taskList]) li, ol li")) { - rect.left -= options.dragHandleWidth; + rect.top += 4; + rect.left -= 18; } + rect.width = options.dragHandleWidth; if (!dragHandleElement) return; dragHandleElement.style.left = `${rect.left - rect.width}px`; - dragHandleElement.style.top = `${rect.top + 3}px`; + dragHandleElement.style.top = `${rect.top}px`; showDragHandle(); }, keydown: () => { hideDragHandle(); }, - wheel: () => { + mousewheel: () => { hideDragHandle(); }, - // dragging className is used for CSS - dragstart: (view) => { + dragenter: (view) => { view.dom.classList.add("dragging"); + hideDragHandle(); }, - drop: (view) => { + drop: (view, event) => { view.dom.classList.remove("dragging"); + hideDragHandle(); + let droppedNode: Node | null = null; + const dropPos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (!dropPos) return; + + if (view.state.selection instanceof NodeSelection) { + droppedNode = view.state.selection.node; + } + if (!droppedNode) return; + + const resolvedPos = view.state.doc.resolve(dropPos.pos); + let isDroppedInsideList = false; + + // Traverse up the document tree to find if we're inside a list item + for (let i = resolvedPos.depth; i > 0; i--) { + if (resolvedPos.node(i).type.name === "listItem") { + isDroppedInsideList = true; + break; + } + } + + // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside
    tag otherwise ol list items will be transformed into ul list item when dropped + if ( + view.state.selection instanceof NodeSelection && + view.state.selection.node.type.name === "listItem" && + !isDroppedInsideList && + listType == "OL" + ) { + const text = droppedNode.textContent; + if (!text) return; + const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text)); + const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph); + + const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); + const slice = new Slice(Fragment.from(newList), 0, 0); + view.dragging = { slice, move: event.ctrlKey }; + } }, dragend: (view) => { view.dom.classList.remove("dragging"); @@ -250,6 +358,7 @@ export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () return [ DragHandle({ dragHandleWidth: 24, + scrollThreshold: { up: 300, down: 100 }, setHideDragHandle, }), ]; diff --git a/packages/editor/extensions/src/extensions/index.ts b/packages/editor/extensions/src/extensions/index.ts new file mode 100644 index 000000000..9ceeb6493 --- /dev/null +++ b/packages/editor/extensions/src/extensions/index.ts @@ -0,0 +1,2 @@ +export * from "./drag-drop"; +export * from "./slash-commands"; diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index f37d18c68..752fbe63c 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -54,7 +54,20 @@ const Command = Extension.create({ props.command({ editor, range }); }, allow({ editor }: { editor: Editor }) { - return !editor.isActive("table"); + const { selection } = editor.state; + + const parentNode = selection.$from.node(selection.$from.depth); + const blockType = parentNode.type.name; + + if (blockType === "codeBlock") { + return false; + } + + if (editor.isActive("table")) { + return false; + } + + return true; }, allowSpaces: true, }, @@ -71,11 +84,7 @@ const Command = Extension.create({ }); const getSuggestionItems = - ( - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, - additionalOptions?: Array - ) => + (uploadFile: UploadImage, additionalOptions?: Array) => ({ query }: { query: string }) => { let slashCommands: ISlashCommandItem[] = [ { @@ -186,7 +195,7 @@ const getSuggestionItems = searchTerms: ["img", "photo", "picture", "media"], icon: , command: ({ editor, range }: CommandProps) => { - insertImageCommand(editor, uploadFile, setIsSubmitting, range); + insertImageCommand(editor, uploadFile, null, range); }, }, { @@ -238,14 +247,15 @@ export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { }; const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => { + // states const [selectedIndex, setSelectedIndex] = useState(0); + // refs + const commandListContainer = useRef(null); const selectItem = useCallback( (index: number) => { const item = items[index]; - if (item) { - command(item); - } + if (item) command(item); }, [command, items] ); @@ -280,8 +290,6 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a setSelectedIndex(0); }, [items]); - const commandListContainer = useRef(null); - useLayoutEffect(() => { const container = commandListContainer?.current; @@ -290,47 +298,53 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a if (item && container) updateScrollView(container, item); }, [selectedIndex]); - return items.length > 0 ? ( + if (items.length <= 0) return null; + + return (
    {items.map((item, index) => ( ))}
    - ) : null; + ); }; -const renderItems = () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; +interface CommandListInstance { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +} +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; return { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { component = new ReactRenderer(CommandList, { props, - // @ts-ignore editor: props.editor, }); - // @ts-ignore + const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container"); + + // @ts-expect-error Tippy overloads are messed up popup = tippy("body", { getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector("#editor-container"), + appendTo: tippyContainer, content: component.element, showOnCreate: true, interactive: true, @@ -353,8 +367,10 @@ const renderItems = () => { return true; } - // @ts-ignore - return component?.ref?.onKeyDown(props); + if (component?.ref?.onKeyDown(props)) { + return true; + } + return false; }, onExit: () => { popup?.[0].destroy(); @@ -363,14 +379,10 @@ const renderItems = () => { }; }; -export const SlashCommand = ( - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, - additionalOptions?: Array -) => +export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array) => Command.configure({ suggestion: { - items: getSuggestionItems(uploadFile, setIsSubmitting, additionalOptions), + items: getSuggestionItems(uploadFile, additionalOptions), render: renderItems, }, }); diff --git a/packages/editor/extensions/src/index.ts b/packages/editor/extensions/src/index.ts index c0532c594..440de1351 100644 --- a/packages/editor/extensions/src/index.ts +++ b/packages/editor/extensions/src/index.ts @@ -1,4 +1,3 @@ import "src/styles/drag-drop.css"; -export { SlashCommand } from "src/extensions/slash-commands"; -export { DragAndDrop } from "src/extensions/drag-drop"; +export { DragAndDrop, SlashCommand } from "src/extensions"; diff --git a/packages/editor/extensions/src/styles/drag-drop.css b/packages/editor/extensions/src/styles/drag-drop.css index d95a8654b..d46d26ecc 100644 --- a/packages/editor/extensions/src/styles/drag-drop.css +++ b/packages/editor/extensions/src/styles/drag-drop.css @@ -1,25 +1,30 @@ +/* drag handle */ .drag-handle { position: fixed; opacity: 1; transition: opacity ease-in 0.2s; - height: 18px; + height: 20px; width: 15px; display: grid; place-items: center; - z-index: 10; + z-index: 5; cursor: grab; border-radius: 2px; - background-color: rgb(var(--color-background-90)); -} - -.drag-handle:hover { - background-color: rgb(var(--color-background-80)); transition: background-color 0.2s; -} -.drag-handle.hidden { - opacity: 0; - pointer-events: none; + &:hover { + background-color: rgba(var(--color-background-80)); + } + + &:active { + background-color: rgba(var(--color-background-80)); + cursor: grabbing; + } + + &.hidden { + opacity: 0; + pointer-events: none; + } } @media screen and (max-width: 600px) { @@ -32,7 +37,6 @@ .drag-handle-container { height: 15px; width: 15px; - cursor: grab; display: grid; place-items: center; } @@ -46,8 +50,46 @@ } .drag-handle-dot { - height: 2.75px; - width: 3px; - background-color: rgba(var(--color-text-200)); + height: 2.5px; + width: 2.5px; + background-color: rgba(var(--color-text-300)); border-radius: 50%; } +/* end drag handle */ + +.ProseMirror:not(.dragging) .ProseMirror-selectednode { + position: relative; + cursor: grab; + outline: none !important; + box-shadow: none; +} + +.ProseMirror:not(.dragging) .ProseMirror-selectednode::after { + content: ""; + position: absolute; + top: 0; + left: -5px; + height: 100%; + width: 100%; + background-color: rgba(var(--color-primary-100), 0.2); + border-radius: 4px; +} + +.ProseMirror img { + transition: filter 0.1s ease-in-out; + cursor: pointer; + + &:hover { + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + filter: brightness(90%); + } +} + +:not(.dragging) .ProseMirror-selectednode.table-wrapper { + padding: 4px 2px; + background-color: rgba(var(--color-primary-300), 0.1) !important; + box-shadow: rgba(var(--color-primary-100)) 0px 0px 0px 2px inset !important; +} diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index c84cb7a9b..e7a500eb3 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.16.0", + "version": "0.18.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/lite-text-editor/src/index.ts b/packages/editor/lite-text-editor/src/index.ts index c37d45039..3ca8c71db 100644 --- a/packages/editor/lite-text-editor/src/index.ts +++ b/packages/editor/lite-text-editor/src/index.ts @@ -1,3 +1,7 @@ export { LiteTextEditor, LiteTextEditorWithRef } from "src/ui"; -export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "src/ui/read-only"; +export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "src/ui/read-only"; export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core"; + +export type { ILiteTextEditor } from "src/ui"; +export type { ILiteTextReadOnlyEditor } from "src/ui/read-only"; +export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 7986e0c6b..71846eca7 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -8,124 +8,83 @@ import { EditorContentWrapper, getEditorClassNames, useEditor, + IMentionHighlight, + EditorRefApi, } from "@plane/editor-core"; -import { FixedMenu } from "src/ui/menus/fixed-menu"; import { LiteTextEditorExtensions } from "src/ui/extensions"; -interface ILiteTextEditor { - value: string; - uploadFile: UploadImage; - deleteFile: DeleteImage; - restoreFile: RestoreImage; - - noBorder?: boolean; - borderOnFocus?: boolean; - customClassName?: string; - editorContentCustomClassNames?: string; - onChange?: (json: any, html: string) => void; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - setShouldShowAlert?: (showAlert: boolean) => void; - forwardedRef?: any; - debouncedUpdatesEnabled?: boolean; - commentAccessSpecifier?: { - accessValue: string; - onAccessChange: (accessKey: string) => void; - showAccessSpecifier: boolean; - commentAccess: { - icon: any; - key: string; - label: "Private" | "Public"; - }[]; +export interface ILiteTextEditor { + initialValue: string; + value?: string | null; + fileHandler: { + cancel: () => void; + delete: DeleteImage; + upload: UploadImage; + restore: RestoreImage; }; + containerClassName?: string; + editorClassName?: string; + onChange?: (json: object, html: string) => void; + forwardedRef?: React.MutableRefObject; onEnterKeyPress?: (e?: any) => void; - cancelUploadImage?: () => any; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; - submitButton?: React.ReactNode; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; tabIndex?: number; + placeholder?: string | ((isFocused: boolean) => string); } -interface LiteTextEditorProps extends ILiteTextEditor { - forwardedRef?: React.Ref; -} - -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; -} - -const LiteTextEditor = (props: LiteTextEditorProps) => { +const LiteTextEditor = (props: ILiteTextEditor) => { const { onChange, - cancelUploadImage, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, - editorContentCustomClassNames, + initialValue, + fileHandler, value, - uploadFile, - deleteFile, - restoreFile, - noBorder, - borderOnFocus, - customClassName, + containerClassName, + editorClassName = "", forwardedRef, - commentAccessSpecifier, onEnterKeyPress, - mentionHighlights, - mentionSuggestions, - submitButton, tabIndex, + mentionHandler, + placeholder = "Add comment...", } = props; const editor = useEditor({ onChange, - cancelUploadImage, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, + initialValue, value, - uploadFile, - deleteFile, - restoreFile, + editorClassName, + restoreFile: fileHandler.restore, + uploadFile: fileHandler.upload, + deleteFile: fileHandler.delete, + cancelUploadImage: fileHandler.cancel, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), - mentionHighlights, - mentionSuggestions, + mentionHandler, + placeholder, + tabIndex, }); - const editorClassNames = getEditorClassNames({ - noBorder, - borderOnFocus, - customClassName, + const editorContainerClassName = getEditorClassNames({ + noBorder: true, + borderOnFocus: false, + containerClassName, }); if (!editor) return null; return ( - +
    - -
    - -
    +
    ); }; -const LiteTextEditorWithRef = React.forwardRef((props, ref) => ( - +const LiteTextEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef"; diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx deleted file mode 100644 index 7ddc76843..000000000 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/icon.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; - -type Props = { - iconName: string; - className?: string; -}; - -export const Icon: React.FC = ({ iconName, className = "" }) => ( - {iconName} -); diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx deleted file mode 100644 index c6786698d..000000000 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { Editor } from "@tiptap/react"; - -import { - BoldItem, - BulletListItem, - cn, - CodeItem, - findTableAncestor, - ImageItem, - isCellSelection, - ItalicItem, - LucideIconType, - NumberedListItem, - QuoteItem, - StrikeThroughItem, - TableItem, - UnderLineItem, - UploadImage, -} from "@plane/editor-core"; -import { Tooltip } from "@plane/ui"; - -export interface BubbleMenuItem { - name: string; - isActive: () => boolean; - command: () => void; - icon: LucideIconType; -} - -type EditorBubbleMenuProps = { - editor: Editor; - commentAccessSpecifier?: { - accessValue: string; - onAccessChange: (accessKey: string) => void; - showAccessSpecifier: boolean; - commentAccess: - | { - icon: any; - key: string; - label: "Private" | "Public"; - }[] - | undefined; - }; - uploadFile: UploadImage; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - submitButton: React.ReactNode; -}; - -export const FixedMenu = (props: EditorBubbleMenuProps) => { - const basicTextFormattingItems: BubbleMenuItem[] = [ - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), - ]; - - const listFormattingItems: BubbleMenuItem[] = [BulletListItem(props.editor), NumberedListItem(props.editor)]; - - const userActionItems: BubbleMenuItem[] = [QuoteItem(props.editor), CodeItem(props.editor)]; - - function getComplexItems(): BubbleMenuItem[] { - const items: BubbleMenuItem[] = [TableItem(props.editor)]; - - items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); - - return items; - } - - const complexItems: BubbleMenuItem[] = getComplexItems(); - - const handleAccessChange = (accessKey: string) => { - props.commentAccessSpecifier?.onAccessChange(accessKey); - }; - - return ( -
    - {props.commentAccessSpecifier && ( -
    - {props?.commentAccessSpecifier.commentAccess?.map((access) => ( - - - - ))} -
    - )} -
    -
    -
    - {basicTextFormattingItems.map((item) => ( - {item.name}}> - - - ))} -
    -
    - {listFormattingItems.map((item) => ( - {item.name}}> - - - ))} -
    -
    - {userActionItems.map((item) => ( - {item.name}}> - - - ))} -
    -
    - {complexItems.map((item) => ( - {item.name}}> - - - ))} -
    -
    -
    {props.submitButton}
    -
    -
    - ); -}; diff --git a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx index 9f81ba5d1..5ceb6956e 100644 --- a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx @@ -1,66 +1,59 @@ import * as React from "react"; -import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { + EditorContainer, + EditorContentWrapper, + EditorReadOnlyRefApi, + getEditorClassNames, + IMentionHighlight, + useReadOnlyEditor, +} from "@plane/editor-core"; -interface ICoreReadOnlyEditor { - value: string; - editorContentCustomClassNames?: string; - noBorder?: boolean; +export interface ILiteTextReadOnlyEditor { + initialValue: string; borderOnFocus?: boolean; - customClassName?: string; - mentionHighlights: string[]; + containerClassName?: string; + editorClassName?: string; + forwardedRef?: React.MutableRefObject; + mentionHandler: { + highlights: () => Promise; + }; tabIndex?: number; } -interface EditorCoreProps extends ICoreReadOnlyEditor { - forwardedRef?: React.Ref; -} - -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; -} - -const LiteReadOnlyEditor = ({ - editorContentCustomClassNames, - noBorder, - borderOnFocus, - customClassName, - value, +const LiteTextReadOnlyEditor = ({ + containerClassName, + editorClassName = "", + initialValue, forwardedRef, - mentionHighlights, + mentionHandler, tabIndex, -}: EditorCoreProps) => { +}: ILiteTextReadOnlyEditor) => { const editor = useReadOnlyEditor({ - value, + initialValue, + editorClassName, forwardedRef, - mentionHighlights, + mentionHandler, }); - const editorClassNames = getEditorClassNames({ - noBorder, - borderOnFocus, - customClassName, + const editorContainerClassName = getEditorClassNames({ + containerClassName, }); if (!editor) return null; return ( - +
    - +
    ); }; -const LiteReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - +const LiteTextReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); -LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; +LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; -export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef }; +export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef }; diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index 794650678..0561dfb7a 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.16.0", + "version": "0.18.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -32,7 +32,6 @@ "@plane/editor-core": "*", "@plane/editor-extensions": "*", "@tiptap/core": "^2.1.13", - "@tiptap/extension-placeholder": "^2.1.13", "lucide-react": "^0.294.0" }, "devDependencies": { diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts index eb745c45b..ad6f043a1 100644 --- a/packages/editor/rich-text-editor/src/index.ts +++ b/packages/editor/rich-text-editor/src/index.ts @@ -1,4 +1,8 @@ export { RichTextEditor, RichTextEditorWithRef } from "src/ui"; -export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "src/ui/read-only"; -export type { RichTextEditorProps, IRichTextEditor } from "src/ui"; -export type { IMentionHighlight, IMentionSuggestion } from "@plane/editor-core"; +export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "src/ui/read-only"; + +export type { IRichTextEditor } from "src/ui"; + +export type { IRichTextReadOnlyEditor } from "src/ui/read-only"; +export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core"; +export type { EditorRefApi, EditorReadOnlyRefApi } from "@plane/editor-core"; 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 3d1da6cda..406fb677f 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -1,28 +1,13 @@ 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, - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void -) => [ - SlashCommand(uploadFile, setIsSubmitting), +type TArguments = { + uploadFile: UploadImage; + dragDropEnabled?: boolean; + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; +}; + +export const RichTextEditorExtensions = ({ uploadFile, dragDropEnabled, setHideDragHandle }: TArguments) => [ + SlashCommand(uploadFile), dragDropEnabled === true && 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 ""; - } - if (node.type.name === "codeBlock") { - return "Type in your code here..."; - } - return "Press '/' for commands..."; - }, - includeChildren: true, - }), ]; diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 366fa471f..e82615b95 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -4,73 +4,58 @@ import { EditorContainer, EditorContentWrapper, getEditorClassNames, + IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage, useEditor, + EditorRefApi, } from "@plane/editor-core"; import * as React from "react"; import { RichTextEditorExtensions } from "src/ui/extensions"; import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { - value: string; - initialValue?: string; + initialValue: string; + value?: string | null; dragDropEnabled?: boolean; - uploadFile: UploadImage; - restoreFile: RestoreImage; - deleteFile: DeleteImage; - noBorder?: boolean; - borderOnFocus?: boolean; - cancelUploadImage?: () => any; - rerenderOnPropsChange?: { - id: string; - description_html: string; + fileHandler: { + cancel: () => void; + delete: DeleteImage; + upload: UploadImage; + restore: RestoreImage; }; - customClassName?: string; - editorContentCustomClassNames?: string; - onChange?: (json: any, html: string) => void; - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void; - setShouldShowAlert?: (showAlert: boolean) => void; - forwardedRef?: any; + id?: string; + containerClassName?: string; + editorClassName?: string; + onChange?: (json: object, html: string) => void; + forwardedRef?: React.MutableRefObject; debouncedUpdatesEnabled?: boolean; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHandler: { + highlights: () => Promise; + suggestions: () => Promise; + }; + placeholder?: string | ((isFocused: boolean) => string); tabIndex?: number; }; -export interface RichTextEditorProps extends IRichTextEditor { - forwardedRef?: React.Ref; -} +const RichTextEditor = (props: IRichTextEditor) => { + const { + onChange, + dragDropEnabled, + initialValue, + value, + fileHandler, + containerClassName, + editorClassName = "", + forwardedRef, + // rerenderOnPropsChange, + id = "", + placeholder, + tabIndex, + mentionHandler, + } = props; -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; - setEditorValueAtCursorPosition: (content: string) => void; -} - -const RichTextEditor = ({ - onChange, - dragDropEnabled, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, - editorContentCustomClassNames, - value, - initialValue, - uploadFile, - deleteFile, - noBorder, - cancelUploadImage, - borderOnFocus, - customClassName, - restoreFile, - forwardedRef, - mentionHighlights, - rerenderOnPropsChange, - mentionSuggestions, - tabIndex, -}: RichTextEditorProps) => { const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin @@ -80,50 +65,51 @@ const RichTextEditor = ({ }; const editor = useEditor({ + id, + editorClassName, + restoreFile: fileHandler.restore, + uploadFile: fileHandler.upload, + deleteFile: fileHandler.delete, + cancelUploadImage: fileHandler.cancel, onChange, - debouncedUpdatesEnabled, - setIsSubmitting, - setShouldShowAlert, + initialValue, value, - uploadFile, - cancelUploadImage, - deleteFile, - restoreFile, forwardedRef, - rerenderOnPropsChange, - extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction), - mentionHighlights, - mentionSuggestions, + // rerenderOnPropsChange, + extensions: RichTextEditorExtensions({ + uploadFile: fileHandler.upload, + dragDropEnabled, + setHideDragHandle: setHideDragHandleFunction, + }), + tabIndex, + mentionHandler, + placeholder, }); - const editorClassNames = getEditorClassNames({ - noBorder, - borderOnFocus, - customClassName, + const editorContainerClassName = getEditorClassNames({ + noBorder: true, + borderOnFocus: false, + containerClassName, }); - // React.useEffect(() => { - // if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); - // }, [editor, initialValue]); - // if (!editor) return null; return ( - + {editor && }
    - +
    ); }; -const RichTextEditorWithRef = React.forwardRef((props, ref) => ( - +const RichTextEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); RichTextEditorWithRef.displayName = "RichTextEditorWithRef"; diff --git a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx index 9aa308731..3220c477e 100644 --- a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx @@ -1,62 +1,54 @@ "use client"; -import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"; +import { + EditorReadOnlyRefApi, + EditorContainer, + EditorContentWrapper, + getEditorClassNames, + IMentionHighlight, + useReadOnlyEditor, +} from "@plane/editor-core"; import * as React from "react"; -interface IRichTextReadOnlyEditor { - value: string; - editorContentCustomClassNames?: string; - noBorder?: boolean; - borderOnFocus?: boolean; - customClassName?: string; - mentionHighlights?: string[]; +export interface IRichTextReadOnlyEditor { + initialValue: string; + containerClassName?: string; + editorClassName?: string; tabIndex?: number; + forwardedRef?: React.MutableRefObject; + mentionHandler: { + highlights: () => Promise; + }; } -interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor { - forwardedRef?: React.Ref; -} +const RichTextReadOnlyEditor = (props: IRichTextReadOnlyEditor) => { + const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props; -interface EditorHandle { - clearEditor: () => void; - setEditorValue: (content: string) => void; -} - -const RichReadOnlyEditor = ({ - editorContentCustomClassNames, - noBorder, - borderOnFocus, - customClassName, - value, - forwardedRef, - mentionHighlights, -}: RichTextReadOnlyEditorProps) => { const editor = useReadOnlyEditor({ - value, + initialValue, + editorClassName, forwardedRef, - mentionHighlights, + mentionHandler, }); - const editorClassNames = getEditorClassNames({ - noBorder, - borderOnFocus, - customClassName, + const editorContainerClassName = getEditorClassNames({ + containerClassName, }); if (!editor) return null; return ( - +
    - +
    ); }; -const RichReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - +const RichTextReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); -RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; +RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; -export { RichReadOnlyEditor, RichReadOnlyEditorWithRef }; +export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef }; diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index f7577ab87..b45b82e7e 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.16.0", + "version": "0.18.0", "main": "index.js", "license": "MIT", "devDependencies": {}, diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index d7e807b91..afc6db09f 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.16.0", + "version": "0.18.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index e0829e87b..0aadcc6d0 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.16.0", + "version": "0.18.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 9c9938845..48f356f42 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.16.0", + "version": "0.18.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts new file mode 100644 index 000000000..d347ecef1 --- /dev/null +++ b/packages/types/src/common.d.ts @@ -0,0 +1,11 @@ +export type TPaginationInfo = { + count: number; + extra_stats: string | null; + next_cursor: string; + next_page_results: boolean; + prev_cursor: string; + prev_page_results: boolean; + total_pages: number; + per_page?: number; + total_results: number; +}; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index c41ab279b..e93d6e444 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -6,8 +6,8 @@ export interface ICycle { backlog_issues: number; cancelled_issues: number; completed_issues: number; - created_at: Date; - created_by: string; + created_at?: string; + created_by?: string; description: string; distribution?: { assignees: TAssigneesDistribution[]; @@ -16,22 +16,22 @@ export interface ICycle { }; end_date: string | null; id: string; - is_favorite: boolean; - issue: string; + is_favorite?: boolean; name: string; owned_by_id: string; progress_snapshot: TProgressSnapshot; project_id: string; - status: TCycleGroups; + status?: TCycleGroups; sort_order: number; start_date: string | null; started_issues: number; - sub_issues: number; + sub_issues?: number; total_issues: number; unstarted_issues: number; - updated_at: Date; - updated_by: string; - assignee_ids: string[]; + updated_at?: string; + updated_by?: string; + archived_at: string | null; + assignee_ids?: string[]; view_props: { filters: IIssueFilterOptions; }; @@ -97,10 +97,6 @@ export type SelectCycleType = | (ICycle & { actionType: "edit" | "delete" | "create-issue" }) | undefined; -export type SelectIssue = - | (TIssue & { actionType: "edit" | "delete" | "create" }) - | null; - export type CycleDateCheckData = { start_date: string; end_date: string; diff --git a/packages/types/src/cycle/cycle_filters.d.ts b/packages/types/src/cycle/cycle_filters.d.ts index 470a20dd2..38f8a7549 100644 --- a/packages/types/src/cycle/cycle_filters.d.ts +++ b/packages/types/src/cycle/cycle_filters.d.ts @@ -13,6 +13,11 @@ export type TCycleFilters = { status?: string[] | null; }; +export type TCycleFiltersByState = { + default: TCycleFilters; + archived: TCycleFilters; +}; + export type TCycleStoredFilters = { display_filters?: TCycleDisplayFilters; filters?: TCycleFilters; diff --git a/packages/types/src/cycle/index.ts b/packages/types/src/cycle/index.d.ts similarity index 100% rename from packages/types/src/cycle/index.ts rename to packages/types/src/cycle/index.d.ts diff --git a/packages/types/src/dashboard.ts b/packages/types/src/dashboard.d.ts similarity index 96% rename from packages/types/src/dashboard.ts rename to packages/types/src/dashboard.d.ts index be7d7b3be..9abd1bf22 100644 --- a/packages/types/src/dashboard.ts +++ b/packages/types/src/dashboard.d.ts @@ -1,17 +1,9 @@ +import { EDurationFilters } from "./enums"; import { IIssueActivity, TIssuePriorities } from "./issues"; import { TIssue } from "./issues/issue"; import { TIssueRelationTypes } from "./issues/issue_relation"; import { TStateGroups } from "./state"; -enum EDurationFilters { - NONE = "none", - TODAY = "today", - THIS_WEEK = "this_week", - THIS_MONTH = "this_month", - THIS_YEAR = "this_year", - CUSTOM = "custom", -} - export type TWidgetKeys = | "overview_stats" | "assigned_issues" diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 259f13e9b..a4d098506 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -4,3 +4,23 @@ export enum EUserProjectRoles { MEMBER = 15, ADMIN = 20, } + +// project pages +export enum EPageAccess { + PUBLIC = 0, + PRIVATE = 1, +} + +export enum EDurationFilters { + NONE = "none", + TODAY = "today", + THIS_WEEK = "this_week", + THIS_MONTH = "this_month", + THIS_YEAR = "this_year", + CUSTOM = "custom", +} + +export enum EIssueCommentAccessSpecifier { + EXTERNAL = "EXTERNAL", + INTERNAL = "INTERNAL", +} diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts new file mode 100644 index 000000000..01a1dfce3 --- /dev/null +++ b/packages/types/src/inbox.d.ts @@ -0,0 +1,98 @@ +import { TPaginationInfo } from "./common"; +import { TIssuePriorities } from "./issues"; +import { TIssue } from "./issues/base"; + +enum EInboxIssueCurrentTab { + OPEN = "open", + CLOSED = "closed", +} + +enum EInboxIssueStatus { + PENDING = -2, + DECLINED = -1, + SNOOZED = 0, + ACCEPTED = 1, + DUPLICATE = 2, +} + +export type TInboxIssueCurrentTab = EInboxIssueCurrentTab; + +export type TInboxIssueStatus = EInboxIssueStatus; + +// filters +export type TInboxIssueFilterMemberKeys = "assignee" | "created_by"; + +export type TInboxIssueFilterDateKeys = "created_at" | "updated_at"; + +export type TInboxIssueFilter = { + [key in TInboxIssueFilterMemberKeys]: string[] | undefined; +} & { + [key in TInboxIssueFilterDateKeys]: string[] | undefined; +} & { + state: string[] | undefined; + status: TInboxIssueStatus[] | undefined; + priority: TIssuePriorities[] | undefined; + labels: string[] | undefined; +}; + +// sorting filters +export type TInboxIssueSortingKeys = "order_by" | "sort_by"; + +export type TInboxIssueSortingOrderByKeys = + | "issue__created_at" + | "issue__updated_at" + | "issue__sequence_id"; + +export type TInboxIssueSortingSortByKeys = "asc" | "desc"; + +export type TInboxIssueSorting = { + order_by: TInboxIssueSortingOrderByKeys | undefined; + sort_by: TInboxIssueSortingSortByKeys | undefined; +}; + +// filtering and sorting types for query params +export type TInboxIssueSortingOrderByQueryParamKeys = + | "issue__created_at" + | "-issue__created_at" + | "issue__updated_at" + | "-issue__updated_at" + | "issue__sequence_id" + | "-issue__sequence_id"; + +export type TInboxIssueSortingOrderByQueryParam = { + order_by: TInboxIssueSortingOrderByQueryParamKeys; +}; + +export type TInboxIssuesQueryParams = { + [key in keyof TInboxIssueFilter]: string; +} & TInboxIssueSortingOrderByQueryParam & { + per_page: number; + cursor: string; + }; + +// inbox issue types + +export type TInboxDuplicateIssueDetails = { + id: string; + sequence_id: string; + name: string; +}; + +export type TInboxIssue = { + id: string; + status: TInboxIssueStatus; + snoozed_till: Date | undefined; + duplicate_to: string | undefined; + source: string; + issue: TIssue; + created_by: string; + duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined; +}; + +export type TInboxIssuePaginationInfo = TPaginationInfo & { + total_results: number; +}; + +export type TInboxIssueWithPagination = TInboxIssuePaginationInfo & { + results: TInboxIssue[]; +}; diff --git a/packages/types/src/inbox/inbox-issue.d.ts b/packages/types/src/inbox/inbox-issue.d.ts deleted file mode 100644 index c7d33f75b..000000000 --- a/packages/types/src/inbox/inbox-issue.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -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-types.d.ts b/packages/types/src/inbox/inbox-types.d.ts deleted file mode 100644 index c3ec8461e..000000000 --- a/packages/types/src/inbox/inbox-types.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { TIssue } from "../issues/base"; -import type { IProjectLite } from "../project"; - -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; - snoozed_till: Date | null; - source: string; - status: -2 | -1 | 0 | 1 | 2; - }[]; -} - -export interface IInbox { - id: string; - project_detail: IProjectLite; - pending_issue_count: number; - created_at: Date; - updated_at: Date; - name: string; - description: string; - is_default: boolean; - created_by: string; - updated_by: string; - project: string; - view_props: { filters: IInboxFilterOptions }; - workspace: string; -} - -export interface IInboxFilterOptions { - priority?: string[] | null; - inbox_status?: number[] | null; -} - -export interface IInboxQueryParams { - priority: string | null; - inbox_status: string | null; -} diff --git a/packages/types/src/inbox/inbox.d.ts b/packages/types/src/inbox/inbox.d.ts deleted file mode 100644 index 1b4e23e0f..000000000 --- a/packages/types/src/inbox/inbox.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 6fd21a4fe..000000000 --- a/packages/types/src/inbox/root.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./inbox-issue"; -export * from "./inbox-types"; -export * from "./inbox"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 48d0c1448..4d98b8f7a 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -12,10 +12,7 @@ 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/root"; - +export * from "./inbox"; export * from "./analytics"; export * from "./calendar"; export * from "./notifications"; @@ -29,3 +26,5 @@ export * from "./auth"; export * from "./api_token"; export * from "./instance"; export * from "./app"; +export * from "./common"; +export * from "./pragmatic"; diff --git a/packages/types/src/issues/activity/issue_comment.d.ts b/packages/types/src/issues/activity/issue_comment.d.ts index 45d34be08..f361ea720 100644 --- a/packages/types/src/issues/activity/issue_comment.d.ts +++ b/packages/types/src/issues/activity/issue_comment.d.ts @@ -4,6 +4,7 @@ import { TIssueActivityIssueDetail, TIssueActivityUserDetail, } from "./base"; +import { EIssueCommentAccessSpecifier } from "../../enums"; export type TIssueComment = { id: string; @@ -20,14 +21,13 @@ export type TIssueComment = { 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"; + access: EIssueCommentAccessSpecifier; }; export type TIssueCommentMap = { diff --git a/packages/types/src/module/index.ts b/packages/types/src/module/index.d.ts similarity index 100% rename from packages/types/src/module/index.ts rename to packages/types/src/module/index.d.ts diff --git a/packages/types/src/module/module_filters.d.ts b/packages/types/src/module/module_filters.d.ts index 10d56c328..297c8046c 100644 --- a/packages/types/src/module/module_filters.d.ts +++ b/packages/types/src/module/module_filters.d.ts @@ -26,6 +26,11 @@ export type TModuleFilters = { target_date?: string[] | null; }; +export type TModuleFiltersByState = { + default: TModuleFilters; + archived: TModuleFilters; +}; + export type TModuleStoredFilters = { display_filters?: TModuleDisplayFilters; filters?: TModuleFilters; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 0af293e50..0019781ba 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -1,4 +1,11 @@ -import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types"; +import type { + TIssue, + IIssueFilterOptions, + ILinkDetails, + TAssigneesDistribution, + TCompletionChartDistribution, + TLabelsDistribution, +} from "@plane/types"; export type TModuleStatus = | "backlog" @@ -12,33 +19,34 @@ export interface IModule { backlog_issues: number; cancelled_issues: number; completed_issues: number; - created_at: Date; - created_by: string; + created_at: string; + created_by?: string; description: string; description_text: any; description_html: any; - distribution: { + distribution?: { assignees: TAssigneesDistribution[]; completion_chart: TCompletionChartDistribution; labels: TLabelsDistribution[]; }; id: string; lead_id: string | null; - link_module: ILinkDetails[]; + link_module?: ILinkDetails[]; member_ids: string[]; is_favorite: boolean; name: string; project_id: string; sort_order: number; - sub_issues: number; + sub_issues?: number; start_date: string | null; started_issues: number; - status: TModuleStatus; + status?: TModuleStatus; target_date: string | null; total_issues: number; unstarted_issues: number; - updated_at: Date; - updated_by: string; + updated_at: string; + updated_by?: string; + archived_at: string | null; view_props: { filters: IIssueFilterOptions; }; @@ -68,7 +76,3 @@ export type ModuleLink = { export type SelectModuleType = | (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined; - -export type SelectIssue = - | (TIssue & { actionType: "edit" | "delete" | "create" }) - | undefined; diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 652e2776f..571b75765 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -36,7 +36,7 @@ export interface IUserNotification { } export interface Data { - issue: IIssueLite; + issue: INotificationIssueLite; issue_activity: { actor: string; field: string; @@ -48,7 +48,7 @@ export interface Data { }; } -export interface IIssueLite { +export interface INotificationIssueLite { id: string; name: string; identifier: string; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index c9b3fb623..a1df4527e 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,64 +1,50 @@ -// types -import { - TIssue, - IIssueLabel, - IWorkspaceLite, - IProjectLite, -} from "@plane/types"; +import { EPageAccess } from "./enums"; -export interface IPage { - access: number; - archived_at: string | null; - blocks: IPageBlock[]; - color: string; - created_at: Date; - created_by: string; - description: string; - description_html: string; - description_stripped: string | null; - id: string; +export type TPage = { + access: EPageAccess | undefined; + archived_at: string | null | undefined; + color: string | undefined; + created_at: Date | undefined; + created_by: string | undefined; + description_html: string | undefined; + id: string | undefined; is_favorite: boolean; is_locked: boolean; - label_details: IIssueLabel[]; - labels: string[]; - name: string; - owned_by: string; - project: string; - project_detail: IProjectLite; - updated_at: Date; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} + labels: string[] | undefined; + name: string | undefined; + owned_by: string | undefined; + project: string | undefined; + updated_at: Date | undefined; + updated_by: string | undefined; + view_props: TPageViewProps | undefined; + workspace: string | undefined; +}; -export interface IRecentPages { - today: string[]; - yesterday: string[]; - this_week: string[]; - older: string[]; - [key: string]: string[]; -} +export type TPageViewProps = { + full_width?: boolean; +}; -export interface IPageBlock { - completed_at: Date | null; - created_at: Date; - created_by: string; - description: any; - description_html: any; - description_stripped: any; - id: string; - issue: string | null; - issue_detail: TIssue | null; - name: string; - page: string; - project: string; - project_detail: IProjectLite; - sort_order: number; - sync: boolean; - updated_at: Date; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} +// page filters +export type TPageNavigationTabs = "public" | "private" | "archived"; -export type TPageViewProps = "list" | "detailed" | "masonry"; +export type TPageFiltersSortKey = + | "name" + | "created_at" + | "updated_at" + | "opened_at"; + +export type TPageFiltersSortBy = "asc" | "desc"; + +export type TPageFilterProps = { + created_at?: string[] | null; + created_by?: string[] | null; + favorites?: boolean; + labels?: string[] | null; +}; + +export type TPageFilters = { + searchQuery: string; + sortKey: TPageFiltersSortKey; + sortBy: TPageFiltersSortBy; + filters?: TPageFilterProps; +}; diff --git a/packages/types/src/pragmatic.d.ts b/packages/types/src/pragmatic.d.ts new file mode 100644 index 000000000..439e2b54f --- /dev/null +++ b/packages/types/src/pragmatic.d.ts @@ -0,0 +1,34 @@ +export type TDropTarget = { + element: Element; + data: Record; +}; + +export type TDropTargetMiscellaneousData = { + dropEffect: string; + isActiveDueToStickiness: boolean; +}; + +export interface IPragmaticPayloadLocation { + initial: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; + current: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; + previous: { + dropTargets: (TDropTarget & TDropTargetMiscellaneousData)[]; + }; +} + +export interface IPragmaticDropPayload { + location: IPragmaticPayloadLocation; + source: TDropTarget; + self: TDropTarget & TDropTargetMiscellaneousData; +} + +export type InstructionType = + | "reparent" + | "reorder-above" + | "reorder-below" + | "make-child" + | "instruction-blocked"; \ No newline at end of file diff --git a/packages/types/src/project/project_filters.d.ts b/packages/types/src/project/project_filters.d.ts index 02ad09ee1..77da7365f 100644 --- a/packages/types/src/project/project_filters.d.ts +++ b/packages/types/src/project/project_filters.d.ts @@ -9,9 +9,14 @@ export type TProjectOrderByOptions = export type TProjectDisplayFilters = { my_projects?: boolean; + archived_projects?: boolean; order_by?: TProjectOrderByOptions; }; +export type TProjectAppliedDisplayFilterKeys = + | "my_projects" + | "archived_projects"; + export type TProjectFilters = { access?: string[] | null; lead?: string[] | null; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index f310d9c66..157ecb16e 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -1,4 +1,4 @@ -import { EUserProjectRoles } from "constants/project"; +import { EUserProjectRoles } from "@/constants/project"; import type { IProjectViewProps, IUser, @@ -23,6 +23,7 @@ export type TProjectLogoProps = { export interface IProject { archive_in: number; + archived_at: string | null; archived_issues: number; archived_sub_issues: number; close_in: number; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 2d7e94d95..ceaa53d02 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,4 +1,4 @@ -import { EUserWorkspaceRoles } from "constants/workspace"; +import { EUserWorkspaceRoles } from "@/constants/workspace"; import type { IProjectMember, IUser, diff --git a/packages/ui/package.json b/packages/ui/package.json index fdd67dcc1..d627c94ba 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.16.0", + "version": "0.18.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/ui/src/drop-indicator.tsx b/packages/ui/src/drop-indicator.tsx new file mode 100644 index 000000000..7ffc83a4b --- /dev/null +++ b/packages/ui/src/drop-indicator.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { cn } from "../helpers"; + +type Props = { + isVisible: boolean; + classNames?: string; +}; + +export const DropIndicator = (props: Props) => { + const { isVisible, classNames = "" } = props; + + return ( +
    + ); +}; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index b94faf436..549c83fe7 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -131,6 +131,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { ref={setReferenceElement} type="button" onClick={(e) => { + e.preventDefault(); e.stopPropagation(); isOpen ? closeDropdown() : openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); @@ -157,6 +158,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} onClick={(e) => { + e.preventDefault(); e.stopPropagation(); isOpen ? closeDropdown() : openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 37608ea8d..2d669cc05 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -91,7 +91,7 @@ const CustomSelect = (props: ICustomSelectProps) => { )} {isOpen && ( - closeDropdown()} static> + closeDropdown()} static>
    ((props, ref) => { : mode === "true-transparent" ? "rounded border-none bg-transparent ring-0" : "" - } ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${ - inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : "" - }`, + } ${hasError ? "border-red-500" : ""} ${inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : ""}`, className )} {...rest} diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx index b93c1aba8..2c47a65f5 100644 --- a/packages/ui/src/form-fields/textarea.tsx +++ b/packages/ui/src/form-fields/textarea.tsx @@ -1,4 +1,8 @@ -import * as React from "react"; +import React, { useRef } from "react"; +// helpers +import { cn } from "../../helpers"; +// hooks +import { useAutoResizeTextArea } from "../hooks/use-auto-resize-textarea"; export interface TextAreaProps extends React.TextareaHTMLAttributes { mode?: "primary" | "transparent"; @@ -6,37 +10,12 @@ export interface TextAreaProps extends React.TextareaHTMLAttributes when the value changes. -const useAutoSizeTextArea = (textAreaRef: HTMLTextAreaElement | null, value: any) => { - React.useEffect(() => { - if (textAreaRef) { - // We need to reset the height momentarily to get the correct scrollHeight for the textarea - textAreaRef.style.height = "0px"; - const scrollHeight = textAreaRef.scrollHeight; - - // We then set the height directly, outside of the render loop - // Trying to set this with state or a ref will product an incorrect value. - textAreaRef.style.height = scrollHeight + "px"; - } - }, [textAreaRef, value]); -}; - const TextArea = React.forwardRef((props, ref) => { - const { - id, - name, - value = "", - rows = 1, - cols = 1, - mode = "primary", - hasError = false, - className = "", - ...rest - } = props; - - const textAreaRef = React.useRef(ref); - - useAutoSizeTextArea(textAreaRef?.current, value); + const { id, name, value = "", mode = "primary", hasError = false, className = "", ...rest } = props; + // refs + const textAreaRef = useRef(ref); + // auto re-size + useAutoResizeTextArea(textAreaRef); return (