diff --git a/.env.example b/.env.example index 71a9074a63..0649839a45 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,5 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 + +MONGO_DB_URL="mongodb://plane-mongodb:27017/" \ No newline at end of file diff --git a/.github/workflows/build-branch-ee.yml b/.github/workflows/build-branch-ee.yml new file mode 100644 index 0000000000..789c7e7986 --- /dev/null +++ b/.github/workflows/build-branch-ee.yml @@ -0,0 +1,460 @@ +name: Branch Build Enterprise + +on: + workflow_dispatch: + push: + branches: + - master + - preview + - develop + release: + types: [released, prereleased] + +env: + TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} + +jobs: + branch_build_setup: + name: Build Setup + runs-on: ubuntu-latest + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + build_web: ${{ steps.changed_files.outputs.web_any_changed }} + build_admin: ${{ steps.changed_files.outputs.admin_any_changed }} + build_space: ${{ steps.changed_files.outputs.space_any_changed }} + build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }} + build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} + artifact_upload_to_s3: ${{ steps.set_env_variables.outputs.artifact_upload_to_s3 }} + artifact_s3_suffix: ${{ steps.set_env_variables.outputs.artifact_s3_suffix }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + else + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + fi + BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" | tr / -) + echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT + + if [ "${{ github.event_name }}" == "release" ]; then + echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT + echo "artifact_s3_suffix=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT + echo "artifact_s3_suffix=latest" >> $GITHUB_OUTPUT + elif [ "${{ env.TARGET_BRANCH }}" == "preview" ] || [ "${{ env.TARGET_BRANCH }}" == "develop" ]; then + echo "artifact_upload_to_s3=true" >> $GITHUB_OUTPUT + echo "artifact_s3_suffix=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + else + echo "artifact_upload_to_s3=false" >> $GITHUB_OUTPUT + echo "artifact_s3_suffix=$BR_NAME" >> $GITHUB_OUTPUT + fi + + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - name: Get changed files + id: changed_files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + apiserver: + - apiserver/** + proxy: + - nginx/** + admin: + - admin/** + - packages/** + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" + space: + - space/** + - packages/** + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" + web: + - web/** + - packages/** + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" + + branch_build_push_admin: + if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Admin Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + ADMIN_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/admin-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Admin Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/admin-enterprise:stable + TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/admin-enterprise:${{ github.event.release.tag_name }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/admin-enterprise:stable + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/admin-enterprise:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/admin-enterprise:latest + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/admin-enterprise:latest + else + TAG=${{ env.ADMIN_TAG }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/admin-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + registry: ${{ vars.HARBOR_REGISTRY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Frontend to Docker Container Registry + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./admin/Dockerfile.admin + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.ADMIN_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_web: + if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Web Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + WEB_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Web Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:stable + TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:${{ github.event.release.tag_name }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:stable + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/web-enterprise:latest + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:latest + else + TAG=${{ env.WEB_TAG }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/web-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + echo "WEB_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + registry: ${{ vars.HARBOR_REGISTRY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Web to Docker Container Registry + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./web/Dockerfile.web + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.WEB_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_space: + 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' }} + name: Build-Push Space Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Space Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:stable + TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:${{ github.event.release.tag_name }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:stable + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/space-enterprise:latest + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:latest + else + TAG=${{ env.SPACE_TAG }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/space-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + registry: ${{ vars.HARBOR_REGISTRY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Space to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./space/Dockerfile.space + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.SPACE_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_apiserver: + if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push API Server Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Backend Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:stable + TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:${{ github.event.release.tag_name }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:stable + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/backend-enterprise:latest + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:latest + else + TAG=${{ env.BACKEND_TAG }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/backend-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + registry: ${{ vars.HARBOR_REGISTRY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Backend to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./apiserver + file: ./apiserver/Dockerfile.api + platforms: ${{ env.BUILDX_PLATFORMS }} + push: true + tags: ${{ env.BACKEND_TAG }} + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + branch_build_push_proxy: + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Proxy Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Proxy Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:stable + TAG=${TAG},${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:${{ github.event.release.tag_name }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:stable + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKERHUB_USERNAME }}/proxy-enterprise:latest + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:latest + else + TAG=${{ env.PROXY_TAG }} + TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/proxy-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + registry: ${{ vars.HARBOR_REGISTRY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Plane-Proxy to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./nginx + file: ./nginx/Dockerfile + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.PROXY_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + upload_artifacts_s3: + if: ${{ needs.branch_build_setup.outputs.artifact_upload_to_s3 == 'true' }} + name: Upload artifacts to S3 Bucket + runs-on: ubuntu-latest + needs: [branch_build_setup] + container: + image: docker:20.10.7 + credentials: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + ARTIFACT_SUFFIX: ${{ needs.branch_build_setup.outputs.artifact_s3_suffix }} + AWS_ACCESS_KEY_ID: ${{ secrets.SELF_HOST_BUCKET_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SELF_HOST_BUCKET_SECRET_KEY }} + TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - name: Upload artifacts + run: | + apk update + apk add --no-cache aws-cli + + mkdir -p ~/${{ env.ARTIFACT_SUFFIX }} + + cp deploy/cli-install/variables.env ~/${{ env.ARTIFACT_SUFFIX }}/variables.env + cp deploy/cli-install/Caddyfile ~/${{ env.ARTIFACT_SUFFIX }}/Caddyfile + sed -e 's@${APP_RELEASE_VERSION}@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/docker-compose.yml > ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose.yml + sed -e 's@${APP_RELEASE_VERSION}@'${{ env.ARTIFACT_SUFFIX }}'@' deploy/cli-install/docker-compose-caddy.yml > ~/${{ env.ARTIFACT_SUFFIX }}/docker-compose-caddy.yml + + aws s3 cp ~/${{ env.ARTIFACT_SUFFIX }} s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ env.ARTIFACT_SUFFIX }} --recursive + + rm -rf ~/${{ env.ARTIFACT_SUFFIX }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000000..3c6f5a0056 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,70 @@ +name: Manual Release Workflow + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'Release Tag (e.g., v0.16-cannary-1)' + required: true + prerelease: + description: 'Pre-Release' + required: true + default: true + type: boolean + draft: + description: 'Draft' + required: true + default: true + type: boolean + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 # Necessary to fetch all history for tags + + - name: Set up Git + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + + - name: Check for the Prerelease + run: | + echo ${{ github.event.release.prerelease }} + + - name: Generate Release Notes + id: generate_notes + run: | + bash ./generate_release_notes.sh + # Directly use the content of RELEASE_NOTES.md for the release body + RELEASE_NOTES=$(cat RELEASE_NOTES.md) + echo "RELEASE_NOTES<> $GITHUB_ENV + echo "$RELEASE_NOTES" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create Tag + run: | + git tag ${{ github.event.inputs.release_tag }} + git push origin ${{ github.event.inputs.release_tag }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ github.event.inputs.release_tag }} + body_path: RELEASE_NOTES.md + draft: ${{ github.event.inputs.draft }} + prerelease: ${{ github.event.inputs.prerelease }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + + + + + diff --git a/apiserver/bin/takeoff.cloud b/apiserver/bin/takeoff.cloud new file mode 100644 index 0000000000..18feb40875 --- /dev/null +++ b/apiserver/bin/takeoff.cloud @@ -0,0 +1,28 @@ +#!/bin/bash +set -e +python manage.py wait_for_db +python manage.py migrate + +# Create the default bucket +#!/bin/bash + +# Collect system information +HOSTNAME=$(hostname) +MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1) +CPU_INFO=$(cat /proc/cpuinfo) +MEMORY_INFO=$(free -h) +DISK_INFO=$(df -h) + +# Concatenate information and compute SHA-256 hash +SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}') + +# Export the variables +export MACHINE_SIGNATURE=$SIGNATURE + +# Register instance +python manage.py setup_instance $INSTANCE_ADMIN_EMAIL + +# Create the default bucket +python manage.py create_bucket + +exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 813c1af21d..434c9c8355 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -42,6 +42,9 @@ from .view import ( IssueViewSerializer, IssueViewFavoriteSerializer, ) + +from .active_cycle import ActiveCycleSerializer + from .cycle import ( CycleSerializer, CycleIssueSerializer, @@ -126,3 +129,13 @@ from .exporter import ExporterHistorySerializer from .webhook import WebhookSerializer, WebhookLogSerializer from .dashboard import DashboardSerializer, WidgetSerializer + +from .integration import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, + GithubIssueSyncSerializer, + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, + SlackProjectSyncSerializer, +) diff --git a/apiserver/plane/app/serializers/active_cycle.py b/apiserver/plane/app/serializers/active_cycle.py new file mode 100644 index 0000000000..91a6d20baf --- /dev/null +++ b/apiserver/plane/app/serializers/active_cycle.py @@ -0,0 +1,58 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .project import ProjectLiteSerializer +from plane.db.models import ( + Cycle, +) + + +class ActiveCycleSerializer(BaseSerializer): + # favorite + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + # state group wise distribution + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) + + # project details + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Cycle + fields = [ + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "status", + "project_detail", + ] + read_only_fields = fields diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 13d321780d..fed343e49a 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -3,6 +3,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer + from .issue import IssueStateSerializer from plane.db.models import ( Cycle, diff --git a/apiserver/plane/app/serializers/integration/__init__.py b/apiserver/plane/app/serializers/integration/__init__.py new file mode 100644 index 0000000000..112ff02d16 --- /dev/null +++ b/apiserver/plane/app/serializers/integration/__init__.py @@ -0,0 +1,8 @@ +from .base import IntegrationSerializer, WorkspaceIntegrationSerializer +from .github import ( + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubIssueSyncSerializer, + GithubCommentSyncSerializer, +) +from .slack import SlackProjectSyncSerializer diff --git a/apiserver/plane/app/serializers/integration/base.py b/apiserver/plane/app/serializers/integration/base.py new file mode 100644 index 0000000000..01e484ed02 --- /dev/null +++ b/apiserver/plane/app/serializers/integration/base.py @@ -0,0 +1,22 @@ +# Module imports +from plane.app.serializers import BaseSerializer +from plane.db.models import Integration, WorkspaceIntegration + + +class IntegrationSerializer(BaseSerializer): + class Meta: + model = Integration + fields = "__all__" + read_only_fields = [ + "verified", + ] + + +class WorkspaceIntegrationSerializer(BaseSerializer): + integration_detail = IntegrationSerializer( + read_only=True, source="integration" + ) + + class Meta: + model = WorkspaceIntegration + fields = "__all__" diff --git a/apiserver/plane/app/serializers/integration/github.py b/apiserver/plane/app/serializers/integration/github.py new file mode 100644 index 0000000000..850bccf1b3 --- /dev/null +++ b/apiserver/plane/app/serializers/integration/github.py @@ -0,0 +1,45 @@ +# Module imports +from plane.app.serializers import BaseSerializer +from plane.db.models import ( + GithubIssueSync, + GithubRepository, + GithubRepositorySync, + GithubCommentSync, +) + + +class GithubRepositorySerializer(BaseSerializer): + class Meta: + model = GithubRepository + fields = "__all__" + + +class GithubRepositorySyncSerializer(BaseSerializer): + repo_detail = GithubRepositorySerializer(source="repository") + + class Meta: + model = GithubRepositorySync + fields = "__all__" + + +class GithubIssueSyncSerializer(BaseSerializer): + class Meta: + model = GithubIssueSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "repository_sync", + ] + + +class GithubCommentSyncSerializer(BaseSerializer): + class Meta: + model = GithubCommentSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "repository_sync", + "issue_sync", + ] diff --git a/apiserver/plane/app/serializers/integration/slack.py b/apiserver/plane/app/serializers/integration/slack.py new file mode 100644 index 0000000000..9c461c5b9b --- /dev/null +++ b/apiserver/plane/app/serializers/integration/slack.py @@ -0,0 +1,14 @@ +# Module imports +from plane.app.serializers import BaseSerializer +from plane.db.models import SlackProjectSync + + +class SlackProjectSyncSerializer(BaseSerializer): + class Meta: + model = SlackProjectSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "workspace_integration", + ] diff --git a/apiserver/plane/app/urls/__init__.py b/apiserver/plane/app/urls/__init__.py index cb5f0253ad..49e3a0ffbc 100644 --- a/apiserver/plane/app/urls/__init__.py +++ b/apiserver/plane/app/urls/__init__.py @@ -18,6 +18,11 @@ from .views import urlpatterns as view_urls from .webhook import urlpatterns as webhook_urls from .workspace import urlpatterns as workspace_urls +from .importer import urlpatterns as importer_urls +from .integration import urlpatterns as integration_urls +from .active_cycle import urlpatterns as active_cycle_urls + + urlpatterns = [ *analytic_urls, *asset_urls, @@ -38,4 +43,8 @@ urlpatterns = [ *workspace_urls, *api_urls, *webhook_urls, + # ee + *active_cycle_urls, + *integration_urls, + *importer_urls, ] diff --git a/apiserver/plane/app/urls/active_cycle.py b/apiserver/plane/app/urls/active_cycle.py new file mode 100644 index 0000000000..69ce8e3c26 --- /dev/null +++ b/apiserver/plane/app/urls/active_cycle.py @@ -0,0 +1,13 @@ +from django.urls import path + +from plane.app.views import ( + ActiveCycleEndpoint, +) + +urlpatterns = [ + path( + "workspaces//active-cycles/", + ActiveCycleEndpoint.as_view(), + name="workspace-active-cycle", + ), +] diff --git a/apiserver/plane/app/urls/importer.py b/apiserver/plane/app/urls/importer.py new file mode 100644 index 0000000000..9da8c4ede8 --- /dev/null +++ b/apiserver/plane/app/urls/importer.py @@ -0,0 +1,43 @@ +from django.urls import path + + +from plane.app.views import ( + ServiceIssueImportSummaryEndpoint, + ImportServiceEndpoint, + UpdateServiceImportStatusEndpoint, + BulkImportIssuesEndpoint, +) + + +urlpatterns = [ + path( + "workspaces//importers//", + ServiceIssueImportSummaryEndpoint.as_view(), + name="importer-summary", + ), + path( + "workspaces//projects/importers//", + ImportServiceEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//importers/", + ImportServiceEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//importers///", + ImportServiceEndpoint.as_view(), + name="importer", + ), + path( + "workspaces//projects//service//importers//", + UpdateServiceImportStatusEndpoint.as_view(), + name="importer-status", + ), + path( + "workspaces//projects//bulk-import-issues//", + BulkImportIssuesEndpoint.as_view(), + name="bulk-import-issues", + ), +] diff --git a/apiserver/plane/app/urls/integration.py b/apiserver/plane/app/urls/integration.py new file mode 100644 index 0000000000..cf3f82d5a4 --- /dev/null +++ b/apiserver/plane/app/urls/integration.py @@ -0,0 +1,150 @@ +from django.urls import path + + +from plane.app.views import ( + IntegrationViewSet, + WorkspaceIntegrationViewSet, + GithubRepositoriesEndpoint, + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + BulkCreateGithubIssueSyncEndpoint, + SlackProjectSyncViewSet, +) + + +urlpatterns = [ + path( + "integrations/", + IntegrationViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="integrations", + ), + path( + "integrations//", + IntegrationViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="integrations", + ), + path( + "workspaces//workspace-integrations/", + WorkspaceIntegrationViewSet.as_view( + { + "get": "list", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "post": "create", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//provider/", + WorkspaceIntegrationViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="workspace-integrations", + ), + # Github Integrations + path( + "workspaces//workspace-integrations//github-repositories/", + GithubRepositoriesEndpoint.as_view(), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync/", + GithubRepositorySyncViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync//", + GithubRepositorySyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync/", + GithubIssueSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//bulk-create-github-issue-sync/", + BulkCreateGithubIssueSyncEndpoint.as_view(), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//", + GithubIssueSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync/", + GithubCommentSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync//", + GithubCommentSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + ## End Github Integrations + # Slack Integration + path( + "workspaces//projects//workspace-integrations//project-slack-sync/", + SlackProjectSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//workspace-integrations//project-slack-sync//", + SlackProjectSyncViewSet.as_view( + { + "delete": "destroy", + "get": "retrieve", + } + ), + ), + ## End Slack Integration +] diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index 05a79994e0..33b49b33ee 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -4,6 +4,7 @@ from django.urls import path from plane.app.views import ( GlobalSearchEndpoint, IssueSearchEndpoint, + SearchEndpoint, ) @@ -18,4 +19,9 @@ urlpatterns = [ IssueSearchEndpoint.as_view(), name="project-issue-search", ), + path( + "workspaces//projects//search/", + SearchEndpoint.as_view(), + name="search", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index e652e003ee..e1e57c862b 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -94,6 +94,7 @@ from .cycle.base import ( CycleViewSet, TransferCycleIssueEndpoint, ) +from .cycle.active_cycle import ActiveCycleEndpoint from .cycle.issue import ( CycleIssueViewSet, ) @@ -179,7 +180,7 @@ from .page.base import ( SubPagesEndpoint, ) -from .search import GlobalSearchEndpoint, IssueSearchEndpoint +from .search import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint from .external.base import ( @@ -220,6 +221,28 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint from .error_404 import custom_404_view +from .importer.base import ( + ServiceIssueImportSummaryEndpoint, + ImportServiceEndpoint, + UpdateServiceImportStatusEndpoint, + BulkImportIssuesEndpoint, + BulkImportModulesEndpoint, +) + +from .integration.base import ( + IntegrationViewSet, + WorkspaceIntegrationViewSet, +) + +from .integration.github import ( + GithubRepositoriesEndpoint, + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + BulkCreateGithubIssueSyncEndpoint, +) + +from .integration.slack import SlackProjectSyncViewSet from .exporter.base import ExportIssuesEndpoint from .notification.base import MarkAllReadNotificationViewSet from .user.base import AccountEndpoint, ProfileEndpoint diff --git a/apiserver/plane/app/views/cycle/active_cycle.py b/apiserver/plane/app/views/cycle/active_cycle.py new file mode 100644 index 0000000000..4198d69765 --- /dev/null +++ b/apiserver/plane/app/views/cycle/active_cycle.py @@ -0,0 +1,259 @@ +# Django imports +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + OuterRef, + Prefetch, + Q, + Value, + When, +) +from django.utils import timezone + + +# Module imports +from plane.app.permissions import ( + WorkspaceUserPermission, +) +from plane.app.serializers import ( + ActiveCycleSerializer, +) +from plane.db.models import ( + Cycle, + CycleFavorite, + Issue, + Label, + User, +) +from plane.utils.analytics_plot import burndown_plot +from plane.app.views.base import BaseAPIView + + +class ActiveCycleEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceUserPermission, + ] + + def get_results_controller(self, results, active_cycles=None): + for cycle in results: + assignee_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle["id"], + project_id=cycle["project_id"], + workspace__slug=self.kwargs.get("slug"), + ) + .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( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle["id"], + project_id=cycle["project_id"], + workspace__slug=self.kwargs.get("slug"), + ) + .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( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + cycle["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + if cycle["start_date"] and cycle["end_date"]: + cycle["distribution"]["completion_chart"] = burndown_plot( + queryset=active_cycles.get(pk=cycle["id"]), + slug=self.kwargs.get("slug"), + project_id=cycle["project_id"], + cycle_id=cycle["id"], + ) + return results + + def get(self, request, slug): + 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"), + ) + active_cycles = ( + Cycle.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + start_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .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( + 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(), + ) + ) + .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(), + ) + ) + .order_by("-created_at") + ) + + return self.paginate( + request=request, + queryset=active_cycles, + on_results=lambda active_cycles: ActiveCycleSerializer( + active_cycles, many=True + ).data, + controller=lambda results: self.get_results_controller( + results, active_cycles + ), + default_per_page=int(request.GET.get("per_page", 3)), + ) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 621c1dcb77..1d875d6219 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -25,6 +25,7 @@ from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status from rest_framework.response import Response + from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, diff --git a/apiserver/plane/app/views/importer/base.py b/apiserver/plane/app/views/importer/base.py new file mode 100644 index 0000000000..9ef85181cf --- /dev/null +++ b/apiserver/plane/app/views/importer/base.py @@ -0,0 +1,560 @@ +# Python imports +import uuid + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Django imports +from django.db.models import Max, Q + +# Module imports +from plane.app.views import BaseAPIView +from plane.db.models import ( + WorkspaceIntegration, + Importer, + APIToken, + Project, + State, + IssueSequence, + Issue, + IssueActivity, + IssueComment, + IssueLink, + IssueLabel, + Workspace, + IssueAssignee, + Module, + ModuleLink, + ModuleIssue, + Label, +) +from plane.app.serializers import ( + ImporterSerializer, + IssueFlatSerializer, + ModuleSerializer, +) +from plane.utils.integrations.github import get_github_repo_details +from plane.utils.importers.jira import ( + jira_project_issue_summary, + is_allowed_hostname, +) +from plane.bgtasks.importer_task import service_importer +from plane.utils.html_processor import strip_tags +from plane.app.permissions import WorkSpaceAdminPermission + + +class ServiceIssueImportSummaryEndpoint(BaseAPIView): + def get(self, request, slug, service): + if service == "github": + owner = request.GET.get("owner", False) + repo = request.GET.get("repo", False) + + if not owner or not repo: + return Response( + {"error": "Owner and repo are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace_integration = WorkspaceIntegration.objects.get( + integration__provider="github", workspace__slug=slug + ) + + access_tokens_url = workspace_integration.metadata.get( + "access_tokens_url", False + ) + + if not access_tokens_url: + return Response( + { + "error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue_count, labels, collaborators = get_github_repo_details( + access_tokens_url, owner, repo + ) + return Response( + { + "issue_count": issue_count, + "labels": labels, + "collaborators": collaborators, + }, + status=status.HTTP_200_OK, + ) + + if service == "jira": + # Check for all the keys + params = { + "project_key": "Project key is required", + "api_token": "API token is required", + "email": "Email is required", + "cloud_hostname": "Cloud hostname is required", + } + + for key, error_message in params.items(): + if not request.GET.get(key, False): + return Response( + {"error": error_message}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_key = request.GET.get("project_key", "") + api_token = request.GET.get("api_token", "") + email = request.GET.get("email", "") + cloud_hostname = request.GET.get("cloud_hostname", "") + + response = jira_project_issue_summary( + email, api_token, project_key, cloud_hostname + ) + if "error" in response: + return Response(response, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + response, + status=status.HTTP_200_OK, + ) + return Response( + {"error": "Service not supported yet"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ImportServiceEndpoint(BaseAPIView): + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def post(self, request, slug, service): + project_id = request.data.get("project_id", False) + + if not project_id: + return Response( + {"error": "Project ID is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + + if service == "github": + data = request.data.get("data", False) + metadata = request.data.get("metadata", False) + config = request.data.get("config", False) + if not data or not metadata or not config: + return Response( + {"error": "Data, config and metadata are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + api_token = APIToken.objects.filter( + user=request.user, workspace=workspace + ).first() + if api_token is None: + api_token = APIToken.objects.create( + user=request.user, + label="Importer", + workspace=workspace, + ) + + importer = Importer.objects.create( + service=service, + project_id=project_id, + status="queued", + initiated_by=request.user, + data=data, + metadata=metadata, + token=api_token, + config=config, + created_by=request.user, + updated_by=request.user, + ) + + service_importer.delay(service, importer.id) + serializer = ImporterSerializer(importer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + if service == "jira": + data = request.data.get("data", False) + metadata = request.data.get("metadata", False) + config = request.data.get("config", False) + + cloud_hostname = metadata.get("cloud_hostname", False) + + if not cloud_hostname: + return Response( + {"error": "Cloud hostname is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not is_allowed_hostname(cloud_hostname): + return Response( + {"error": "Hostname is not a valid hostname."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not data or not metadata: + return Response( + {"error": "Data, config and metadata are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + api_token = APIToken.objects.filter( + user=request.user, workspace=workspace + ).first() + if api_token is None: + api_token = APIToken.objects.create( + user=request.user, + label="Importer", + workspace=workspace, + ) + + importer = Importer.objects.create( + service=service, + project_id=project_id, + status="queued", + initiated_by=request.user, + data=data, + metadata=metadata, + token=api_token, + config=config, + created_by=request.user, + updated_by=request.user, + ) + + service_importer.delay(service, importer.id) + serializer = ImporterSerializer(importer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response( + {"error": "Servivce not supported yet"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request, slug): + imports = ( + Importer.objects.filter(workspace__slug=slug) + .order_by("-created_at") + .select_related("initiated_by", "project", "workspace") + ) + serializer = ImporterSerializer(imports, many=True) + return Response(serializer.data) + + def delete(self, request, slug, service, pk): + importer = Importer.objects.get( + pk=pk, service=service, workspace__slug=slug + ) + + if importer.imported_data is not None: + # Delete all imported Issues + imported_issues = importer.imported_data.get("issues", []) + Issue.issue_objects.filter(id__in=imported_issues).delete() + + # Delete all imported Labels + imported_labels = importer.imported_data.get("labels", []) + Label.objects.filter(id__in=imported_labels).delete() + + if importer.service == "jira": + imported_modules = importer.imported_data.get("modules", []) + Module.objects.filter(id__in=imported_modules).delete() + importer.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def patch(self, request, slug, service, pk): + importer = Importer.objects.get( + pk=pk, service=service, workspace__slug=slug + ) + serializer = ImporterSerializer( + importer, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class UpdateServiceImportStatusEndpoint(BaseAPIView): + def post(self, request, slug, project_id, service, importer_id): + importer = Importer.objects.get( + pk=importer_id, + workspace__slug=slug, + project_id=project_id, + service=service, + ) + importer.status = request.data.get("status", "processing") + importer.save() + return Response(status.HTTP_200_OK) + + +class BulkImportIssuesEndpoint(BaseAPIView): + def post(self, request, slug, project_id, service): + # Get the project + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + # Get the default state + default_state = State.objects.filter( + ~Q(name="Triage"), project_id=project_id, default=True + ).first() + # if there is no default state assign any random state + if default_state is None: + default_state = State.objects.filter( + ~Q(name="Triage"), project_id=project_id + ).first() + + # Get the maximum sequence_id + last_id = IssueSequence.objects.filter( + project_id=project_id + ).aggregate(largest=Max("sequence"))["largest"] + + last_id = 1 if last_id is None else last_id + 1 + + # Get the maximum sort order + largest_sort_order = Issue.objects.filter( + project_id=project_id, state=default_state + ).aggregate(largest=Max("sort_order"))["largest"] + + largest_sort_order = ( + 65535 if largest_sort_order is None else largest_sort_order + 10000 + ) + + # Get the issues_data + issues_data = request.data.get("issues_data", []) + + if not len(issues_data): + return Response( + {"error": "Issue data is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Issues + bulk_issues = [] + for issue_data in issues_data: + bulk_issues.append( + Issue( + project_id=project_id, + workspace_id=project.workspace_id, + state_id=( + issue_data.get("state") + if issue_data.get("state", False) + else default_state.id + ), + name=issue_data.get("name", "Issue Created through Bulk"), + description_html=issue_data.get( + "description_html", "

" + ), + description_stripped=( + None + if ( + issue_data.get("description_html") == "" + or issue_data.get("description_html") is None + ) + else strip_tags(issue_data.get("description_html")) + ), + sequence_id=last_id, + sort_order=largest_sort_order, + start_date=issue_data.get("start_date", None), + target_date=issue_data.get("target_date", None), + priority=issue_data.get("priority", "none"), + created_by=request.user, + ) + ) + + largest_sort_order = largest_sort_order + 10000 + last_id = last_id + 1 + + issues = Issue.objects.bulk_create( + bulk_issues, + batch_size=100, + ignore_conflicts=True, + ) + + # Sequences + _ = IssueSequence.objects.bulk_create( + [ + IssueSequence( + issue=issue, + sequence=issue.sequence_id, + project_id=project_id, + workspace_id=project.workspace_id, + ) + for issue in issues + ], + batch_size=100, + ) + + # Attach Labels + bulk_issue_labels = [] + for issue, issue_data in zip(issues, issues_data): + labels_list = issue_data.get("labels_list", []) + bulk_issue_labels = bulk_issue_labels + [ + IssueLabel( + issue=issue, + label_id=label_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for label_id in labels_list + ] + + _ = IssueLabel.objects.bulk_create( + bulk_issue_labels, batch_size=100, ignore_conflicts=True + ) + + # Attach Assignees + bulk_issue_assignees = [] + for issue, issue_data in zip(issues, issues_data): + assignees_list = issue_data.get("assignees_list", []) + bulk_issue_assignees = bulk_issue_assignees + [ + IssueAssignee( + issue=issue, + assignee_id=assignee_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for assignee_id in assignees_list + ] + + _ = IssueAssignee.objects.bulk_create( + bulk_issue_assignees, batch_size=100, ignore_conflicts=True + ) + + # Track the issue activities + IssueActivity.objects.bulk_create( + [ + IssueActivity( + issue=issue, + actor=request.user, + project_id=project_id, + workspace_id=project.workspace_id, + comment=f"imported the issue from {service}", + verb="created", + created_by=request.user, + ) + for issue in issues + ], + batch_size=100, + ) + + # Create Comments + bulk_issue_comments = [] + for issue, issue_data in zip(issues, issues_data): + comments_list = issue_data.get("comments_list", []) + bulk_issue_comments = bulk_issue_comments + [ + IssueComment( + issue=issue, + comment_html=comment.get("comment_html", "

"), + actor=request.user, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for comment in comments_list + ] + + _ = IssueComment.objects.bulk_create( + bulk_issue_comments, batch_size=100 + ) + + # Attach Links + _ = IssueLink.objects.bulk_create( + [ + IssueLink( + issue=issue, + url=issue_data.get("link", {}).get( + "url", "https://github.com" + ), + title=issue_data.get("link", {}).get( + "title", "Original Issue" + ), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for issue, issue_data in zip(issues, issues_data) + ] + ) + + return Response( + {"issues": IssueFlatSerializer(issues, many=True).data}, + status=status.HTTP_201_CREATED, + ) + + +class BulkImportModulesEndpoint(BaseAPIView): + def post(self, request, slug, project_id, service): + modules_data = request.data.get("modules_data", []) + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + modules = Module.objects.bulk_create( + [ + Module( + name=module.get("name", uuid.uuid4().hex), + description=module.get("description", ""), + start_date=module.get("start_date", None), + target_date=module.get("target_date", None), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for module in modules_data + ], + batch_size=100, + ignore_conflicts=True, + ) + + modules = Module.objects.filter( + id__in=[module.id for module in modules] + ) + + if len(modules) == len(modules_data): + _ = ModuleLink.objects.bulk_create( + [ + ModuleLink( + module=module, + url=module_data.get("link", {}).get( + "url", "https://plane.so" + ), + title=module_data.get("link", {}).get( + "title", "Original Issue" + ), + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for module, module_data in zip(modules, modules_data) + ], + batch_size=100, + ignore_conflicts=True, + ) + + bulk_module_issues = [] + for module, module_data in zip(modules, modules_data): + module_issues_list = module_data.get("module_issues_list", []) + bulk_module_issues = bulk_module_issues + [ + ModuleIssue( + issue_id=issue, + module=module, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + ) + for issue in module_issues_list + ] + + _ = ModuleIssue.objects.bulk_create( + bulk_module_issues, batch_size=100, ignore_conflicts=True + ) + + serializer = ModuleSerializer(modules, many=True) + return Response( + {"modules": serializer.data}, status=status.HTTP_201_CREATED + ) + + else: + return Response( + { + "message": "Modules created but issues could not be imported" + }, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/integration/__init__.py b/apiserver/plane/app/views/integration/__init__.py new file mode 100644 index 0000000000..ea20d96eaf --- /dev/null +++ b/apiserver/plane/app/views/integration/__init__.py @@ -0,0 +1,9 @@ +from .base import IntegrationViewSet, WorkspaceIntegrationViewSet +from .github import ( + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + BulkCreateGithubIssueSyncEndpoint, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) +from .slack import SlackProjectSyncViewSet diff --git a/apiserver/plane/app/views/integration/base.py b/apiserver/plane/app/views/integration/base.py new file mode 100644 index 0000000000..06b2784522 --- /dev/null +++ b/apiserver/plane/app/views/integration/base.py @@ -0,0 +1,181 @@ +# Python improts +import uuid + +# Django imports +from django.contrib.auth.hashers import make_password + +# Third party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from plane.app.views import BaseViewSet +from plane.db.models import ( + Integration, + WorkspaceIntegration, + Workspace, + User, + WorkspaceMember, + APIToken, +) +from plane.app.serializers import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, +) +from plane.utils.integrations.github import ( + get_github_metadata, + delete_github_installation, +) +from plane.app.permissions import WorkSpaceAdminPermission +from plane.utils.integrations.slack import slack_oauth + + +class IntegrationViewSet(BaseViewSet): + serializer_class = IntegrationSerializer + model = Integration + + def create(self, request): + serializer = IntegrationSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, pk): + integration = Integration.objects.get(pk=pk) + if integration.verified: + return Response( + {"error": "Verified integrations cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IntegrationSerializer( + integration, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, pk): + integration = Integration.objects.get(pk=pk) + if integration.verified: + return Response( + {"error": "Verified integrations cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + integration.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceIntegrationViewSet(BaseViewSet): + serializer_class = WorkspaceIntegrationSerializer + model = WorkspaceIntegration + + permission_classes = [ + WorkSpaceAdminPermission, + ] + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("integration") + ) + + def create(self, request, slug, provider): + workspace = Workspace.objects.get(slug=slug) + integration = Integration.objects.get(provider=provider) + config = {} + if provider == "github": + installation_id = request.data.get("installation_id", None) + if not installation_id: + return Response( + {"error": "Installation ID is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + metadata = get_github_metadata(installation_id) + config = {"installation_id": installation_id} + + if provider == "slack": + code = request.data.get("code", False) + + if not code: + return Response( + {"error": "Code is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + slack_response = slack_oauth(code=code) + + metadata = slack_response + access_token = metadata.get("access_token", False) + team_id = metadata.get("team", {}).get("id", False) + if not metadata or not access_token or not team_id: + return Response( + { + "error": "Slack could not be installed. Please try again later" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + config = {"team_id": team_id, "access_token": access_token} + + # Create a bot user + bot_user = User.objects.create( + email=f"{uuid.uuid4().hex}@plane.so", + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + is_bot=True, + first_name=integration.title, + avatar=( + integration.avatar_url + if integration.avatar_url is not None + else "" + ), + ) + + # Create an API Token for the bot user + api_token = APIToken.objects.create( + user=bot_user, + user_type=1, # bot user + workspace=workspace, + ) + + workspace_integration = WorkspaceIntegration.objects.create( + workspace=workspace, + integration=integration, + actor=bot_user, + api_token=api_token, + metadata=metadata, + config=config, + ) + + # Add bot user as a member of workspace + _ = WorkspaceMember.objects.create( + workspace=workspace_integration.workspace, + member=bot_user, + role=20, + ) + return Response( + WorkspaceIntegrationSerializer(workspace_integration).data, + status=status.HTTP_201_CREATED, + ) + + def destroy(self, request, slug, pk): + workspace_integration = WorkspaceIntegration.objects.get( + pk=pk, workspace__slug=slug + ) + + if workspace_integration.integration.provider == "github": + installation_id = workspace_integration.config.get( + "installation_id", False + ) + if installation_id: + delete_github_installation(installation_id=installation_id) + + workspace_integration.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/integration/github.py b/apiserver/plane/app/views/integration/github.py new file mode 100644 index 0000000000..00e476e06c --- /dev/null +++ b/apiserver/plane/app/views/integration/github.py @@ -0,0 +1,201 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.views import BaseViewSet, BaseAPIView +from plane.db.models import ( + GithubIssueSync, + GithubRepositorySync, + GithubRepository, + WorkspaceIntegration, + ProjectMember, + Label, + GithubCommentSync, + Project, +) +from plane.app.serializers import ( + GithubIssueSyncSerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, +) +from plane.utils.integrations.github import get_github_repos +from plane.app.permissions import ( + ProjectBasePermission, + ProjectEntityPermission, +) + + +class GithubRepositoriesEndpoint(BaseAPIView): + permission_classes = [ + ProjectBasePermission, + ] + + def get(self, request, slug, workspace_integration_id): + page = request.GET.get("page", 1) + workspace_integration = WorkspaceIntegration.objects.get( + workspace__slug=slug, pk=workspace_integration_id + ) + + if workspace_integration.integration.provider != "github": + return Response( + {"error": "Not a github integration"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + access_tokens_url = workspace_integration.metadata["access_tokens_url"] + repositories_url = ( + workspace_integration.metadata["repositories_url"] + + f"?per_page=100&page={page}" + ) + repositories = get_github_repos(access_tokens_url, repositories_url) + return Response(repositories, status=status.HTTP_200_OK) + + +class GithubRepositorySyncViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + + serializer_class = GithubRepositorySyncSerializer + model = GithubRepositorySync + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + ) + + def create(self, request, slug, project_id, workspace_integration_id): + name = request.data.get("name", False) + url = request.data.get("url", False) + config = request.data.get("config", {}) + repository_id = request.data.get("repository_id", False) + owner = request.data.get("owner", False) + + if not name or not url or not repository_id or not owner: + return Response( + {"error": "Name, url, repository_id and owner are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace integration + workspace_integration = WorkspaceIntegration.objects.get( + pk=workspace_integration_id + ) + + # Delete the old repository object + GithubRepositorySync.objects.filter( + project_id=project_id, workspace__slug=slug + ).delete() + GithubRepository.objects.filter( + project_id=project_id, workspace__slug=slug + ).delete() + + # Create repository + repo = GithubRepository.objects.create( + name=name, + url=url, + config=config, + repository_id=repository_id, + owner=owner, + project_id=project_id, + ) + + # Create a Label for github + label = Label.objects.filter( + name="GitHub", + project_id=project_id, + ).first() + + if label is None: + label = Label.objects.create( + name="GitHub", + project_id=project_id, + description="Label to sync Plane issues with GitHub issues", + color="#003773", + ) + + # Create repo sync + repo_sync = GithubRepositorySync.objects.create( + repository=repo, + workspace_integration=workspace_integration, + actor=workspace_integration.actor, + credentials=request.data.get("credentials", {}), + project_id=project_id, + label=label, + ) + + # Add bot as a member in the project + _ = ProjectMember.objects.get_or_create( + member=workspace_integration.actor, role=20, project_id=project_id + ) + + # Return Response + return Response( + GithubRepositorySyncSerializer(repo_sync).data, + status=status.HTTP_201_CREATED, + ) + + +class GithubIssueSyncViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + serializer_class = GithubIssueSyncSerializer + model = GithubIssueSync + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + repository_sync_id=self.kwargs.get("repo_sync_id"), + ) + + +class BulkCreateGithubIssueSyncEndpoint(BaseAPIView): + def post(self, request, slug, project_id, repo_sync_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + github_issue_syncs = request.data.get("github_issue_syncs", []) + github_issue_syncs = GithubIssueSync.objects.bulk_create( + [ + GithubIssueSync( + issue_id=github_issue_sync.get("issue"), + repo_issue_id=github_issue_sync.get("repo_issue_id"), + issue_url=github_issue_sync.get("issue_url"), + github_issue_id=github_issue_sync.get("github_issue_id"), + repository_sync_id=repo_sync_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for github_issue_sync in github_issue_syncs + ], + batch_size=100, + ignore_conflicts=True, + ) + + serializer = GithubIssueSyncSerializer(github_issue_syncs, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class GithubCommentSyncViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + serializer_class = GithubCommentSyncSerializer + model = GithubCommentSync + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_sync_id=self.kwargs.get("issue_sync_id"), + ) diff --git a/apiserver/plane/app/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py new file mode 100644 index 0000000000..22a47d3d09 --- /dev/null +++ b/apiserver/plane/app/views/integration/slack.py @@ -0,0 +1,95 @@ +# Django import +from django.db import IntegrityError + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from plane.app.views import BaseViewSet +from plane.db.models import ( + SlackProjectSync, + WorkspaceIntegration, + ProjectMember, +) +from plane.app.serializers import SlackProjectSyncSerializer +from plane.app.permissions import ( + ProjectBasePermission, +) +from plane.utils.integrations.slack import slack_oauth + + +class SlackProjectSyncViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + serializer_class = SlackProjectSyncSerializer + model = SlackProjectSync + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + ) + + def create(self, request, slug, project_id, workspace_integration_id): + try: + code = request.data.get("code", False) + + if not code: + return Response( + {"error": "Code is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + slack_response = slack_oauth(code=code) + + workspace_integration = WorkspaceIntegration.objects.get( + workspace__slug=slug, pk=workspace_integration_id + ) + + workspace_integration = WorkspaceIntegration.objects.get( + pk=workspace_integration_id, workspace__slug=slug + ) + slack_project_sync = SlackProjectSync.objects.create( + access_token=slack_response.get("access_token"), + scopes=slack_response.get("scope"), + bot_user_id=slack_response.get("bot_user_id"), + webhook_url=slack_response.get("incoming_webhook", {}).get( + "url" + ), + data=slack_response, + team_id=slack_response.get("team", {}).get("id"), + team_name=slack_response.get("team", {}).get("name"), + workspace_integration=workspace_integration, + project_id=project_id, + ) + _ = ProjectMember.objects.get_or_create( + member=workspace_integration.actor, + role=20, + project_id=project_id, + ) + serializer = SlackProjectSyncSerializer(slack_project_sync) + return Response(serializer.data, status=status.HTTP_200_OK) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "Slack is already installed for the project"}, + status=status.HTTP_410_GONE, + ) + capture_exception(e) + return Response( + { + "error": "Slack could not be installed. Please try again later" + }, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 4a4ffd826d..59336e724e 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -18,6 +18,7 @@ from plane.db.models import ( Module, Page, IssueView, + ProjectMember, ) from plane.utils.issue_search import search_issues @@ -249,7 +250,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 + project__archived_at__isnull=True, ) if workspace_search == "false": @@ -300,3 +301,201 @@ class IssueSearchEndpoint(BaseAPIView): ), status=status.HTTP_200_OK, ) + + +class SearchEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + query = request.query_params.get("query", False) + query_type = request.query_params.get("query_type", "issue") + count = int(request.query_params.get("count", 5)) + + if query_type == "mention": + fields = ["member__first_name", "member__last_name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + ProjectMember.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project_id=project_id, + workspace__slug=slug, + ) + .order_by("-created_at") + .values( + "member__first_name", + "member__last_name", + "member__avatar", + "member__display_name", + "member__id", + )[:count] + ) + + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + access=0, + ) + .order_by("-created_at") + .values("name", "id")[:count] + ) + return Response( + {"users": users, "pages": pages}, status=status.HTTP_200_OK + ) + + if query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values("name", "id", "identifier", "workspace__slug")[:count] + ) + return Response(projects, status=status.HTTP_200_OK) + + if query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + )[:count] + ) + return Response(issues, status=status.HTTP_200_OK) + + if query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + )[:count] + ) + return Response(cycles, status=status.HTTP_200_OK) + + if query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + )[:count] + ) + return Response(modules, status=status.HTTP_200_OK) + + if query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project_id=project_id, + workspace__slug=slug, + access=0, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + )[:count] + ) + return Response(pages, status=status.HTTP_200_OK) + + return Response( + {"error": "Please provide a valid query"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/bgtasks/api_logs_task.py b/apiserver/plane/bgtasks/api_logs_task.py index 038b939d54..13819cabf3 100644 --- a/apiserver/plane/bgtasks/api_logs_task.py +++ b/apiserver/plane/bgtasks/api_logs_task.py @@ -2,14 +2,69 @@ from django.utils import timezone from datetime import timedelta from plane.db.models import APIActivityLog from celery import shared_task +from django.conf import settings +from pymongo import MongoClient +from pymongo.errors import BulkWriteError +from plane.utils.exception_logger import log_exception + +BATCH_SIZE = 3000 @shared_task def delete_api_logs(): - # Get the logs older than 30 days to delete - logs_to_delete = APIActivityLog.objects.filter( - created_at__lte=timezone.now() - timedelta(days=30) - ) - # Delete the logs - logs_to_delete._raw_delete(logs_to_delete.db) + if settings.MONGO_DB_URL: + # Get the logs older than 30 days to delete + logs_to_delete = APIActivityLog.objects.filter( + created_at__lte=timezone.now() - timedelta(days=30) + ) + + # Create a MongoDB client + client = MongoClient(settings.MONGO_DB_URL) + db = client["plane"] + collection = db["api_activity_logs"] + + # Function to insert documents in batches + def bulk_insert(docs): + try: + collection.insert_many(docs) + except BulkWriteError as bwe: + log_exception(bwe) + + # Prepare the logs for bulk insert + def log_generator(): + batch = [] + for log in logs_to_delete.iterator(): + batch.append( + { + "token_identifier": log.token_identifier, + "path": log.path, + "method": log.method, + "query_params": log.query_params, + "headers": log.headers, + "body": log.body, + "response_body": log.response_body, + "response_code": log.response_code, + "ip_address": log.ip_address, + "user_agent": log.user_agent, + "created_at": log.created_at, + "updated_at": log.updated_at, + "created_by": str(log.created_by_id) if log.created_by_id else None, + "updated_by": str(log.updated_by_id) if log.updated_by_id else None, + } + ) + # If batch size is reached, yield the batch + if len(batch) == BATCH_SIZE: + yield batch + batch = [] + + # Yield the remaining logs + if batch: + yield batch + + # Upload the logs to MongoDB in batches + for batch in log_generator(): + bulk_insert(batch) + + # Delete the logs + logs_to_delete._raw_delete(logs_to_delete.db) diff --git a/apiserver/plane/bgtasks/create_faker.py b/apiserver/plane/bgtasks/create_faker.py new file mode 100644 index 0000000000..7f714b7126 --- /dev/null +++ b/apiserver/plane/bgtasks/create_faker.py @@ -0,0 +1,598 @@ +# Python imports +import random +from datetime import datetime + +# 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, + WorkspaceMember, + User, + Project, + ProjectMember, + State, + Label, + Cycle, + Module, + Issue, + IssueSequence, + IssueAssignee, + IssueLabel, + IssueActivity, + CycleIssue, + ModuleIssue, +) + + +def create_workspace_members(workspace, members): + members = User.objects.filter(email__in=members) + + _ = WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + workspace=workspace, + member=member, + role=20, + ) + for member in members + ], + ignore_conflicts=True, + ) + return + + +def create_project(workspace, user_id): + fake = Faker() + name = fake.name() + project = Project.objects.create( + workspace=workspace, + name=name, + identifier=name[ + : random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1) + ].upper(), + created_by_id=user_id, + ) + + # 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_issues(workspace, project, user_id, issue_count): + fake = Faker() + Faker.seed(0) + + states = State.objects.values_list("id", flat=True) + creators = ProjectMember.objects.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), + ) + ) + + sentence = fake.sentence() + issues.append( + Issue( + state_id=states[random.randint(0, len(states) - 1)], + project=project, + workspace=workspace, + name=sentence[:254], + description_html=f"

{sentence}

", + description_stripped=sentence, + 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 + + +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): + # assignees + 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 assignees + 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_fake_data( + slug, email, members, issue_count, cycle_count, module_count +): + workspace = Workspace.objects.get(slug=slug) + + user = User.objects.get(email=email) + user_id = user.id + + # create workspace members + print("creating workspace members") + create_workspace_members(workspace=workspace, members=members) + print("Done creating workspace members") + + # Create a project + print("Creating project") + project = create_project(workspace=workspace, user_id=user_id) + print("Done creating projects") + + # create project members + print("Creating project members") + create_project_members( + workspace=workspace, project=project, members=members + ) + print("Done creating project members") + + # Create states + print("Creating states") + _ = create_states(workspace=workspace, project=project, user_id=user_id) + print("Done creating states") + + # Create labels + print("Creating labels") + _ = create_labels(workspace=workspace, project=project, user_id=user_id) + print("Done creating labels") + + # create cycles + print("Creating cycles") + _ = create_cycles( + workspace=workspace, + project=project, + user_id=user_id, + cycle_count=cycle_count, + ) + print("Done creating cycles") + + # create modules + print("Creating modules") + _ = create_modules( + workspace=workspace, + project=project, + user_id=user_id, + module_count=module_count, + ) + print("Done creating modules") + + print("Creating issues") + create_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating issues") + + print("Creating parent and sub issues") + create_issue_parent( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating parent and sub issues") + + print("Creating issue assignees") + create_issue_assignees( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating issue assignees") + + print("Creating issue labels") + create_issue_labels( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating issue labels") + + print("Creating cycle issues") + create_cycle_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating cycle issues") + + print("Creating module issues") + create_module_issues( + workspace=workspace, + project=project, + user_id=user_id, + issue_count=issue_count, + ) + print("Done creating module issues") + + return diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py new file mode 100644 index 0000000000..941d73e47c --- /dev/null +++ b/apiserver/plane/bgtasks/importer_task.py @@ -0,0 +1,212 @@ +# Python imports +import json +import requests +import uuid + +# Django imports +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.auth.hashers import make_password + +# Third Party imports +from celery import shared_task +from sentry_sdk import capture_exception + +# Module imports +from plane.app.serializers import ImporterSerializer +from plane.db.models import ( + Importer, + WorkspaceMember, + GithubRepositorySync, + GithubRepository, + ProjectMember, + WorkspaceIntegration, + Label, + User, + IssueProperty, + UserNotificationPreference, +) + +from plane.bgtasks.user_welcome_task import send_welcome_slack + + +@shared_task +def service_importer(service, importer_id): + try: + importer = Importer.objects.get(pk=importer_id) + importer.status = "processing" + importer.save() + + users = importer.data.get("users", []) + + # Check if we need to import users as well + if len(users): + # For all invited users create the users + new_users = User.objects.bulk_create( + [ + User( + email=user.get("email").strip().lower(), + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + for user in users + if user.get("import", False) == "invite" + ], + batch_size=100, + ignore_conflicts=True, + ) + + _ = UserNotificationPreference.objects.bulk_create( + [UserNotificationPreference(user=user) for user in new_users], + batch_size=100, + ) + + _ = [ + send_welcome_slack.delay( + str(user.id), + True, + f"{user.email} was imported to Plane from {service}", + ) + for user in new_users + ] + + workspace_users = User.objects.filter( + email__in=[ + user.get("email").strip().lower() + for user in users + if user.get("import", False) == "invite" + or user.get("import", False) == "map" + ] + ) + + # Check if any of the users are already member of workspace + _ = WorkspaceMember.objects.filter( + member__in=[user for user in workspace_users], + workspace_id=importer.workspace_id, + ).update(is_active=True) + + # Add new users to Workspace and project automatically + WorkspaceMember.objects.bulk_create( + [ + WorkspaceMember( + member=user, + workspace_id=importer.workspace_id, + created_by=importer.created_by, + ) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + + ProjectMember.objects.bulk_create( + [ + ProjectMember( + project_id=importer.project_id, + workspace_id=importer.workspace_id, + member=user, + created_by=importer.created_by, + ) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=importer.project_id, + workspace_id=importer.workspace_id, + user=user, + created_by=importer.created_by, + ) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + + # Check if sync config is on for github importers + if service == "github" and importer.config.get("sync", False): + name = importer.metadata.get("name", False) + url = importer.metadata.get("url", False) + config = importer.metadata.get("config", {}) + owner = importer.metadata.get("owner", False) + repository_id = importer.metadata.get("repository_id", False) + + workspace_integration = WorkspaceIntegration.objects.get( + workspace_id=importer.workspace_id, + integration__provider="github", + ) + + # Delete the old repository object + GithubRepositorySync.objects.filter( + project_id=importer.project_id + ).delete() + GithubRepository.objects.filter( + project_id=importer.project_id + ).delete() + + # Create a Label for github + label = Label.objects.filter( + name="GitHub", project_id=importer.project_id + ).first() + + if label is None: + label = Label.objects.create( + name="GitHub", + project_id=importer.project_id, + description="Label to sync Plane issues with GitHub issues", + color="#003773", + ) + # Create repository + repo = GithubRepository.objects.create( + name=name, + url=url, + config=config, + repository_id=repository_id, + owner=owner, + project_id=importer.project_id, + ) + + # Create repo sync + _ = GithubRepositorySync.objects.create( + repository=repo, + workspace_integration=workspace_integration, + actor=workspace_integration.actor, + credentials=importer.data.get("credentials", {}), + project_id=importer.project_id, + label=label, + ) + + # Add bot as a member in the project + _ = ProjectMember.objects.get_or_create( + member=workspace_integration.actor, + role=20, + project_id=importer.project_id, + ) + + if settings.PROXY_BASE_URL: + headers = {"Content-Type": "application/json"} + import_data_json = json.dumps( + ImporterSerializer(importer).data, + cls=DjangoJSONEncoder, + ) + _ = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/", + json=import_data_json, + headers=headers, + ) + + return + except Exception as e: + importer = Importer.objects.get(pk=importer_id) + importer.status = "failed" + importer.save() + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/user_welcome_task.py b/apiserver/plane/bgtasks/user_welcome_task.py new file mode 100644 index 0000000000..33f4b56863 --- /dev/null +++ b/apiserver/plane/bgtasks/user_welcome_task.py @@ -0,0 +1,36 @@ +# Django imports +from django.conf import settings + +# Third party imports +from celery import shared_task +from sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +# Module imports +from plane.db.models import User + + +@shared_task +def send_welcome_slack(user_id, created, message): + try: + instance = User.objects.get(pk=user_id) + + if created and not instance.is_bot: + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=message, + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + return + except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) + capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index c0b945e62b..a7b15fe221 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -3,8 +3,11 @@ import logging # Third party imports from celery import shared_task +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError # Django imports +from django.conf import settings from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags @@ -15,6 +18,18 @@ from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception +def push_updated_to_slack(workspace, workspace_member_invite): + # Send message on slack as well + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=f"{workspace_member_invite.email} has been invited to {workspace.name} as a {workspace_member_invite.role}", + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + + @shared_task def workspace_invitation(email, workspace_id, token, current_site, invitor): try: @@ -80,6 +95,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): msg.send() logging.getLogger("plane").info("Email sent succesfully") + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + push_updated_to_slack(workspace, workspace_member_invite) + return except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: log_exception(e) diff --git a/apiserver/plane/db/management/commands/faker.py b/apiserver/plane/db/management/commands/faker.py new file mode 100644 index 0000000000..fb66ada57a --- /dev/null +++ b/apiserver/plane/db/management/commands/faker.py @@ -0,0 +1,79 @@ +# 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 be existing in Database" + ) + + user = User.objects.get(email=creator) + + members = input("Enter Member emails (comma separated): ") + members = members.split(",") if members != "" else [] + + 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: ") + ) + + # 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 + ) + + from plane.bgtasks.create_faker import create_fake_data + + create_fake_data.delay( + slug=workspace_slug, + email=creator, + members=members, + issue_count=issue_count, + cycle_count=cycle_count, + module_count=module_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/models/user.py b/apiserver/plane/db/models/user.py index f35520d8fa..4793e10630 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -15,6 +15,12 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from django.conf import settings + +# Third party imports +from sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError # Module imports from ..mixins import TimeAuditModel @@ -214,3 +220,23 @@ def create_user_notification(sender, instance, created, **kwargs): mention=False, issue_completed=False, ) + + +@receiver(post_save, sender=User) +def send_welcome_slack(sender, instance, created, **kwargs): + try: + if created and not instance.is_bot: + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=f"New user {instance.email} has signed up and begun the onboarding journey.", + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + return + except Exception as e: + capture_exception(e) + return diff --git a/apiserver/plane/db/mongodb.py b/apiserver/plane/db/mongodb.py new file mode 100644 index 0000000000..355e16f788 --- /dev/null +++ b/apiserver/plane/db/mongodb.py @@ -0,0 +1,23 @@ +from pymongo import MongoClient + +def singleton(cls): + instances = {} + + def wrapper(*args, **kwargs): + if cls not in instances: + instances[cls] = cls(*args, **kwargs) + return instances[cls] + + return wrapper + +@singleton +class Database: + db = None + client = None + + def __init__(self, mongo_uri, database_name): + self.client = MongoClient(mongo_uri) + self.db = self.client[database_name] + + def get_db(self): + return self.db diff --git a/apiserver/plane/license/management/commands/setup_instance.py b/apiserver/plane/license/management/commands/setup_instance.py new file mode 100644 index 0000000000..2a5b1a3c98 --- /dev/null +++ b/apiserver/plane/license/management/commands/setup_instance.py @@ -0,0 +1,74 @@ +# Python imports +import json +import secrets +import uuid + +# Django imports +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +# Module imports +from plane.license.models import Instance, InstanceAdmin +from plane.db.models import User + + +class Command(BaseCommand): + help = "Check if instance in registered else register" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("admin_email", type=str, help="Admin Email") + + def handle(self, *args, **options): + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + admin_email = options.get("admin_email", False) + + if not admin_email: + raise CommandError("admin email is required") + + user_count = User.objects.filter(is_bot=False).count() + + user = User.objects.filter(email=admin_email).first() + if user is None: + user = User.objects.create( + email=admin_email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + ) + + try: + # Check if the instance is registered + instance = Instance.objects.first() + + if instance is None: + instance = Instance.objects.create( + instance_name="Plane Enterprise", + instance_id=secrets.token_hex(12), + license_key=None, + api_key=secrets.token_hex(8), + version=data.get("version"), + last_checked_at=timezone.now(), + user_count=user_count, + is_verified=True, + is_setup_done=True, + is_signup_screen_visited=True, + ) + + # Get or create an instance admin + _, created = InstanceAdmin.objects.get_or_create( + user=user, instance=instance, role=20, is_verified=True + ) + + if not created: + self.stdout.write( + self.style.WARNING("given email is already an instance admin") + ) + + self.stdout.write(self.style.SUCCESS("Successful")) + except Exception as e: + print(e) + raise CommandError("Failure") diff --git a/apiserver/plane/middleware/api_log_middleware.py b/apiserver/plane/middleware/api_log_middleware.py index 96c62c2fd9..aab081dee6 100644 --- a/apiserver/plane/middleware/api_log_middleware.py +++ b/apiserver/plane/middleware/api_log_middleware.py @@ -38,6 +38,5 @@ class APITokenLogMiddleware: except Exception as e: print(e) - # If the token does not exist, you can decide whether to log this as an invalid attempt return None diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 7ee1bdf559..2332522c1b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -294,7 +294,7 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( # Application Envs PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) # For External - +SLACK_BOT_TOKEN = os.environ.get("SLACK_BOT_TOKEN", False) FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) # Unsplash Access key @@ -324,6 +324,9 @@ SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1" DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) +# MongoDB Settings +MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False) + # Cookie Settings SESSION_COOKIE_SECURE = secure_origins SESSION_COOKIE_HTTPONLY = True diff --git a/apiserver/plane/utils/importers/__init__.py b/apiserver/plane/utils/importers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/utils/importers/jira.py b/apiserver/plane/utils/importers/jira.py new file mode 100644 index 0000000000..6f3a7c2178 --- /dev/null +++ b/apiserver/plane/utils/importers/jira.py @@ -0,0 +1,117 @@ +import requests +import re +from requests.auth import HTTPBasicAuth +from sentry_sdk import capture_exception +from urllib.parse import urlparse, urljoin + + +def is_allowed_hostname(hostname): + allowed_domains = [ + "atl-paas.net", + "atlassian.com", + "atlassian.net", + "jira.com", + ] + parsed_uri = urlparse(f"https://{hostname}") + domain = parsed_uri.netloc.split(":")[0] # Ensures no port is included + base_domain = ".".join(domain.split(".")[-2:]) + return base_domain in allowed_domains + + +def is_valid_project_key(project_key): + if project_key: + project_key = project_key.strip().upper() + # Adjust the regular expression as needed based on your specific requirements. + if len(project_key) > 30: + return False + # Check the validity of the key as well + pattern = re.compile(r"^[A-Z0-9]{1,10}$") + return pattern.match(project_key) is not None + else: + False + + +def generate_valid_project_key(project_key): + return project_key.strip().upper() + + +def generate_url(hostname, path): + if not is_allowed_hostname(hostname): + raise ValueError("Invalid or unauthorized hostname") + return urljoin(f"https://{hostname}", path) + + +def jira_project_issue_summary(email, api_token, project_key, hostname): + try: + if not is_allowed_hostname(hostname): + return {"error": "Invalid or unauthorized hostname"} + + if not is_valid_project_key(project_key): + return {"error": "Invalid project key"} + + auth = HTTPBasicAuth(email, api_token) + headers = {"Accept": "application/json"} + + # make the project key upper case + project_key = generate_valid_project_key(project_key) + + # issues + issue_url = generate_url( + hostname, + f"/rest/api/3/search?jql=project={project_key} AND issuetype!=Epic", + ) + issue_response = requests.request( + "GET", issue_url, headers=headers, auth=auth + ).json()["total"] + + # modules + module_url = generate_url( + hostname, + f"/rest/api/3/search?jql=project={project_key} AND issuetype=Epic", + ) + module_response = requests.request( + "GET", module_url, headers=headers, auth=auth + ).json()["total"] + + # status + status_url = generate_url( + hostname, f"/rest/api/3/project/${project_key}/statuses" + ) + status_response = requests.request( + "GET", status_url, headers=headers, auth=auth + ).json() + + # labels + labels_url = generate_url( + hostname, f"/rest/api/3/label/?jql=project={project_key}" + ) + labels_response = requests.request( + "GET", labels_url, headers=headers, auth=auth + ).json()["total"] + + # users + users_url = generate_url( + hostname, f"/rest/api/3/users/search?jql=project={project_key}" + ) + users_response = requests.request( + "GET", users_url, headers=headers, auth=auth + ).json() + + return { + "issues": issue_response, + "modules": module_response, + "labels": labels_response, + "states": len(status_response), + "users": ( + [ + user + for user in users_response + if user.get("accountType") == "atlassian" + ] + ), + } + except Exception as e: + capture_exception(e) + return { + "error": "Something went wrong could not fetch information from jira" + } diff --git a/apiserver/plane/utils/integrations/__init__.py b/apiserver/plane/utils/integrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py new file mode 100644 index 0000000000..5a7ce2aa29 --- /dev/null +++ b/apiserver/plane/utils/integrations/github.py @@ -0,0 +1,154 @@ +import os +import jwt +import requests +from urllib.parse import urlparse, parse_qs +from datetime import datetime, timedelta +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.backends import default_backend +from django.conf import settings + + +def get_jwt_token(): + app_id = os.environ.get("GITHUB_APP_ID", "") + secret = bytes( + os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8" + ) + current_timestamp = int(datetime.now().timestamp()) + due_date = datetime.now() + timedelta(minutes=10) + expiry = int(due_date.timestamp()) + payload = { + "iss": app_id, + "sub": app_id, + "exp": expiry, + "iat": current_timestamp, + "aud": "https://github.com/login/oauth/access_token", + } + + priv_rsakey = load_pem_private_key(secret, None, default_backend()) + token = jwt.encode(payload, priv_rsakey, algorithm="RS256") + return token + + +def get_github_metadata(installation_id): + token = get_jwt_token() + + url = f"https://api.github.com/app/installations/{installation_id}" + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github+json", + } + response = requests.get(url, headers=headers).json() + return response + + +def get_github_repos(access_tokens_url, repositories_url): + token = get_jwt_token() + + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github+json", + } + + oauth_response = requests.post( + access_tokens_url, + headers=headers, + ).json() + + oauth_token = oauth_response.get("token", "") + headers = { + "Authorization": "Bearer " + str(oauth_token), + "Accept": "application/vnd.github+json", + } + response = requests.get( + repositories_url, + headers=headers, + ).json() + return response + + +def delete_github_installation(installation_id): + token = get_jwt_token() + + url = f"https://api.github.com/app/installations/{installation_id}" + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github+json", + } + response = requests.delete(url, headers=headers) + return response + + +def get_github_repo_details(access_tokens_url, owner, repo): + token = get_jwt_token() + + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + oauth_response = requests.post( + access_tokens_url, + headers=headers, + ).json() + + oauth_token = oauth_response.get("token") + headers = { + "Authorization": "Bearer " + oauth_token, + "Accept": "application/vnd.github+json", + } + open_issues = requests.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers=headers, + ).json()["open_issues_count"] + + total_labels = 0 + + labels_response = requests.get( + f"https://api.github.com/repos/{owner}/{repo}/labels?per_page=100&page=1", + headers=headers, + ) + + # Check if there are more pages + if len(labels_response.links.keys()): + # get the query parameter of last + last_url = labels_response.links.get("last").get("url") + parsed_url = urlparse(last_url) + last_page_value = parse_qs(parsed_url.query)["page"][0] + total_labels = total_labels + 100 * (int(last_page_value) - 1) + + # Get labels in last page + last_page_labels = requests.get(last_url, headers=headers).json() + total_labels = total_labels + len(last_page_labels) + else: + total_labels = len(labels_response.json()) + + # Currently only supporting upto 100 collaborators + # TODO: Update this function to fetch all collaborators + collaborators = requests.get( + f"https://api.github.com/repos/{owner}/{repo}/collaborators?per_page=100&page=1", + headers=headers, + ).json() + + return open_issues, total_labels, collaborators + + +def get_release_notes(): + token = settings.GITHUB_ACCESS_TOKEN + + if token: + headers = { + "Authorization": "Bearer " + str(token), + "Accept": "application/vnd.github.v3+json", + } + else: + headers = { + "Accept": "application/vnd.github.v3+json", + } + url = "https://api.github.com/repos/makeplane/plane/releases?per_page=5&page=1" + response = requests.get(url, headers=headers) + + if response.status_code != 200: + return {"error": "Unable to render information from Github Repository"} + + return response.json() diff --git a/apiserver/plane/utils/integrations/slack.py b/apiserver/plane/utils/integrations/slack.py new file mode 100644 index 0000000000..0cc5b93b27 --- /dev/null +++ b/apiserver/plane/utils/integrations/slack.py @@ -0,0 +1,21 @@ +import os +import requests + + +def slack_oauth(code): + SLACK_OAUTH_URL = os.environ.get("SLACK_OAUTH_URL", False) + SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", False) + SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", False) + + # Oauth Slack + if SLACK_OAUTH_URL and SLACK_CLIENT_ID and SLACK_CLIENT_SECRET: + response = requests.get( + SLACK_OAUTH_URL, + params={ + "code": code, + "client_id": SLACK_CLIENT_ID, + "client_secret": SLACK_CLIENT_SECRET, + }, + ) + return response.json() + return {} diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index a6bd2ab50d..e55ef50bc5 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -9,6 +9,8 @@ psycopg==3.1.18 psycopg-binary==3.1.18 psycopg-c==3.1.18 dj-database-url==2.1.0 +# mongo +pymongo==4.6.1 # redis redis==5.0.4 django-redis==5.4.0 @@ -60,4 +62,4 @@ zxcvbn==4.4.28 # timezone pytz==2024.1 # jwt -PyJWT==2.8.0 \ No newline at end of file +PyJWT==2.8.0 diff --git a/deploy/cli-install/Caddyfile b/deploy/cli-install/Caddyfile new file mode 100644 index 0000000000..1079e1d2eb --- /dev/null +++ b/deploy/cli-install/Caddyfile @@ -0,0 +1,22 @@ +{ + email {$CERT_EMAIL} + {$CERT_ACME_DNS} +} + +{$APP_PROTOCOL}://{$DOMAIN_NAME} { + request_body { + max_size {$FILE_SIZE_LIMIT} + } + + reverse_proxy /spaces/* space:3000 + + reverse_proxy /god-mode/* admin:3000 + + reverse_proxy /api/* api:8000 + + reverse_proxy /auth/* api:8000 + + reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000 + + reverse_proxy /* web:3000 +} diff --git a/deploy/cli-install/docker-compose-caddy.yml b/deploy/cli-install/docker-compose-caddy.yml new file mode 100644 index 0000000000..82c2a7d7c7 --- /dev/null +++ b/deploy/cli-install/docker-compose-caddy.yml @@ -0,0 +1,173 @@ +x-proxy-env: &proxy-env + environment: + - DOMAIN_NAME=${DOMAIN_NAME:-localhost} + - CERT_EMAIL=${CERT_EMAIL:-admin@localhost} + - APP_PROTOCOL=${APP_PROTOCOL:-http} + - CERT_ACME_DNS=${CERT_ACME_DNS:-} + - BUCKET_NAME=${BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - LISTEN_HTTP_PORT=${LISTEN_HTTP_PORT:-80} + - LISTEN_HTTPS_PORT=${LISTEN_HTTPS_PORT:-443} + +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_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} + # Gunicorn Workers + - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} + #DB SETTINGS + - PGHOST=${PGHOST:-plane-db} + - PGDATABASE=${PGDATABASE:-plane} + - POSTGRES_USER=${POSTGRES_USER:-plane} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} + - POSTGRES_DB=${POSTGRES_DB:-plane} + - PGDATA=${PGDATA:-/var/lib/postgresql/data} + - 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://plane-redis:6379/} + # Application secret + - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + # DATA STORE SETTINGS + - USE_MINIO=${USE_MINIO:-1} + - AWS_REGION=${AWS_REGION:-""} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"} + - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"} + - BUCKET_NAME=${BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + +services: + + admin: + <<: *app-env + image: registry.plane.tools/plane/admin-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: node admin/server.js admin + deploy: + replicas: ${ADMIN_REPLICAS:-1} + depends_on: + - api + - web + + web: + <<: *app-env + image: registry.plane.tools/plane/web-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: /usr/local/bin/start.sh web/server.js web + deploy: + replicas: ${WEB_REPLICAS:-1} + depends_on: + - api + - worker + + space: + <<: *app-env + image: registry.plane.tools/plane/space-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: /usr/local/bin/start.sh space/server.js space + deploy: + replicas: ${SPACE_REPLICAS:-1} + depends_on: + - api + - worker + - web + + api: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: ./bin/takeoff + deploy: + replicas: ${API_REPLICAS:-1} + # volumes: + # - ${INSTALL_DIR}/logs/api:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + worker: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: ./bin/worker + # volumes: + # - ${INSTALL_DIR}/logs/worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + + beat-worker: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: ./bin/beat + # volumes: + # - ${INSTALL_DIR}/logs/beat-worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + + migrator: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: no + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate" + # volumes: + # - ${INSTALL_DIR}/logs/migrator:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + plane-db: + <<: *app-env + image: registry.plane.tools/plane/postgres:15.5-alpine + restart: unless-stopped + command: postgres -c 'max_connections=1000' + volumes: + - ${INSTALL_DIR}/data/db:/var/lib/postgresql/data + + plane-redis: + <<: *app-env + image: registry.plane.tools/plane/redis:7.2.4-alpine + restart: unless-stopped + volumes: + - ${INSTALL_DIR}/data/redis:/data + + plane-minio: + <<: *app-env + image: registry.plane.tools/plane/minio:latest + restart: unless-stopped + command: server /export --console-address ":9090" + volumes: + - ${INSTALL_DIR}/data/minio/uploads:/export + - ${INSTALL_DIR}/data/minio/data:/data + + # Comment this if you already have a reverse proxy running + proxy: + <<: *proxy-env + image: registry.plane.tools/plane/caddy:latest + restart: unless-stopped + ports: + - ${LISTEN_HTTP_PORT:-80}:80 + - ${LISTEN_HTTPS_PORT:-443}:443 + volumes: + - ${INSTALL_DIR}/Caddyfile:/etc/caddy/Caddyfile + - ${INSTALL_DIR}/caddy/config:/config + - ${INSTALL_DIR}/caddy/data:/data + depends_on: + - web + - api + - space diff --git a/deploy/cli-install/docker-compose.yml b/deploy/cli-install/docker-compose.yml new file mode 100644 index 0000000000..c0dd06f53d --- /dev/null +++ b/deploy/cli-install/docker-compose.yml @@ -0,0 +1,168 @@ +# 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_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} + # Gunicorn Workers + - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} + #DB SETTINGS + - PGHOST=${PGHOST:-plane-db} + - PGDATABASE=${PGDATABASE:-plane} + - POSTGRES_USER=${POSTGRES_USER:-plane} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} + - POSTGRES_DB=${POSTGRES_DB:-plane} + - PGDATA=${PGDATA:-/var/lib/postgresql/data} + - 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://plane-redis:6379/} + # Application secret + - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + # DATA STORE SETTINGS + - USE_MINIO=${USE_MINIO:-1} + - AWS_REGION=${AWS_REGION:-""} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"} + - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"} + - BUCKET_NAME=${BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + +services: + + admin: + <<: *app-env + image: registry.plane.tools/plane/admin-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: node admin/server.js admin + deploy: + replicas: ${ADMIN_REPLICAS:-1} + depends_on: + - api + - web + + web: + <<: *app-env + image: registry.plane.tools/plane/web-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: unless-stopped + command: /usr/local/bin/start.sh web/server.js web + deploy: + replicas: ${WEB_REPLICAS:-1} + depends_on: + - api + - worker + + space: + <<: *app-env + image: registry.plane.tools/plane/space-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: unless-stopped + command: /usr/local/bin/start.sh space/server.js space + deploy: + replicas: ${SPACE_REPLICAS:-1} + depends_on: + - api + - worker + - web + + api: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: unless-stopped + command: ./bin/takeoff + deploy: + replicas: ${API_REPLICAS:-1} + # volumes: + # - ${INSTALL_DIR}/logs/api:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + worker: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: unless-stopped + command: ./bin/worker + # volumes: + # - ${INSTALL_DIR}/logs/worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + + beat-worker: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: unless-stopped + command: ./bin/beat + # volumes: + # - ${INSTALL_DIR}/logs/beat-worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + + migrator: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: no + command: > + sh -c "python manage.py wait_for_db && + python manage.py migrate" + # volumes: + # - ${INSTALL_DIR}/logs/migrator:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + plane-db: + <<: *app-env + image: registry.plane.tools/plane/postgres:15.5-alpine + pull_policy: if_not_present + restart: unless-stopped + command: postgres -c 'max_connections=1000' + volumes: + - ${INSTALL_DIR}/data/db:/var/lib/postgresql/data + plane-redis: + <<: *app-env + image: registry.plane.tools/plane/redis:7.2.4-alpine + pull_policy: if_not_present + restart: unless-stopped + volumes: + - ${INSTALL_DIR}/data/redis:/data + + plane-minio: + <<: *app-env + image: registry.plane.tools/plane/minio:latest + pull_policy: if_not_present + restart: unless-stopped + command: server /export --console-address ":9090" + volumes: + - ${INSTALL_DIR}/data/minio/uploads:/export + - ${INSTALL_DIR}/data/minio/data:/data + + # Comment this if you already have a reverse proxy running + proxy: + <<: *app-env + image: registry.plane.tools/plane/proxy-enterprise:${APP_RELEASE_VERSION} + pull_policy: if_not_present + restart: unless-stopped + ports: + - ${NGINX_PORT}:80 + depends_on: + - web + - api + - space diff --git a/deploy/cli-install/variables.env b/deploy/cli-install/variables.env new file mode 100644 index 0000000000..8490ea273d --- /dev/null +++ b/deploy/cli-install/variables.env @@ -0,0 +1,56 @@ +INSTALL_DIR=/opt/plane + +WEB_REPLICAS=1 +SPACE_REPLICAS=1 +API_REPLICAS=1 + +NGINX_PORT=80 +LISTEN_HTTP_PORT=80 +LISTEN_HTTPS_PORT=443 + +APP_PROTOCOL=http + +# If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https +CERT_EMAIL=admin@localhost + +# For DNS Challenge based certificate generation, set the CERT_ACME_DNS +# CERT_ACME_DNS=acme_dns CERT_DNS_PROVIDER CERT_DNS_PROVIDER_API_KEY +CERT_ACME_DNS= + +WEB_URL=http://localhost +DEBUG=0 +SENTRY_DSN= +SENTRY_ENVIRONMENT=production +CORS_ALLOWED_ORIGINS=http://localhost + +#DB SETTINGS +PGHOST=plane-db +PGDATABASE=plane +POSTGRES_USER=plane +POSTGRES_PASSWORD=plane +POSTGRES_DB=plane +PGDATA=/var/lib/postgresql/data +DATABASE_URL= + +# REDIS SETTINGS +REDIS_HOST=plane-redis +REDIS_PORT=6379 +REDIS_URL= + +# Secret Key +SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 + +# DATA STORE SETTINGS +USE_MINIO=1 +AWS_REGION= +AWS_ACCESS_KEY_ID=access-key +AWS_SECRET_ACCESS_KEY=secret-key +AWS_S3_ENDPOINT_URL=http://plane-minio:9000 +AWS_S3_BUCKET_NAME=uploads +MINIO_ROOT_USER=access-key +MINIO_ROOT_PASSWORD=secret-key +BUCKET_NAME=uploads +FILE_SIZE_LIMIT=5242880 + +# Gunicorn Workers +GUNICORN_WORKERS=2 diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 6f12abd61b..5b6ae2ee82 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -59,13 +59,13 @@ services: - api - worker - web - + admin: <<: *app-env image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable} pull_policy: ${PULL_POLICY:-always} restart: unless-stopped - command: node admin/server.js admin + command: node admin/server.js admin deploy: replicas: ${ADMIN_REPLICAS:-1} depends_on: diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 3dce85f3aa..daae53b726 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -6,6 +6,7 @@ volumes: redisdata: uploads: pgdata: + mongodbdata: services: @@ -43,6 +44,16 @@ services: - .env environment: PGDATA: /var/lib/postgresql/data + + plane-mongodb: + image: mongo:7.0.5 + restart: unless-stopped + networks: + - dev_env + volumes: + - mongodbdata:/data/db + env_file: + - .env web: build: diff --git a/docker-compose.yml b/docker-compose.yml index bf80660556..a647fab316 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,15 @@ services: depends_on: - plane-db - plane-redis + + mongodb: + image: "mongo" + restart: unless-stopped + volumes: + - "mongodb_data:/data/db" + environment: + - MONGO_INITDB_ROOT_USERNAME=${MONGO_INITDB_ROOT_USERNAME:-plane} + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_INITDB_ROOT_PASSWORD:-plane} worker: container_name: bgworker @@ -157,3 +166,4 @@ volumes: pgdata: redisdata: uploads: + mongodb_data: diff --git a/generate_release_notes.sh b/generate_release_notes.sh new file mode 100644 index 0000000000..2d7b96e146 --- /dev/null +++ b/generate_release_notes.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# Initialize temporary files for each category +FEATURES_FILE=$(mktemp) +IMPROVEMENTS_FILE=$(mktemp) +BUGS_FILE=$(mktemp) +OTHERS_FILE=$(mktemp) + +FEATURES_COUNT=0 +IMPROVEMENTS_COUNT=0 +BUGS_COUNT=0 +OTHERS_COUNT=0 + +# Check if there are any tags in the repository +if git describe --tags --abbrev=0 > /dev/null 2>&1; then + # Fetch all commits from the last tag to HEAD + COMMITS=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"%s|%h") +else + # If no tags are found, list all commits + COMMITS=$(git log --pretty=format:"%s|%h") +fi + +# Save IFS and set it to newline to handle commits correctly +OLD_IFS=$IFS +IFS=$'\n' + +# Loop through each commit to categorize +for commit in $COMMITS; do + IFS="|" read -r commit_message hash <<< "$commit" + + # Normalize commit message to handle case sensitivity + normalized_message=$(echo "$commit_message" | tr '[:upper:]' '[:lower:]') + + # Skip commits that start with "merge" or "chore" or do not contain a PR number + if echo "$normalized_message" | grep -qE '^(merge|chore)'; then + continue + fi + + # Extract PR number if present + PR_NUMBER=$(echo $commit_message | grep -o -E "#[0-9]+" || echo "") + if [[ -z "$PR_NUMBER" ]]; then + continue # Skip commits without a PR number + fi + + # Format the commit message + CLEAN_MESSAGE=$(echo $commit_message | sed -E "s/#[0-9]+//; s/^(feat|refactor|fix|chore): //I; s/^([Ff]eat|[Rr]efactor|[Ff]ix|[Cc]hore) //I; s/^\[.*\] //; s/[:\-] / /; s/\(\) //") + CLEAN_MESSAGE="$(tr '[:lower:]' '[:upper:]' <<< ${CLEAN_MESSAGE:0:1})${CLEAN_MESSAGE:1}." + CLEAN_MESSAGE=$(echo $CLEAN_MESSAGE | sed 's/()//g') # Remove empty brackets + + # Categorize and limit the number of commits under each heading + if [[ $FEATURES_COUNT -lt 30 && $normalized_message =~ ^feat ]]; then + echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$FEATURES_FILE" + ((FEATURES_COUNT++)) + elif [[ $IMPROVEMENTS_COUNT -lt 30 && $normalized_message =~ ^refactor ]]; then + echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$IMPROVEMENTS_FILE" + ((IMPROVEMENTS_COUNT++)) + elif [[ $BUGS_COUNT -lt 30 && $normalized_message =~ ^fix ]]; then + echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$BUGS_FILE" + ((BUGS_COUNT++)) + elif [[ $OTHERS_COUNT -lt 30 ]]; then + echo "- $CLEAN_MESSAGE $PR_NUMBER" >> "$OTHERS_FILE" + ((OTHERS_COUNT++)) + fi +done + +# Restore IFS +IFS=$OLD_IFS + +# Generate the release notes by concatenating the temporary files +{ + echo '## What Changed' + echo "## Features" + cat "$FEATURES_FILE" + echo "## Improvements" + cat "$IMPROVEMENTS_FILE" + echo "## Bugs" + cat "$BUGS_FILE" + echo "## Others" + cat "$OTHERS_FILE" +} > RELEASE_NOTES.md + +# Clean up temporary files +rm "$FEATURES_FILE" "$IMPROVEMENTS_FILE" "$BUGS_FILE" "$OTHERS_FILE" + +echo "Release notes generated in RELEASE_NOTES.md" diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index 00312cb00f..5a609c3d35 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -390,3 +390,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] { margin-top: 0; } /* end tailwind typography */ + +.ProseMirror .issue-embed img { + margin: 0 !important; +} diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 3d46b58404..a20714c75e 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -14,10 +14,7 @@ export function CoreEditorProps(editorClassName: string): EditorProps { // prevent default event listeners from firing when slash command is active if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); - if (slashCommand) { - console.log("registered"); - return true; - } + if (slashCommand) return true; } }, }, diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index f8eea14ce7..863fc4ea1b 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -7,3 +7,5 @@ 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"; + +export type { TEmbedItem } from "src/ui/extensions/widgets/issue-embed/block/types"; diff --git a/packages/editor/document-editor/src/ui/extensions/extensions.tsx b/packages/editor/document-editor/src/ui/extensions/extensions.tsx new file mode 100644 index 0000000000..1af23124dd --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/extensions.tsx @@ -0,0 +1,70 @@ +import Placeholder from "@tiptap/extension-placeholder"; +// plane imports +import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; +import { UploadImage, ISlashCommandItem } from "@plane/editor-core"; +// ui +import { LayersIcon } from "@plane/ui"; +import { IssueEmbedSuggestions, IssueWidget, IssueListRenderer, TIssueEmbedConfig } from "src/ui/extensions"; + +export const DocumentEditorExtensions = ( + uploadFile: UploadImage, + setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void, + issueEmbedConfig?: TIssueEmbedConfig +) => { + const additionalOptions: ISlashCommandItem[] = [ + { + key: "issue_embed", + title: "Issue embed", + description: "Embed an issue from the project.", + searchTerms: ["issue", "link", "embed"], + icon: , + command: ({ editor, range }) => { + editor + .chain() + .focus() + .insertContentAt( + range, + "

#issue_

" + ) + .run(); + }, + }, + ]; + + const extensions = [ + SlashCommand(uploadFile, additionalOptions), + DragAndDrop(setHideDragHandle), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return `Heading ${node.attrs.level}`; + } + if (node.type.name === "image" || node.type.name === "table") { + return ""; + } + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + ]; + + if (issueEmbedConfig) { + extensions.push( + // TODO: check this + // @ts-expect-error resolve this + IssueWidget({ + widgetCallback: issueEmbedConfig.widgetCallback, + }).configure({ + issueEmbedConfig, + }), + IssueEmbedSuggestions.configure({ + suggestion: { + render: () => IssueListRenderer(issueEmbedConfig.searchCallback), + }, + }) + ); + } + + return extensions; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/index.ts b/packages/editor/document-editor/src/ui/extensions/index.ts new file mode 100644 index 0000000000..74c05a8e4d --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/index.ts @@ -0,0 +1,2 @@ +export * from "./widgets"; +export * from "./extensions"; diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx deleted file mode 100644 index b2816974e1..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget"; - -import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; -import { UploadImage } from "@plane/editor-core"; - -type TArguments = { - uploadFile: UploadImage; - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; -}; - -export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ - SlashCommand(uploadFile), - DragAndDrop(setHideDragHandle), - IssueWidgetPlaceholder(), -]; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/index.ts b/packages/editor/document-editor/src/ui/extensions/widgets/index.ts new file mode 100644 index 0000000000..f30596cb00 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/index.ts @@ -0,0 +1 @@ +export * from "./issue-embed"; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx deleted file mode 100644 index 35a09bcc29..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Editor, Range } from "@tiptap/react"; -import { IssueEmbedSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension"; -import { getIssueSuggestionItems } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items"; -import { IssueListRenderer } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer"; -import { v4 as uuidv4 } from "uuid"; - -export type CommandProps = { - editor: Editor; - range: Range; -}; - -export interface IIssueListSuggestion { - title: string; - priority: "high" | "low" | "medium" | "urgent"; - identifier: string; - state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog"; - command: ({ editor, range }: CommandProps) => void; -} - -export const IssueSuggestions = (suggestions: any[]) => { - const mappedSuggestions: IIssueListSuggestion[] = suggestions.map((suggestion): IIssueListSuggestion => { - const transactionId = uuidv4(); - return { - title: suggestion.name, - priority: suggestion.priority.toString(), - identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`, - state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo", - command: ({ editor, range }) => { - editor - .chain() - .focus() - .insertContentAt(range, { - type: "issue-embed-component", - attrs: { - entity_identifier: suggestion.id, - id: transactionId, - title: suggestion.name, - project_identifier: suggestion.project_detail.identifier, - sequence_id: suggestion.sequence_id, - entity_name: "issue", - }, - }) - .run(); - }, - }; - }); - - return IssueEmbedSuggestions.configure({ - suggestion: { - items: getIssueSuggestionItems(mappedSuggestions), - render: IssueListRenderer, - }, - }); -}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items.tsx deleted file mode 100644 index df468f2eea..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { IIssueListSuggestion } from "src/ui/extensions/widgets/issue-embed-suggestion-list"; - -export const getIssueSuggestionItems = - (issueSuggestions: Array) => - ({ query }: { query: string }) => { - const search = query.toLowerCase(); - const filteredSuggestions = issueSuggestions.filter( - (item) => - item.title.toLowerCase().includes(search) || - item.identifier.toLowerCase().includes(search) || - item.priority.toLowerCase().includes(search) - ); - - return filteredSuggestions; - }; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx deleted file mode 100644 index e586bfd80c..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { cn } from "@plane/editor-core"; -import { Editor } from "@tiptap/core"; -import tippy from "tippy.js"; -import { ReactRenderer } from "@tiptap/react"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { PriorityIcon } from "@plane/ui"; - -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 = top - containerHeight; - item.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } else if (bottom > containerHeight + container.scrollTop) { - // container.scrollTop = bottom - containerHeight; - item.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } -}; -interface IssueSuggestionProps { - title: string; - priority: "high" | "low" | "medium" | "urgent" | "none"; - state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog"; - identifier: string; -} - -const IssueSuggestionList = ({ - items, - command, - editor, -}: { - items: IssueSuggestionProps[]; - command: any; - editor: Editor; - range: any; -}) => { - const [selectedIndex, setSelectedIndex] = useState(0); - const [currentSection, setCurrentSection] = useState("Backlog"); - const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"]; - const [displayedItems, setDisplayedItems] = useState<{ - [key: string]: IssueSuggestionProps[]; - }>({}); - const [displayedTotalLength, setDisplayedTotalLength] = useState(0); - const commandListContainer = useRef(null); - - useEffect(() => { - const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; - let totalLength = 0; - sections.forEach((section) => { - newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5); - - totalLength += newDisplayedItems[section].length; - }); - setDisplayedTotalLength(totalLength); - setDisplayedItems(newDisplayedItems); - }, [items]); - - const selectItem = useCallback( - (section: string, index: number) => { - const item = displayedItems[section][index]; - if (item) { - command(item); - } - }, - [command, displayedItems, currentSection] - ); - - useEffect(() => { - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; - const onKeyDown = (e: KeyboardEvent) => { - if (navigationKeys.includes(e.key)) { - // if (editor.isFocused) { - // editor.chain().blur(); - // commandListContainer.current?.focus(); - // } - if (e.key === "ArrowUp") { - setSelectedIndex( - (selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length - ); - return true; - } - if (e.key === "ArrowDown") { - const nextIndex = (selectedIndex + 1) % displayedItems[currentSection].length; - setSelectedIndex(nextIndex); - if (nextIndex === 4) { - const nextItems = items - .filter((item) => item.state === currentSection) - .slice(displayedItems[currentSection].length, displayedItems[currentSection].length + 5); - setDisplayedItems((prevItems) => ({ - ...prevItems, - [currentSection]: [...prevItems[currentSection], ...nextItems], - })); - } - return true; - } - if (e.key === "Enter") { - selectItem(currentSection, selectedIndex); - return true; - } - if (e.key === "Tab") { - const currentSectionIndex = sections.indexOf(currentSection); - const nextSectionIndex = (currentSectionIndex + 1) % sections.length; - setCurrentSection(sections[nextSectionIndex]); - setSelectedIndex(0); - return true; - } - return false; - } else if (e.key === "Escape") { - if (!editor.isFocused) { - editor.chain().focus(); - } - } - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [displayedItems, selectedIndex, setSelectedIndex, selectItem, currentSection]); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - if (container) { - const sectionContainer = container?.querySelector(`#${currentSection}-container`) as HTMLDivElement; - if (sectionContainer) { - updateScrollView(container, sectionContainer); - } - const sectionScrollContainer = container?.querySelector(`#${currentSection}`) as HTMLElement; - const item = sectionScrollContainer?.children[selectedIndex] as HTMLElement; - if (item && sectionScrollContainer) { - updateScrollView(sectionScrollContainer, item); - } - } - }, [selectedIndex, currentSection]); - - return displayedTotalLength > 0 ? ( -
- {sections.map((section) => { - const sectionItems = displayedItems[section]; - return ( - sectionItems && - sectionItems.length > 0 && ( -
-
- {section} -
-
- {sectionItems.map((item: IssueSuggestionProps, index: number) => ( - - ))} -
-
- ) - ); - })} -
- ) : null; -}; -export const IssueListRenderer = () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; - - return { - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - const container = document.querySelector(".frame-renderer") as HTMLElement; - component = new ReactRenderer(IssueSuggestionList, { - props, - // @ts-ignore - editor: props.editor, - }); - // @ts-ignore - popup = tippy(".frame-renderer", { - flipbehavior: ["bottom", "top"], - appendTo: () => document.querySelector(".frame-renderer") as HTMLElement, - flip: true, - flipOnUpdate: true, - getReferenceClientRect: props.clientRect, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - - container.addEventListener("scroll", () => { - popup?.[0].destroy(); - }); - }, - onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component?.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", "Tab"]; - if (navigationKeys.includes(props.event.key)) { - // @ts-ignore - component?.ref?.onKeyDown(props); - return true; - } - return false; - }, - onExit: (e) => { - const container = document.querySelector(".frame-renderer") as HTMLElement; - if (container) { - container.removeEventListener("scroll", () => {}); - } - popup?.[0].destroy(); - setTimeout(() => { - component?.destroy(); - }, 300); - }, - }; -}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx deleted file mode 100644 index 264a701521..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node"; - -export const IssueWidgetPlaceholder = () => IssueWidget.configure({}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx deleted file mode 100644 index d3b6fd04f7..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck -import { Button } from "@plane/ui"; -import { NodeViewWrapper } from "@tiptap/react"; -import { Crown } from "lucide-react"; - -export const IssueWidgetCard = (props) => ( - -
-
- {props.node.attrs.project_identifier}-{props.node.attrs.sequence_id} -
-
-
-
-
- -
-
- Embed and access issues in pages seamlessly, upgrade to plane pro now. -
-
- - - -
-
-
-
-); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx deleted file mode 100644 index 6c744927ad..0000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; -import { IssueWidgetCard } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-card"; -import { ReactNodeViewRenderer } from "@tiptap/react"; - -export const IssueWidget = Node.create({ - name: "issue-embed-component", - group: "block", - atom: true, - - addAttributes() { - return { - id: { - default: null, - }, - class: { - default: "w-[600px]", - }, - title: { - default: null, - }, - entity_name: { - default: null, - }, - entity_identifier: { - default: null, - }, - project_identifier: { - default: null, - }, - sequence_id: { - default: null, - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer((props: Object) => ); - }, - - parseHTML() { - return [ - { - tag: "issue-embed-component", - getAttrs: (node: string | HTMLElement) => { - if (typeof node === "string") { - return null; - } - return { - id: node.getAttribute("id") || "", - title: node.getAttribute("title") || "", - entity_name: node.getAttribute("entity_name") || "", - entity_identifier: node.getAttribute("entity_identifier") || "", - project_identifier: node.getAttribute("project_identifier") || "", - sequence_id: node.getAttribute("sequence_id") || "", - }; - }, - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; - }, -}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/index.ts b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/index.ts new file mode 100644 index 0000000000..77e7ee9a86 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/index.ts @@ -0,0 +1,2 @@ +export * from "./issue-widget-node"; +export * from "./types"; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/issue-widget-node.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/issue-widget-node.tsx new file mode 100644 index 0000000000..2b7854996b --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/issue-widget-node.tsx @@ -0,0 +1,54 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; + +type Props = { + widgetCallback: (issueId: string) => React.ReactNode; +}; + +export const IssueWidget = (props: Props) => + Node.create({ + name: "issue-embed-component", + group: "block", + atom: true, + + addAttributes() { + return { + entity_identifier: { + default: undefined, + }, + id: { + default: undefined, + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer((issueProps: any) => ( + {props.widgetCallback(issueProps.node.attrs.entity_identifier)} + )); + }, + + parseHTML() { + return [ + { + tag: "issue-embed-component", + getAttrs: (node: string | HTMLElement) => { + if (typeof node === "string") { + return null; + } + return { + id: node.getAttribute("id") || "", + title: node.getAttribute("title") || "", + entity_name: node.getAttribute("entity_name") || "", + entity_identifier: node.getAttribute("entity_identifier") || "", + project_identifier: node.getAttribute("project_identifier") || "", + sequence_id: node.getAttribute("sequence_id") || "", + }; + }, + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; + }, + }); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/types.ts b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/types.ts new file mode 100644 index 0000000000..658561fe74 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/block/types.ts @@ -0,0 +1,19 @@ +export type TEmbedConfig = { + issue?: TIssueEmbedConfig; +}; + +export type TReadOnlyEmbedConfig = { + issue?: Omit; +}; + +export type TIssueEmbedConfig = { + searchCallback: (searchQuery: string) => Promise; + widgetCallback: (issueId: string) => React.ReactNode; +}; + +export type TEmbedItem = { + id: string; + title: string; + subTitle: string; + icon: React.ReactNode; +}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/index.ts b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/index.ts new file mode 100644 index 0000000000..a8f542a193 --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/index.ts @@ -0,0 +1,2 @@ +export * from "./block"; +export * from "./suggestions-list"; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/index.ts b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/index.ts new file mode 100644 index 0000000000..30cdd6882f --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/index.ts @@ -0,0 +1,2 @@ +export * from "./issue-suggestion-extension"; +export * from "./issue-suggestion-renderer"; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/issue-suggestion-extension.tsx similarity index 99% rename from packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx rename to packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/issue-suggestion-extension.tsx index 96a5c1325b..5cab7d9f27 100644 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/issue-suggestion-extension.tsx @@ -17,6 +17,7 @@ export const IssueEmbedSuggestions = Extension.create({ }, }; }, + addProseMirrorPlugins() { return [ Suggestion({ diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/issue-suggestion-renderer.tsx new file mode 100644 index 0000000000..6fb4d7152d --- /dev/null +++ b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed/suggestions-list/issue-suggestion-renderer.tsx @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useState } from "react"; +import { Editor } from "@tiptap/core"; +import { ReactRenderer, Range } from "@tiptap/react"; +import tippy from "tippy.js"; +import { v4 as uuidv4 } from "uuid"; +// core +import { cn } from "@plane/editor-core"; +// types +import { TEmbedItem } from "src/ui/extensions"; + +type TSuggestionsListProps = { + editor: Editor; + searchCallback: (searchQuery: string) => Promise; + query: string; + range: Range; +}; + +const IssueSuggestionList = (props: TSuggestionsListProps) => { + const { editor, searchCallback, query, range } = props; + // states + const [items, setItems] = useState(undefined); + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = useCallback( + (item: TEmbedItem) => { + try { + const docSize = editor.state.doc.content.size; + if (range.from < 0 || range.to >= docSize) return; + + const transactionId = uuidv4(); + editor + .chain() + .focus() + .insertContentAt(range, { + type: "issue-embed-component", + attrs: { + entity_identifier: item?.id, + id: transactionId, + }, + }) + .run(); + } catch (error) { + console.log("Error inserting issue embed", error); + } + }, + [editor, range] + ); + + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; + const onKeyDown = (e: KeyboardEvent) => { + if (!items) return; + + if (navigationKeys.includes(e.key)) { + if (e.key === "ArrowUp") { + const newIndex = selectedIndex - 1; + setSelectedIndex(newIndex < 0 ? items.length - 1 : newIndex); + return true; + } + if (e.key === "ArrowDown") { + const newIndex = selectedIndex + 1; + setSelectedIndex(newIndex >= items.length ? 0 : newIndex); + return true; + } + if (e.key === "Enter") { + const item = items[selectedIndex]; + selectItem(item); + return true; + } + return false; + } else if (e.key === "Escape") { + if (!editor.isFocused) editor.chain().focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [editor, items, selectedIndex, setSelectedIndex, selectItem]); + + useEffect(() => { + setItems(undefined); + searchCallback(query).then((data) => { + setItems(data); + }); + }, [query, searchCallback]); + + return ( +
+ {items ? ( + items.length > 0 ? ( + items.map((item, index) => ( + + )) + ) : ( +
No results found
+ ) + ) : ( +
Loading
+ )} +
+ ); +}; + +export const IssueListRenderer = (searchCallback: (searchQuery: string) => Promise) => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container"); + + component = new ReactRenderer(IssueSuggestionList, { + props: { + ...props, + searchCallback, + }, + editor: props.editor, + }); + // @ts-expect-error Tippy overloads are messed up + popup = tippy("body", { + flipbehavior: ["bottom", "top"], + appendTo: tippyContainer, + flip: true, + flipOnUpdate: true, + getReferenceClientRect: props.clientRect, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + + tippyContainer?.addEventListener("scroll", () => { + popup?.[0].destroy(); + }); + }, + onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component?.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", "Tab"]; + if (navigationKeys.includes(props.event.key)) { + // @ts-expect-error fix the types + component?.ref?.onKeyDown(props); + return true; + } + return false; + }, + onExit: () => { + const container = document.querySelector(".frame-renderer") as HTMLElement; + if (container) { + container.removeEventListener("scroll", () => {}); + } + popup?.[0].destroy(); + setTimeout(() => { + component?.destroy(); + }, 300); + }, + }; +}; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 1f1c5f7067..644a16796d 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -9,8 +9,8 @@ import { IMentionHighlight, IMentionSuggestion, } from "@plane/editor-core"; -import { DocumentEditorExtensions } from "src/ui/extensions"; import { PageRenderer } from "src/ui/components/page-renderer"; +import { DocumentEditorExtensions, TEmbedConfig } from "src/ui/extensions"; interface IDocumentEditor { initialValue: string; @@ -31,6 +31,8 @@ interface IDocumentEditor { suggestions: () => Promise; }; tabIndex?: number; + // embed configuration + embedHandler?: TEmbedConfig; placeholder?: string | ((isFocused: boolean, value: string) => string); } @@ -46,6 +48,7 @@ const DocumentEditor = (props: IDocumentEditor) => { handleEditorReady, forwardedRef, tabIndex, + embedHandler, placeholder, } = props; // states @@ -71,10 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => { handleEditorReady, forwardedRef, mentionHandler, - extensions: DocumentEditorExtensions({ - uploadFile: fileHandler.upload, - setHideDragHandle: setHideDragHandleFunction, - }), + extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction, embedHandler?.issue), placeholder, tabIndex, }); diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 0e75c2db49..093cd239ab 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -2,7 +2,7 @@ import { forwardRef, MutableRefObject } from "react"; import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core"; // components import { PageRenderer } from "src/ui/components/page-renderer"; -import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget"; +import { IssueWidget, TReadOnlyEmbedConfig } from "src/ui/extensions"; interface IDocumentReadOnlyEditor { initialValue: string; @@ -14,6 +14,7 @@ interface IDocumentReadOnlyEditor { highlights: () => Promise; }; forwardedRef?: React.MutableRefObject; + embedHandler?: TReadOnlyEmbedConfig; } const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { @@ -25,6 +26,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { tabIndex, handleEditorReady, mentionHandler, + embedHandler, } = props; const editor = useReadOnlyEditor({ initialValue, @@ -32,7 +34,15 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { mentionHandler, forwardedRef, handleEditorReady, - extensions: [IssueWidgetPlaceholder()], + extensions: embedHandler?.issue + ? [ + IssueWidget({ + widgetCallback: embedHandler?.issue?.widgetCallback, + }).configure({ + issueEmbedConfig: embedHandler?.issue, + }), + ] + : [], }); if (!editor) { diff --git a/packages/editor/extensions/src/styles/drag-drop.css b/packages/editor/extensions/src/styles/drag-drop.css index d46d26ecca..d4f5c68b99 100644 --- a/packages/editor/extensions/src/styles/drag-drop.css +++ b/packages/editor/extensions/src/styles/drag-drop.css @@ -64,15 +64,24 @@ 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:not(.dragging) .ProseMirror-selectednode { + --horizontal-offset: 5px; + + &:has(.issue-embed) { + --horizontal-offset: 0px; + } + + &::after { + content: ""; + position: absolute; + top: 0; + left: calc(-1 * var(--horizontal-offset)); + height: 100%; + width: calc(100% + (var(--horizontal-offset) * 2)); + background-color: rgba(var(--color-primary-100), 0.2); + border-radius: 4px; + pointer-events: none; + } } .ProseMirror img { diff --git a/packages/types/src/active-cycle.d.ts b/packages/types/src/active-cycle.d.ts new file mode 100644 index 0000000000..75ed52f7fb --- /dev/null +++ b/packages/types/src/active-cycle.d.ts @@ -0,0 +1,5 @@ +import type { IProjectLite, ICycle } from "@plane/types"; + +export interface IActiveCycle extends ICycle { + project_detail: IProjectLite; +} diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index b8dd2d3c15..3c743f2962 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,3 +28,5 @@ export * from "./webhook"; export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; +// enterprise +export * from "./active-cycle"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 4871ddc06e..63ad93cafd 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,4 +1,5 @@ import { EPageAccess } from "./enums"; +import { TIssuePriorities } from "./issues"; export type TPage = { access: EPageAccess | undefined; @@ -43,3 +44,15 @@ export type TPageFilters = { sortBy: TPageFiltersSortBy; filters?: TPageFilterProps; }; + +export type TPageEmbedType = "mention" | "issue"; + +export type TPageEmbedResponse = { + id: string; + name: string; + priority: TIssuePriorities; + project__identifier: string; + project_id: string; + sequence_id: string; + state_id: string; +}; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index 157ecb16e5..6b8c41f685 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -68,6 +68,12 @@ export interface IProjectLite { id: string; name: string; identifier: string; + emoji: string | null; + logo_props: TProjectLogoProps; + icon_prop: { + name: string; + color: string; + } | null; } type ProjectPreferences = { diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index ceaa53d025..f1e3e725bf 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,5 +1,6 @@ import { EUserWorkspaceRoles } from "@/constants/workspace"; import type { + ICycle, IProjectMember, IUser, IUserLite, @@ -182,3 +183,14 @@ export interface IProductUpdateResponse { eyes: number; }; } + +export interface IWorkspaceActiveCyclesResponse { + count: number; + extra_stats: null; + next_cursor: string; + next_page_results: boolean; + prev_cursor: string; + prev_page_results: boolean; + results: ICycle[]; + total_pages: number; +} diff --git a/turbo.json b/turbo.json index d9a63f30e0..b10f1f900e 100644 --- a/turbo.json +++ b/turbo.json @@ -19,6 +19,8 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", + "NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL", + "NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL", "SENTRY_AUTH_TOKEN" ], "pipeline": { diff --git a/web/components/active-cycles/card.tsx b/web/components/active-cycles/card.tsx new file mode 100644 index 0000000000..4107fda515 --- /dev/null +++ b/web/components/active-cycles/card.tsx @@ -0,0 +1,38 @@ +import { FC } from "react"; +// types +import { IActiveCycle } from "@plane/types"; +// components +import { + ActiveCyclesProjectTitle, + ActiveCycleHeader, + ActiveCycleProgress, + ActiveCycleProductivity, + ActiveCycleStats, +} from "@/components/active-cycles"; + +export type ActiveCycleInfoCardProps = { + cycle: IActiveCycle; + workspaceSlug: string; + projectId: string; +}; + +export const ActiveCycleInfoCard: FC = (props) => { + const { cycle, workspaceSlug, projectId } = props; + + return ( +
+ + + + +
+ + + +
+
+ ); +}; diff --git a/web/components/active-cycles/cycle-stats.tsx b/web/components/active-cycles/cycle-stats.tsx new file mode 100644 index 0000000000..07e4bd3205 --- /dev/null +++ b/web/components/active-cycles/cycle-stats.tsx @@ -0,0 +1,284 @@ +import { FC, Fragment } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +// icons +import { CalendarCheck } from "lucide-react"; +// headless ui +import { Tab } from "@headlessui/react"; +// types +import { IActiveCycle } from "@plane/types"; +// ui +import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui"; +// components +import { SingleProgressStats } from "@/components/core"; +import { StateDropdown } from "@/components/dropdowns"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; +import { EIssuesStoreType } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; +// hooks +import { useIssues, useProjectState } from "@/hooks/store"; +import useLocalStorage from "@/hooks/use-local-storage"; +import { EmptyState } from "../empty-state"; + +export type ActiveCycleStatsProps = { + workspaceSlug: string; + projectId: string; + cycle: IActiveCycle; +}; + +export const ActiveCycleStats: FC = observer((props) => { + const { workspaceSlug, projectId, cycle } = props; + + const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); + + const currentValue = (tab: string | null) => { + switch (tab) { + case "Priority-Issues": + return 0; + case "Assignees": + return 1; + case "Labels": + return 2; + default: + return 0; + } + }; + const { + issues: { fetchActiveCycleIssues }, + } = useIssues(EIssuesStoreType.CYCLE); + const { fetchWorkspaceStates } = useProjectState(); + + const { data: activeCycleIssues } = useSWR( + workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null, + workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null + ); + + useSWR( + workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null, + workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null + ); + + const cycleIssues = activeCycleIssues ?? []; + + return ( +
+ { + switch (i) { + case 0: + return setTab("Priority-Issues"); + case 1: + return setTab("Assignees"); + case 2: + return setTab("Labels"); + + default: + return setTab("Priority-Issues"); + } + }} + > + + + cn( + "relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500", + { + "text-custom-text-300 bg-custom-background-100": selected, + "hover:text-custom-text-300": !selected, + } + ) + } + > + Priority Issues + + + cn( + "relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500", + { + "text-custom-text-300 bg-custom-background-100": selected, + "hover:text-custom-text-300": !selected, + } + ) + } + > + Assignees + + + cn( + "relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500", + { + "text-custom-text-300 bg-custom-background-100": selected, + "hover:text-custom-text-300": !selected, + } + ) + } + > + Labels + + + + + +
+ {cycleIssues ? ( + cycleIssues.length > 0 ? ( + cycleIssues.map((issue: any) => ( + +
+ + + + + {cycle.project_detail?.identifier}-{issue.sequence_id} + + + + {issue.name} + +
+
+ {}} + projectId={projectId?.toString() ?? ""} + disabled + buttonVariant="background-with-text" + buttonContainerClassName="cursor-pointer max-w-24" + showTooltip + /> + {issue.target_date && ( + +
+ + + {renderFormattedDateWithoutYear(issue.target_date)} + +
+
+ )} +
+ + )) + ) : ( +
+ +
+ ) + ) : ( + + + + + + )} +
+
+ + + {cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? ( + cycle.distribution.assignees.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + + + {assignee.display_name} +
+ } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + else + return ( + +
+ User +
+ No assignee + + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + }) + ) : ( +
+ +
+ )} + + + + {cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? ( + cycle.distribution.labels.map((label, index) => ( + + + {label.label_name ?? "No labels"} + + } + completed={label.completed_issues} + total={label.total_issues} + /> + )) + ) : ( +
+ +
+ )} +
+ + + + ); +}); diff --git a/web/components/active-cycles/header.tsx b/web/components/active-cycles/header.tsx new file mode 100644 index 0000000000..cf9b764c83 --- /dev/null +++ b/web/components/active-cycles/header.tsx @@ -0,0 +1,77 @@ +import { FC } from "react"; +import Link from "next/link"; +// types +import { ICycle, TCycleGroups } from "@plane/types"; +// ui +import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui"; +// helpers +import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper"; +import { truncateText } from "@/helpers/string.helper"; +// hooks +import { useMember } from "@/hooks/store"; + +export type ActiveCycleHeaderProps = { + cycle: ICycle; + workspaceSlug: string; + projectId: string; +}; + +export const ActiveCycleHeader: FC = (props) => { + const { cycle, workspaceSlug, projectId } = props; + // store + const { getUserDetails } = useMember(); + const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined; + + const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0; + const currentCycleStatus = cycle?.status?.toLocaleLowerCase() as TCycleGroups; + + const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name); + + return ( +
+
+ + +

{truncateText(cycle.name, 70)}

+
+ + + {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} + + +
+
+
+
+ + {cycleAssignee.length > 0 && ( + + + {cycleAssignee.map((member) => ( + + ))} + + + )} +
+
+ + View Cycle + +
+
+ ); +}; diff --git a/web/components/active-cycles/index.ts b/web/components/active-cycles/index.ts new file mode 100644 index 0000000000..9dec611322 --- /dev/null +++ b/web/components/active-cycles/index.ts @@ -0,0 +1,7 @@ +export * from "./header"; +export * from "./progress"; +export * from "./project-title"; +export * from "./productivity"; +export * from "./cycle-stats"; +export * from "./card"; +export * from "./list-page"; diff --git a/web/components/active-cycles/list-page.tsx b/web/components/active-cycles/list-page.tsx new file mode 100644 index 0000000000..ff2bb01865 --- /dev/null +++ b/web/components/active-cycles/list-page.tsx @@ -0,0 +1,54 @@ +import { FC, useEffect } from "react"; +import useSWR from "swr"; +// ui +import { Spinner } from "@plane/ui"; +// components +import { ActiveCycleInfoCard } from "@/components/active-cycles"; +// constants +import { WORKSPACE_ACTIVE_CYCLES_LIST } from "@/constants/fetch-keys"; +// services +import { CycleService } from "@/services/cycle.service"; +const cycleService = new CycleService(); + +export type ActiveCyclesListPageProps = { + workspaceSlug: string; + cursor: string; + perPage: number; + updateTotalPages: (count: number) => void; + updateResultsCount: (count: number) => void; +}; + +export const ActiveCyclesListPage: FC = (props) => { + const { workspaceSlug, cursor, perPage, updateTotalPages, updateResultsCount } = props; + + // fetching active cycles in workspace + const { data: workspaceActiveCycles } = useSWR( + workspaceSlug && cursor ? WORKSPACE_ACTIVE_CYCLES_LIST(workspaceSlug as string, cursor, `${perPage}`) : null, + workspaceSlug && cursor ? () => cycleService.workspaceActiveCycles(workspaceSlug.toString(), cursor, perPage) : null + ); + + useEffect(() => { + if (workspaceActiveCycles) { + updateTotalPages(workspaceActiveCycles.total_pages); + updateResultsCount(workspaceActiveCycles.results.length); + } + }, [updateTotalPages, updateResultsCount, workspaceActiveCycles]); + + if (!workspaceActiveCycles) { + return ( +
+ +
+ ); + } + + return ( + <> + {workspaceActiveCycles.results.map((cycle: any) => ( +
+ +
+ ))} + + ); +}; diff --git a/web/components/active-cycles/productivity.tsx b/web/components/active-cycles/productivity.tsx new file mode 100644 index 0000000000..d5ca70bcd5 --- /dev/null +++ b/web/components/active-cycles/productivity.tsx @@ -0,0 +1,57 @@ +import { FC } from "react"; +// types +import { ICycle } from "@plane/types"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { EmptyStateType } from "@/constants/empty-state"; +import { EmptyState } from "../empty-state"; + +export type ActiveCycleProductivityProps = { + cycle: ICycle; +}; + +export const ActiveCycleProductivity: FC = (props) => { + const { cycle } = props; + + return ( +
+
+

Issue burndown

+
+ {cycle.total_issues > 0 ? ( + <> +
+
+
+
+ + Ideal +
+
+ + Current +
+
+ {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} +
+
+ +
+
+ + ) : ( + <> +
+ +
+ + )} +
+ ); +}; diff --git a/web/components/active-cycles/progress.tsx b/web/components/active-cycles/progress.tsx new file mode 100644 index 0000000000..86f87c00f8 --- /dev/null +++ b/web/components/active-cycles/progress.tsx @@ -0,0 +1,89 @@ +import { FC } from "react"; +// types +import { ICycle } from "@plane/types"; +// ui +import { LinearProgressIndicator } from "@plane/ui"; +// constants +import { WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; +import { EmptyStateType } from "@/constants/empty-state"; +import { EmptyState } from "../empty-state"; + +export type ActiveCycleProgressProps = { + cycle: ICycle; +}; + +export const ActiveCycleProgress: FC = (props) => { + const { cycle } = props; + + const progressIndicatorData = WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ + id: index, + name: group.title, + value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0, + color: group.color, + })); + + const groupedIssues: any = { + completed: cycle.completed_issues, + started: cycle.started_issues, + unstarted: cycle.unstarted_issues, + backlog: cycle.backlog_issues, + }; + + return ( +
+
+
+

Progress

+ {cycle.total_issues > 0 && ( + + {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ + cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" + } closed`} + + )} +
+ {cycle.total_issues > 0 && } +
+ + {cycle.total_issues > 0 ? ( +
+ {Object.keys(groupedIssues).map((group, index) => ( + <> + {groupedIssues[group] > 0 && ( +
+
+
+ + {group} +
+ {`${groupedIssues[group]} ${ + groupedIssues[group] > 1 ? "Issues" : "Issue" + }`} +
+
+ )} + + ))} + {cycle.cancelled_issues > 0 && ( + + + {`${cycle.cancelled_issues} cancelled ${ + cycle.cancelled_issues > 1 ? "issues are" : "issue is" + } excluded from this report.`}{" "} + + + )} +
+ ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/web/components/active-cycles/project-title.tsx b/web/components/active-cycles/project-title.tsx new file mode 100644 index 0000000000..676f0b0384 --- /dev/null +++ b/web/components/active-cycles/project-title.tsx @@ -0,0 +1,19 @@ +import { FC } from "react"; +// types +import { IProject } from "@plane/types"; +// ui +import { ProjectLogo } from "../project"; + +export type ActiveCyclesProjectTitleProps = { + project: Partial | undefined; +}; + +export const ActiveCyclesProjectTitle: FC = (props) => { + const { project } = props; + return ( +
+ {project?.logo_props && } +

{project?.name}

+
+ ); +}; diff --git a/web/components/cycles/active-cycle-info.tsx b/web/components/cycles/active-cycle-info.tsx new file mode 100644 index 0000000000..45d600d326 --- /dev/null +++ b/web/components/cycles/active-cycle-info.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +// types +import { IActiveCycle } from "@plane/types"; +// components +import { + ActiveCyclesProjectTitle, + ActiveCycleHeader, + ActiveCycleProgress, + ActiveCycleProductivity, + ActiveCycleStats, +} from "@/components/active-cycles"; +// hooks +import { useProject } from "@/hooks/store"; + +export type ActiveCycleInfoProps = { + cycle: IActiveCycle; + workspaceSlug: string; + projectId: string; +}; + +export const ActiveCycleInfo: FC = observer((props) => { + const { cycle, workspaceSlug, projectId } = props; + + const { getProjectById } = useProject(); + + const projectDetails = getProjectById(projectId); + + if (!projectDetails) return null; + + return ( + <> + +
+ +
+ + + +
+
+ + ); +}); diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index b1b718175e..bc713b5df3 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -1,3 +1,5 @@ +export * from "./cycles-view"; +export * from "./active-cycle-info"; export * from "./active-cycle"; export * from "./applied-filters"; export * from "./board/"; diff --git a/web/components/headers/workspace-active-cycles.tsx b/web/components/headers/workspace-active-cycles.tsx index 5861cba60a..b30f4bab4b 100644 --- a/web/components/headers/workspace-active-cycles.tsx +++ b/web/components/headers/workspace-active-cycles.tsx @@ -1,9 +1,8 @@ import { observer } from "mobx-react"; // ui -import { Crown } from "lucide-react"; import { Breadcrumbs, ContrastIcon } from "@plane/ui"; +// components import { BreadcrumbLink } from "@/components/common"; -// icons export const WorkspaceActiveCycleHeader = observer(() => (
@@ -20,7 +19,9 @@ export const WorkspaceActiveCycleHeader = observer(() => ( } /> - + + Beta +
diff --git a/web/components/license/index.ts b/web/components/license/index.ts new file mode 100644 index 0000000000..ddc06de25f --- /dev/null +++ b/web/components/license/index.ts @@ -0,0 +1 @@ +export * from "./pro-plan-modal"; diff --git a/web/components/license/pro-plan-modal.tsx b/web/components/license/pro-plan-modal.tsx new file mode 100644 index 0000000000..42dc28aed6 --- /dev/null +++ b/web/components/license/pro-plan-modal.tsx @@ -0,0 +1,192 @@ +import { FC, Fragment, useState } from "react"; +// icons +import { CheckCircle } from "lucide-react"; +// ui +import { Dialog, Transition, Tab } from "@headlessui/react"; +// store +import { useEventTracker } from "@/hooks/store"; + +function classNames(...classes: any[]) { + return classes.filter(Boolean).join(" "); +} + +const PRICING_CATEGORIES = ["Monthly", "Yearly"]; + +const MONTHLY_PLAN_ITEMS = [ + "White-glove onboarding for your use-cases", + "Bespoke implementation", + "Priority integrations", + "Priority Support and SLAs", + "Early access to all paid features", + "Locked-in discount for a whole year", +]; + +const YEARLY_PLAN_ITEMS = [ + "White-glove onboarding for your use-cases", + "Bespoke implementation", + "Priority integrations", + "Priority Support and SLAs", + "Early access to all paid features", + "Tiered discounts for the second and third years", +]; + +export type ProPlanModalProps = { + isOpen: boolean; + handleClose: () => void; +}; + +export const ProPlanModal: FC = (props) => { + const { isOpen, handleClose } = props; + // store + const { captureEvent } = useEventTracker(); + // states + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [tabIndex, setTabIndex] = useState(0); + + const handleProPlaneMonthRedirection = () => { + if (process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL) { + window.open(process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL, "_blank"); + captureEvent("pro_plan_modal_month_redirection", {}); + } + }; + + const handleProPlanYearlyRedirection = () => { + if (process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL) { + window.open(process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL, "_blank"); + captureEvent("pro_plan_modal_yearly_redirection", {}); + } + }; + + return ( + + + +
+ + +
+
+ + + + Early-adopter pricing for believers + +
+

+ Build Plane to your specs. You decide what we prioritize and build for everyone. Also get tailored + onboarding + implementation and priority support. +

+ +
+ + {PRICING_CATEGORIES.map((category, index) => ( + + classNames( + "w-full rounded-lg py-2 text-sm font-medium leading-5", + "ring-white/60 ring-offset-2 ring-offset-custom-primary-90 focus:outline-none", + selected + ? "bg-custom-background-100 text-custom-primary-100 shadow" + : "hover:bg-custom-background-80 text-custom-text-300 hover:text-custom-text-100" + ) + } + onClick={() => setTabIndex(index)} + > + <> + {category} + {category === "Yearly" && ( + + -28% + + )} + + + ))} + +
+ + + +

+ $7 + /user/month +

+
    + {MONTHLY_PLAN_ITEMS.map((item) => ( +
  • +

    + + {item} +

    +
  • + ))} +
+
+
+
+ +
+
+ + +

+ $5 + /user/month +

+
    + {YEARLY_PLAN_ITEMS.map((item) => ( +
  • +

    + + {item} +

    +
  • + ))} +
+
+
+
+ +
+
+ + + +
+ + +
+
+
+
+ ); +}; diff --git a/web/components/pages/editor/editor-body.tsx b/web/components/pages/editor/editor-body.tsx index a896bcc580..3333557534 100644 --- a/web/components/pages/editor/editor-body.tsx +++ b/web/components/pages/editor/editor-body.tsx @@ -13,11 +13,12 @@ import { // types import { IUserLite, TPage } from "@plane/types"; // components -import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages"; +import { IssueEmbedCard, PageContentBrowser, PageEditorTitle, PageContentLoader } from "@/components/pages"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store"; +import { useIssueEmbed } from "@/hooks/use-issue-embed"; import { usePageFilters } from "@/hooks/use-page-filters"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // services @@ -79,8 +80,11 @@ export const PageEditorBody: React.FC = observer((props) => { members: projectMemberDetails, user: currentUser ?? undefined, }); + // page filters const { isFullWidth } = usePageFilters(); + // issue-embed + const { fetchIssues } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? ""); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -88,6 +92,11 @@ export const PageEditorBody: React.FC = observer((props) => { updateMarkings(description_html ?? "

"); }, [description_html, updateMarkings]); + const handleIssueSearch = async (searchQuery: string) => { + const response = await fetchIssues(searchQuery); + return response; + }; + if (pageDescription === undefined) return ; return ( @@ -149,6 +158,24 @@ export const PageEditorBody: React.FC = observer((props) => { highlights: mentionHighlights, suggestions: mentionSuggestions, }} + embedHandler={{ + issue: { + searchCallback: async (query) => + new Promise((resolve) => { + setTimeout(async () => { + const response = await handleIssueSearch(query); + resolve(response); + }, 300); + }), + widgetCallback: (issueId) => ( + + ), + }, + }} /> )} /> @@ -162,6 +189,17 @@ export const PageEditorBody: React.FC = observer((props) => { mentionHandler={{ highlights: mentionHighlights, }} + embedHandler={{ + issue: { + widgetCallback: (issueId) => ( + + ), + }, + }} /> )} diff --git a/web/components/pages/editor/embed/index.ts b/web/components/pages/editor/embed/index.ts new file mode 100644 index 0000000000..f30596cb00 --- /dev/null +++ b/web/components/pages/editor/embed/index.ts @@ -0,0 +1 @@ +export * from "./issue-embed"; diff --git a/web/components/pages/editor/embed/issue-embed.tsx b/web/components/pages/editor/embed/issue-embed.tsx new file mode 100644 index 0000000000..bf98b9e68b --- /dev/null +++ b/web/components/pages/editor/embed/issue-embed.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { AlertTriangle } from "lucide-react"; +// types +import { IIssueDisplayProperties } from "@plane/types"; +// ui +import { Loader } from "@plane/ui"; +// components +import { IssueProperties } from "@/components/issues/issue-layouts/properties/all-properties"; +// constants +import { ISSUE_DISPLAY_PROPERTIES } from "@/constants/issue"; +import { EUserProjectRoles } from "@/constants/project"; +// hooks +import { useIssueDetail, useProject, useUser } from "@/hooks/store"; + +type Props = { + issueId: string; + projectId: string; + workspaceSlug: string; +}; + +export const IssueEmbedCard: React.FC = observer((props) => { + const { issueId, projectId, workspaceSlug } = props; + // states + const [error, setError] = useState(null); + // store hooks + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + const { getProjectById } = useProject(); + const { + setPeekIssue, + issue: { fetchIssue, getIssueById, updateIssue }, + } = useIssueDetail(); + // derived values + const projectRole = currentWorkspaceAllProjectsRole?.[projectId]; + const projectDetails = getProjectById(projectId); + const issueDetails = getIssueById(issueId); + // auth + const isReadOnly = !!projectRole && projectRole < EUserProjectRoles.MEMBER; + // issue display properties + const displayProperties: IIssueDisplayProperties = {}; + ISSUE_DISPLAY_PROPERTIES.forEach((property) => { + displayProperties[property.key] = true; + }); + // fetch issue details if not available + useEffect(() => { + if (!issueDetails) { + fetchIssue(workspaceSlug, projectId, issueId) + .then(() => setError(null)) + .catch((error) => setError(error)); + } + }, [fetchIssue, issueDetails, issueId, projectId, workspaceSlug]); + + if (!issueDetails && !error) + return ( +
+ + +
+ + +
+
+
+ ); + + if (error) + return ( +
+ + This Issue embed is not found in any project. It can no longer be updated or accessed from here. +
+ ); + + return ( +
+ setPeekIssue({ + issueId, + projectId, + workspaceSlug, + }) + } + > +
+ {projectDetails?.identifier}-{issueDetails?.sequence_id} +
+

{issueDetails?.name}

+ {issueDetails && ( + await updateIssue(workspaceSlug, projectId, issueId, data)} + isReadOnly={isReadOnly} + /> + )} +
+ ); +}); diff --git a/web/components/pages/editor/index.ts b/web/components/pages/editor/index.ts index 0ed58726f4..d3484dad69 100644 --- a/web/components/pages/editor/index.ts +++ b/web/components/pages/editor/index.ts @@ -1,3 +1,4 @@ +export * from "./embed"; export * from "./header"; export * from "./summary"; export * from "./editor-body"; diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 130a62c873..6ad76ab04a 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -1,10 +1,10 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -// headless ui -import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react"; -import { Transition } from "@headlessui/react"; // icons +import { FileText, HelpCircle, MessagesSquare, MoveLeft, Zap } from "lucide-react"; +// headless ui +import { Transition } from "@headlessui/react"; // ui import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks @@ -13,6 +13,7 @@ import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // assets import packageJson from "package.json"; +import { PlaneBadge } from "./plane-badge"; const HELP_OPTIONS = [ { @@ -64,11 +65,9 @@ export const WorkspaceHelpSection: React.FC = observe }`} > {!isCollapsed && ( - -
- Community -
-
+ <> + + )}
diff --git a/web/components/workspace/index.ts b/web/components/workspace/index.ts index 579a4c7526..25818abbdb 100644 --- a/web/components/workspace/index.ts +++ b/web/components/workspace/index.ts @@ -9,3 +9,6 @@ export * from "./sidebar-dropdown"; export * from "./sidebar-menu"; export * from "./sidebar-quick-action"; export * from "./workspace-active-cycles-upgrade"; + +// ee imports +export * from "./workspace-active-cycles-list"; diff --git a/web/components/workspace/plane-badge.tsx b/web/components/workspace/plane-badge.tsx new file mode 100644 index 0000000000..e646c9a2c0 --- /dev/null +++ b/web/components/workspace/plane-badge.tsx @@ -0,0 +1,41 @@ +import React, { useState } from "react"; +// ui +import { Tooltip, Button } from "@plane/ui"; +// components +import { ProPlanModal } from "@/components/license"; +// hooks +import { useEventTracker } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// assets +import packageJson from "package.json"; + +export const PlaneBadge: React.FC = () => { + // states + const [isProPlanModalOpen, setIsProPlanModalOpen] = useState(false); + // hooks + const { captureEvent } = useEventTracker(); + const { isMobile } = usePlatformOS(); + + const handleProPlanModalOpen = () => { + setIsProPlanModalOpen(true); + captureEvent("pro_plan_modal_opened", {}); + }; + + return ( + <> + setIsProPlanModalOpen(false)} /> + + +
+ Community +
+
+ + ); +}; diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index c2d4168382..ae8d4531a1 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -2,7 +2,6 @@ import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { Crown } from "lucide-react"; // ui import { Tooltip } from "@plane/ui"; // components @@ -70,7 +69,9 @@ export const WorkspaceSidebarMenu = observer(() => { } {!sidebarCollapsed &&

{link.label}

} {!sidebarCollapsed && link.key === "active-cycles" && ( - + + Beta + )}
diff --git a/web/components/workspace/workspace-active-cycles-list.tsx b/web/components/workspace/workspace-active-cycles-list.tsx new file mode 100644 index 0000000000..b298d97dec --- /dev/null +++ b/web/components/workspace/workspace-active-cycles-list.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import { Button } from "@plane/ui"; +import { ActiveCyclesListPage } from "@/components/active-cycles"; +import { EmptyStateType } from "@/constants/empty-state"; +import { EmptyState } from "../empty-state"; + +const perPage = 3; + +export const WorkspaceActiveCyclesList = observer(() => { + // state + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); // workspaceActiveCycles.results.length + // router + const router = useRouter(); + const { workspaceSlug } = router.query; + + const activeCyclesPages = []; + + const updateTotalPages = (count: number) => { + setTotalPages(count); + }; + + const updateResultsCount = (count: number) => { + setResultsCount(count); + }; + + const handleLoadMore = () => { + setPageCount(pageCount + 1); + }; + + if (!workspaceSlug) { + return null; + } + + for (let i = 1; i <= pageCount; i++) { + activeCyclesPages.push( + + ); + } + + return ( +
+ {activeCyclesPages} + + {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} + + {resultsCount === 0 && } +
+ ); +}); diff --git a/web/constants/cycle.ts b/web/constants/cycle.ts index 15e2bf6791..fa3540451b 100644 --- a/web/constants/cycle.ts +++ b/web/constants/cycle.ts @@ -150,3 +150,27 @@ export const WORKSPACE_ACTIVE_CYCLES_DETAILS = [ icon: Microscope, }, ]; + +// ee +export const WORKSPACE_ACTIVE_CYCLE_STATE_GROUPS_DETAILS = [ + { + key: "completed_issues", + title: "Completed", + color: "#6490FE", + }, + { + key: "started_issues", + title: "Started", + color: "#FDD97F", + }, + { + key: "unstarted_issues", + title: "Unstarted", + color: "#FEB055", + }, + { + key: "backlog_issues", + title: "Backlog", + color: "#F0F0F3", + }, +]; diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index 9ca6f4edb3..bae2990981 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -88,6 +88,7 @@ export enum EmptyStateType { ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state", ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state", + WORKSPACE_ACTIVE_CYCLES = "workspace-active-cycles", DISABLED_PROJECT_INBOX = "disabled-project-inbox", DISABLED_PROJECT_CYCLE = "disabled-project-cycle", DISABLED_PROJECT_MODULE = "disabled-project-module", @@ -608,6 +609,13 @@ const emptyStateDetails = { title: "Add labels to issues to see the \n breakdown of work by labels.", path: "/empty-state/active-cycle/label", }, + [EmptyStateType.WORKSPACE_ACTIVE_CYCLES]: { + key: EmptyStateType.WORKSPACE_ACTIVE_CYCLES, + title: "No active cycles", + description: + "Cycles of your projects that includes any period that encompasses today's date within its range. Find the progress and details of all your active cycle here.", + path: "/empty-state/onboarding/workspace-active-cycles", + }, [EmptyStateType.DISABLED_PROJECT_INBOX]: { key: EmptyStateType.DISABLED_PROJECT_INBOX, title: "Inbox is not enabled for the project.", diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index 5d0fb77173..98a85bec83 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -140,6 +140,8 @@ export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${w export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`; // cycles +export const WORKSPACE_ACTIVE_CYCLES_LIST = (workspaceSlug: string, cursor: string, per_page: string) => + `WORKSPACE_ACTIVE_CYCLES_LIST_${workspaceSlug.toUpperCase()}_${cursor.toUpperCase()}_${per_page.toUpperCase()}`; export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`; export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`; export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`; diff --git a/web/constants/project.ts b/web/constants/project.ts index 5cbea431ae..cff91fa921 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -115,6 +115,14 @@ export const PROJECT_SETTINGS_LINKS: { highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels`, Icon: SettingIcon, }, + { + key: "integrations", + label: "Integrations", + href: `/settings/integrations`, + access: EUserProjectRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`, + Icon: SettingIcon, + }, { key: "estimates", label: "Estimates", diff --git a/web/constants/workspace.ts b/web/constants/workspace.ts index 5245d48498..a471c63f8d 100644 --- a/web/constants/workspace.ts +++ b/web/constants/workspace.ts @@ -168,6 +168,22 @@ export const WORKSPACE_SETTINGS_LINKS: { highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`, Icon: SettingIcon, }, + { + key: "integrations", + label: "Integrations", + href: `/settings/integrations`, + access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`, + Icon: SettingIcon, + }, + { + key: "import", + label: "Imports", + href: `/settings/imports`, + access: EUserWorkspaceRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`, + Icon: SettingIcon, + }, { key: "export", label: "Exports", diff --git a/web/hooks/use-issue-embed.tsx b/web/hooks/use-issue-embed.tsx new file mode 100644 index 0000000000..e6b64a21e3 --- /dev/null +++ b/web/hooks/use-issue-embed.tsx @@ -0,0 +1,37 @@ +// editor +import { TEmbedItem } from "@plane/document-editor"; +// types +import { TPageEmbedResponse } from "@plane/types"; +// ui +import { PriorityIcon } from "@plane/ui"; +// services +import { PageService } from "@/services/page.service"; + +const pageService = new PageService(); + +export const useIssueEmbed = (workspaceSlug: string, projectId: string) => { + const fetchIssues = async (searchQuery: string): Promise => + await pageService + .searchEmbed(workspaceSlug, projectId, { + query_type: "issue", + query: searchQuery, + count: 10, + }) + .then((res) => { + const structuredIssues: TEmbedItem[] = (res ?? []).map((issue) => ({ + id: issue.id, + subTitle: `${issue.project__identifier}-${issue.sequence_id}`, + title: issue.name, + icon: , + })); + + return structuredIssues; + }) + .catch((err) => { + throw Error(err); + }); + + return { + fetchIssues, + }; +}; diff --git a/web/pages/[workspaceSlug]/active-cycles.tsx b/web/pages/[workspaceSlug]/active-cycles.tsx index 86bb37fa04..3ba5fc47bc 100644 --- a/web/pages/[workspaceSlug]/active-cycles.tsx +++ b/web/pages/[workspaceSlug]/active-cycles.tsx @@ -3,13 +3,12 @@ import { observer } from "mobx-react"; // components import { PageHead } from "@/components/core"; import { WorkspaceActiveCycleHeader } from "@/components/headers"; -import { WorkspaceActiveCyclesUpgrade } from "@/components/workspace"; +import { WorkspaceActiveCyclesList } from "@/components/workspace"; // layouts import { useWorkspace } from "@/hooks/store"; import { AppLayout } from "@/layouts/app-layout"; // types import { NextPageWithLayout } from "@/lib/types"; -// hooks const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => { const { currentWorkspace } = useWorkspace(); @@ -19,7 +18,7 @@ const WorkspaceActiveCyclesPage: NextPageWithLayout = observer(() => { return ( <> - + ); }); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx new file mode 100644 index 0000000000..a4d8c4dd7b --- /dev/null +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -0,0 +1,86 @@ +import { ReactElement } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/router"; +import useSWR from "swr"; +import { IProject } from "@plane/types"; +// hooks +import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; +import { ProjectSettingHeader } from "@/components/headers"; +import { IntegrationCard } from "@/components/project"; +import { IntegrationsSettingsLoader } from "@/components/ui"; +// layouts +import { EmptyStateType } from "@/constants/empty-state"; +import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys"; +import { AppLayout } from "@/layouts/app-layout"; +import { ProjectSettingLayout } from "@/layouts/settings-layout"; +// services +import { NextPageWithLayout } from "@/lib/types"; +import { IntegrationService } from "@/services/integrations"; +import { ProjectService } from "@/services/project"; +// components +// ui +// types +// fetch-keys +// constants + +// services +const integrationService = new IntegrationService(); +const projectService = new ProjectService(); + +const ProjectIntegrationsPage: NextPageWithLayout = observer(() => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // fetch project details + const { data: projectDetails } = useSWR( + workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, + workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null + ); + // fetch Integrations list + const { data: workspaceIntegrations } = useSWR( + workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, + () => (workspaceSlug ? integrationService.getWorkspaceIntegrationsList(workspaceSlug as string) : null) + ); + // derived values + const isAdmin = projectDetails?.member_role === 20; + const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Integrations` : undefined; + + return ( + <> + +
+
+

Integrations

+
+ {workspaceIntegrations ? ( + workspaceIntegrations.length > 0 ? ( +
+ {workspaceIntegrations.map((integration) => ( + + ))} +
+ ) : ( +
+ +
+ ) + ) : ( + + )} +
+ + ); +}); + +ProjectIntegrationsPage.getLayout = function getLayout(page: ReactElement) { + return ( + }> + {page} + + ); +}; + +export default ProjectIntegrationsPage; diff --git a/web/pages/[workspaceSlug]/settings/imports.tsx b/web/pages/[workspaceSlug]/settings/imports.tsx index d1f6573b48..caf958d388 100644 --- a/web/pages/[workspaceSlug]/settings/imports.tsx +++ b/web/pages/[workspaceSlug]/settings/imports.tsx @@ -1,16 +1,16 @@ -import { observer } from "mobx-react"; +import { observer } from "mobx-react-lite"; // hooks -import { PageHead } from "components/core"; -import { WorkspaceSettingHeader } from "components/headers"; -import IntegrationGuide from "components/integration/guide"; -import { EUserWorkspaceRoles } from "constants/workspace"; -import { useUser, useWorkspace } from "hooks/store"; +import { PageHead } from "@/components/core"; +import { WorkspaceSettingHeader } from "@/components/headers"; +import IntegrationGuide from "@/components/integration/guide"; +import { EUserWorkspaceRoles } from "@/constants/workspace"; +import { useUser, useWorkspace } from "@/hooks/store"; // layouts -import { AppLayout } from "layouts/app-layout"; -import { WorkspaceSettingLayout } from "layouts/settings-layout"; +import { AppLayout } from "@/layouts/app-layout"; +import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; // components // types -import { NextPageWithLayout } from "lib/types"; +import { NextPageWithLayout } from "@/lib/types"; // constants const ImportsPage: NextPageWithLayout = observer(() => { diff --git a/web/pages/[workspaceSlug]/settings/integrations.tsx b/web/pages/[workspaceSlug]/settings/integrations.tsx index 9f06b92eec..ad5f1e5913 100644 --- a/web/pages/[workspaceSlug]/settings/integrations.tsx +++ b/web/pages/[workspaceSlug]/settings/integrations.tsx @@ -1,26 +1,25 @@ import { ReactElement } from "react"; -import { observer } from "mobx-react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import useSWR from "swr"; -// hooks -// services -// layouts // components -import { PageHead } from "components/core"; -import { WorkspaceSettingHeader } from "components/headers"; -import { SingleIntegrationCard } from "components/integration"; +import { PageHead } from "@/components/core"; +import { WorkspaceSettingHeader } from "@/components/headers"; +import { SingleIntegrationCard } from "@/components/integration"; // ui -import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "components/ui"; -// types -// fetch-keys -import { APP_INTEGRATIONS } from "constants/fetch-keys"; +import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; -import { useUser, useWorkspace } from "hooks/store"; -import { AppLayout } from "layouts/app-layout"; -import { WorkspaceSettingLayout } from "layouts/settings-layout"; -import { NextPageWithLayout } from "lib/types"; -import { IntegrationService } from "services/integrations"; +import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser, useWorkspace } from "@/hooks/store"; +// layouts +import { AppLayout } from "@/layouts/app-layout"; +import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; +// types +import { NextPageWithLayout } from "@/lib/types"; +// services +import { IntegrationService } from "@/services/integrations"; const integrationService = new IntegrationService(); @@ -38,6 +37,10 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; + const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => + workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null + ); + if (!isAdmin) return ( <> @@ -48,10 +51,6 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { ); - const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => - workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null - ); - return ( <> diff --git a/web/public/empty-state/onboarding/workspace-active-cycles-dark.webp b/web/public/empty-state/onboarding/workspace-active-cycles-dark.webp new file mode 100644 index 0000000000..281fd34ca8 Binary files /dev/null and b/web/public/empty-state/onboarding/workspace-active-cycles-dark.webp differ diff --git a/web/public/empty-state/onboarding/workspace-active-cycles-light.webp b/web/public/empty-state/onboarding/workspace-active-cycles-light.webp new file mode 100644 index 0000000000..d0ed805bf5 Binary files /dev/null and b/web/public/empty-state/onboarding/workspace-active-cycles-light.webp differ diff --git a/web/services/cycle.service.ts b/web/services/cycle.service.ts index 5a256614f1..b25560f875 100644 --- a/web/services/cycle.service.ts +++ b/web/services/cycle.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // types -import type { CycleDateCheckData, ICycle, TIssue } from "@plane/types"; +import type { CycleDateCheckData, ICycle, IWorkspaceActiveCyclesResponse, TIssue } from "@plane/types"; // helpers export class CycleService extends APIService { @@ -10,6 +10,23 @@ export class CycleService extends APIService { super(API_BASE_URL); } + async workspaceActiveCycles( + workspaceSlug: string, + cursor: string, + per_page: number + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/active-cycles/`, { + params: { + per_page, + cursor, + }, + }) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + async getWorkspaceCycles(workspaceSlug: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/cycles/`) .then((response) => response?.data) diff --git a/web/services/page.service.ts b/web/services/page.service.ts index f84f547542..04c86ec355 100644 --- a/web/services/page.service.ts +++ b/web/services/page.service.ts @@ -1,5 +1,5 @@ // types -import { TPage } from "@plane/types"; +import { TPage, TPageEmbedType } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services @@ -119,4 +119,22 @@ export class PageService extends APIService { throw error?.response?.data; }); } + + async searchEmbed( + workspaceSlug: string, + projectId: string, + params: { + query_type: TPageEmbedType; + count?: number; + query: string; + } + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/search/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/yarn.lock b/yarn.lock index c25eddcda9..989d5657c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7954,16 +7954,7 @@ streamx@^2.15.0, streamx@^2.16.1: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8043,14 +8034,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==