diff --git a/.env.example b/.env.example index 0ef4d21913a..90efa8b412c 100644 --- a/.env.example +++ b/.env.example @@ -15,12 +15,15 @@ RABBITMQ_USER="plane" RABBITMQ_PASSWORD="plane" RABBITMQ_VHOST="plane" +LISTEN_HTTP_PORT=80 +LISTEN_HTTPS_PORT=443 + # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" AWS_SECRET_ACCESS_KEY="secret-key" AWS_S3_ENDPOINT_URL="http://plane-minio:9000" -# Changing this requires change in the nginx.conf for uploads if using minio setup +# Changing this requires change in the proxy config for uploads if using minio setup AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit FILE_SIZE_LIMIT=5242880 @@ -36,8 +39,15 @@ DOCKERIZED=1 # deprecated # set to 1 If using the pre-configured minio setup USE_MINIO=1 -# Nginx Configuration -NGINX_PORT=80 +# If SSL Cert to be generated, set CERT_EMAIl="email " +CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory +TRUSTED_PROXIES=0.0.0.0/0 +SITE_ADDRESS=:80 +CERT_EMAIL= + +# For DNS Challenge based certificate generation, set the CERT_ACME_DNS, CERT_EMAIL +# CERT_ACME_DNS="acme_dns " +CERT_ACME_DNS= # Force HTTPS for handling SSL Termination MINIO_ENDPOINT_SSL=0 diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml deleted file mode 100644 index 3fb2958f1fa..00000000000 --- a/.github/workflows/build-aio-base.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Build AIO Base Image - -on: - workflow_dispatch: - inputs: - base_tag_name: - description: 'Base Tag Name' - required: false - default: '' - -env: - TARGET_BRANCH: ${{ github.ref_name }} - -jobs: - base_build_setup: - name: Build Preparation - 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 }} - image_tag: ${{ steps.set_env_variables.outputs.IMAGE_TAG }} - - steps: - - id: set_env_variables - name: Set Environment Variables - run: | - echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT - - if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then - echo "IMAGE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - echo "IMAGE_TAG=latest" >> $GITHUB_OUTPUT - elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then - echo "IMAGE_TAG=preview" >> $GITHUB_OUTPUT - else - echo "IMAGE_TAG=develop" >> $GITHUB_OUTPUT - fi - - - if [ "${{ env.TARGET_BRANCH }}" == "master" ]; 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 - - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - - full_base_build_push: - runs-on: ubuntu-latest - needs: [base_build_setup] - env: - BASE_IMG_TAG: makeplane/plane-aio-base:full-${{ needs.base_build_setup.outputs.image_tag }} - BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }} - BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }} - BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }} - BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }} - steps: - - name: Check out the repo - uses: actions/checkout@v4 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - 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: Build and Push to Docker Hub - uses: docker/build-push-action@v6.9.0 - with: - context: ./aio - file: ./aio/Dockerfile-base-full - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.BASE_IMG_TAG }} - push: true - cache-from: type=gha - cache-to: type=gha,mode=max - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - slim_base_build_push: - runs-on: ubuntu-latest - needs: [base_build_setup] - env: - BASE_IMG_TAG: makeplane/plane-aio-base:slim-${{ needs.base_build_setup.outputs.image_tag }} - BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }} - BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }} - BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }} - BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }} - steps: - - name: Check out the repo - uses: actions/checkout@v4 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - 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: Build and Push to Docker Hub - uses: docker/build-push-action@v6.9.0 - with: - context: ./aio - file: ./aio/Dockerfile-base-slim - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.BASE_IMG_TAG }} - push: true - cache-from: type=gha - cache-to: type=gha,mode=max - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/build-aio-branch.yml b/.github/workflows/build-aio-branch.yml deleted file mode 100644 index 3cc288354a8..00000000000 --- a/.github/workflows/build-aio-branch.yml +++ /dev/null @@ -1,207 +0,0 @@ -name: Branch Build AIO - -on: - workflow_dispatch: - inputs: - full: - description: 'Run full build' - type: boolean - required: false - default: false - slim: - description: 'Run slim build' - type: boolean - required: false - default: false - base_tag_name: - description: 'Base Tag Name' - required: false - default: '' - release: - types: [released, prereleased] - -env: - TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} - FULL_BUILD_INPUT: ${{ github.event.inputs.full }} - SLIM_BUILD_INPUT: ${{ github.event.inputs.slim }} - -jobs: - branch_build_setup: - name: Build Setup - runs-on: ubuntu-latest - outputs: - gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} - flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} - 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 }} - aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }} - do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }} - do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }} - - 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 - - echo "AIO_BASE_TAG=latest" >> $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 - - if [ "${{ github.event_name}}" == "workflow_dispatch" ] && [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then - echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT - elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then - echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT - else - echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT - fi - fi - echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT - - if [ "${{ env.FULL_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then - echo "DO_FULL_BUILD=true" >> $GITHUB_OUTPUT - else - echo "DO_FULL_BUILD=false" >> $GITHUB_OUTPUT - fi - - if [ "${{ env.SLIM_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then - echo "DO_SLIM_BUILD=true" >> $GITHUB_OUTPUT - else - echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT - fi - - FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g') - echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT - - - id: checkout_files - name: Checkout Files - uses: actions/checkout@v4 - - full_build_push: - if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }} - runs-on: ubuntu-22.04 - needs: [branch_build_setup] - env: - BUILD_TYPE: full - AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }} - AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.flat_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 Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }} - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest - else - TAG=${{ env.AIO_IMAGE_TAGS }} - fi - echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - 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 to Docker Hub - uses: docker/build-push-action@v6.9.0 - with: - context: . - file: ./aio/Dockerfile-app - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.AIO_IMAGE_TAGS }} - push: true - build-args: | - BASE_TAG=${{ env.AIO_BASE_TAG }} - BUILD_TYPE=${{env.BUILD_TYPE}} - cache-from: type=gha - cache-to: type=gha,mode=max - - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - slim_build_push: - if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }} - runs-on: ubuntu-22.04 - needs: [branch_build_setup] - env: - BUILD_TYPE: slim - AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }} - AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.flat_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 Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }} - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest - else - TAG=${{ env.AIO_IMAGE_TAGS }} - fi - echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - 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 to Docker Hub - uses: docker/build-push-action@v6.9.0 - with: - context: . - file: ./aio/Dockerfile-app - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.AIO_IMAGE_TAGS }} - push: true - build-args: | - BASE_TAG=${{ env.AIO_BASE_TAG }} - BUILD_TYPE=${{env.BUILD_TYPE}} - cache-from: type=gha - cache-to: type=gha,mode=max - - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 6c75af134ab..acd9348d2e6 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -25,6 +25,11 @@ on: required: false default: false type: boolean + aio_build: + description: "Build for AIO docker image" + required: false + default: false + type: boolean push: branches: - preview @@ -36,6 +41,7 @@ env: BUILD_TYPE: ${{ github.event.inputs.build_type }} RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }} + AIO_BUILD: ${{ github.event.inputs.aio_build }} jobs: branch_build_setup: @@ -54,11 +60,13 @@ jobs: dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }} dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }} dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }} + dh_img_aio: ${{ steps.set_env_variables.outputs.DH_IMG_AIO }} build_type: ${{steps.set_env_variables.outputs.BUILD_TYPE}} build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }} build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }} release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }} + aio_build: ${{ steps.set_env_variables.outputs.AIO_BUILD }} steps: - id: set_env_variables @@ -84,12 +92,15 @@ jobs: echo "DH_IMG_LIVE=plane-live" >> $GITHUB_OUTPUT echo "DH_IMG_BACKEND=plane-backend" >> $GITHUB_OUTPUT echo "DH_IMG_PROXY=plane-proxy" >> $GITHUB_OUTPUT + echo "DH_IMG_AIO=plane-aio-community" >> $GITHUB_OUTPUT echo "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT BUILD_RELEASE=false BUILD_PRERELEASE=false RELVERSION="latest" + BUILD_AIO=${{ env.AIO_BUILD }} + if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g') echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT @@ -108,10 +119,14 @@ jobs: if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then BUILD_PRERELEASE=true fi + + BUILD_AIO=true fi + echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT + echo "AIO_BUILD=${BUILD_AIO}" >> $GITHUB_OUTPUT - id: checkout_files name: Checkout Files @@ -133,7 +148,7 @@ jobs: docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }} build-context: . - dockerfile-path: ./admin/Dockerfile.admin + dockerfile-path: ./apps/admin/Dockerfile.admin 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 }} @@ -155,7 +170,7 @@ jobs: docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }} build-context: . - dockerfile-path: ./web/Dockerfile.web + dockerfile-path: ./apps/web/Dockerfile.web 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 }} @@ -177,7 +192,7 @@ jobs: docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }} build-context: . - dockerfile-path: ./space/Dockerfile.space + dockerfile-path: ./apps/space/Dockerfile.space 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 }} @@ -199,13 +214,13 @@ jobs: docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} build-context: . - dockerfile-path: ./live/Dockerfile.live + dockerfile-path: ./apps/live/Dockerfile.live 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 }} - branch_build_push_apiserver: + branch_build_push_api: name: Build-Push API Server Docker Image runs-on: ubuntu-22.04 needs: [branch_build_setup] @@ -220,8 +235,8 @@ jobs: dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_backend }} - build-context: ./apiserver - dockerfile-path: ./apiserver/Dockerfile.api + build-context: ./apps/api + dockerfile-path: ./apps/api/Dockerfile.api 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 }} @@ -242,13 +257,102 @@ jobs: dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} docker-image-owner: makeplane docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }} - build-context: ./nginx - dockerfile-path: ./nginx/Dockerfile + build-context: ./apps/proxy + dockerfile-path: ./apps/proxy/Dockerfile.ce 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 }} + branch_build_push_aio: + if: ${{ needs.branch_build_setup.outputs.aio_build == 'true' }} + name: Build-Push AIO Docker Image + runs-on: ubuntu-22.04 + needs: [ + branch_build_setup, + branch_build_push_admin, + branch_build_push_web, + branch_build_push_space, + branch_build_push_live, + branch_build_push_api, + branch_build_push_proxy + ] + steps: + - name: Checkout Files + uses: actions/checkout@v4 + + - name: Prepare AIO Assets + id: prepare_aio_assets + run: | + cd deployments/aio/community + + if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then + aio_version=${{ needs.branch_build_setup.outputs.release_version }} + else + aio_version=${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + bash ./build.sh --release $aio_version + echo "AIO_BUILD_VERSION=${aio_version}" >> $GITHUB_OUTPUT + + - name: Upload AIO Assets + uses: actions/upload-artifact@v4 + with: + path: ./deployments/aio/community/dist + name: aio-assets-dist + + - name: AIO Build and Push + uses: makeplane/actions/build-push@v1.1.0 + with: + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_aio }} + build-context: ./deployments/aio/community + dockerfile-path: ./deployments/aio/community/Dockerfile + 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 }} + additional-assets: aio-assets-dist + additional-assets-dir: ./deployments/aio/community/dist + build-args: | + PLANE_VERSION=${{ steps.prepare_aio_assets.outputs.AIO_BUILD_VERSION }} + + upload_build_assets: + name: Upload Build Assets + runs-on: ubuntu-22.04 + needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_api, branch_build_push_proxy] + steps: + - name: Checkout Files + uses: actions/checkout@v4 + + - name: Update Assets + run: | + if [ "${{ needs.branch_build_setup.outputs.build_type }}" == "Release" ]; then + REL_VERSION=${{ needs.branch_build_setup.outputs.release_version }} + else + REL_VERSION=${{ needs.branch_build_setup.outputs.gh_branch_name }} + fi + + cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh + sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml + # sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env + + - name: Upload Assets + uses: actions/upload-artifact@v4 + with: + name: community-assets + path: | + ./deployments/cli/community/setup.sh + ./deployments/cli/community/restore.sh + ./deployments/cli/community/restore-airgapped.sh + ./deployments/cli/community/docker-compose.yml + ./deployments/cli/community/variables.env + ./deployments/swarm/community/swarm.sh + publish_release: if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} name: Build Release @@ -260,7 +364,7 @@ jobs: branch_build_push_web, branch_build_push_space, branch_build_push_live, - branch_build_push_apiserver, + branch_build_push_api, branch_build_push_proxy, ] env: @@ -271,9 +375,9 @@ jobs: - name: Update Assets run: | - cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh - sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml - # sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env + cp ./deployments/cli/community/install.sh deployments/cli/community/setup.sh + sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deployments/cli/community/docker-compose.yml + # sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deployments/cli/community/variables.env - name: Create Release id: create_release @@ -287,9 +391,10 @@ jobs: prerelease: ${{ env.IS_PRERELEASE }} generate_release_notes: true files: | - ${{ github.workspace }}/deploy/selfhost/setup.sh - ${{ github.workspace }}/deploy/selfhost/swarm.sh - ${{ github.workspace }}/deploy/selfhost/restore.sh - ${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh - ${{ github.workspace }}/deploy/selfhost/docker-compose.yml - ${{ github.workspace }}/deploy/selfhost/variables.env + ${{ github.workspace }}/deployments/cli/community/setup.sh + ${{ github.workspace }}/deployments/cli/community/restore.sh + ${{ github.workspace }}/deployments/cli/community/restore-airgapped.sh + ${{ github.workspace }}/deployments/cli/community/docker-compose.yml + ${{ github.workspace }}/deployments/cli/community/variables.env + ${{ github.workspace }}/deployments/swarm/community/swarm.sh + diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 227425c8f14..acbd9d26eb2 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -6,7 +6,7 @@ on: types: ["opened", "synchronize", "ready_for_review"] jobs: - lint-apiserver: + lint-server: if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: @@ -17,10 +17,10 @@ jobs: python-version: "3.x" # Specify the Python version you need - name: Install Pylint run: python -m pip install ruff - - name: Install Apiserver Dependencies - run: cd apiserver && pip install -r requirements.txt - - name: Lint apiserver - run: ruff check --fix apiserver + - name: Install Server Dependencies + run: cd apps/server && pip install -r requirements.txt + - name: Lint apps/server + run: ruff check --fix apps/server lint-admin: if: github.event.pull_request.draft == false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a156752086..c401c3c2cf5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,7 @@ When opening a new issue, please use a clear and concise title that follows this - For documentation: `📘 Docs: [short description]` **Examples:** + - `🐛 Bug: API token expiry time not saving correctly` - `📘 Docs: Clarify RAM requirement for local setup` - `🚀 Feature: Allow custom time selection for token expiration` @@ -47,7 +48,7 @@ This helps us triage and manage issues more efficiently. The project is a monorepo, with backend api and frontend in a single repo. -The backend is a django project which is kept inside apiserver +The backend is a django project which is kept inside apps/api 1. Clone the repo @@ -105,11 +106,13 @@ To ensure consistency throughout the source code, please keep these rules in min - **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. ## Contributing to language support + This guide is designed to help contributors understand how to add or update translations in the application. ### Understanding translation structure #### File organization + Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks: ``` @@ -122,7 +125,9 @@ packages/i18n/src/locales/ └── [language]/ └── translations.json ``` + #### Nested structure + To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example: ```json @@ -137,32 +142,37 @@ To keep translations organized, we use a nested structure for keys. This makes i ``` ### Translation formatting guide + We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations: #### Examples + - **Simple variables** - ```json - { + + ```json + { "greeting": "Hello, {name}!" - } - ``` + } + ``` - **Pluralization** - ```json - { + ```json + { "items": "{count, plural, one {Work item} other {Work items}}" - } - ``` + } + ``` ### Contributing guidelines #### Updating existing translations + 1. Locate the key in `locales//translations.json`. 2. Update the value while ensuring the key structure remains intact. 3. Preserve any existing ICU formats (e.g., variables, pluralization). #### Adding new translation keys + 1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed. 2. Keep the nesting structure consistent across all languages. @@ -170,48 +180,48 @@ We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) 3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages. ### Adding new languages -Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully: -1. **Update type definitions** -Add the new language to the TLanguage type in the language definitions file: +Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully: - ```typescript - // types/language.ts - export type TLanguage = "en" | "fr" | "your-lang"; - ``` +1. **Update type definitions** + Add the new language to the TLanguage type in the language definitions file: -2. **Add language configuration** -Include the new language in the list of supported languages: +```ts + // packages/i18n/src/types/language.ts + export type TLanguage = "en" | "fr" | "your-lang"; +``` - ```typescript - // constants/language.ts - export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ - { label: "English", value: "en" }, - { label: "Your Language", value: "your-lang" } - ]; - ``` +1. **Add language configuration** + Include the new language in the list of supported languages: +```ts + // packages/i18n/src/constants/language.ts + export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ + { label: "English", value: "en" }, + { label: "Your Language", value: "your-lang" } + ]; +``` -3. **Create translation files** - 1. Create a new folder for your language under locales (e.g., `locales/your-lang/`). +2. **Create translation files** + 1. Create a new folder for your language under locales (e.g., `locales/your-lang/`). 2. Add a `translations.json` file inside the folder. 3. Copy the structure from an existing translation file and translate all keys. -4. **Update import logic** -Modify the language import logic to include your new language: - - ```typescript - private importLanguageFile(language: TLanguage): Promise { - switch (language) { - case "your-lang": - return import("../locales/your-lang/translations.json"); - // ... - } - } - ``` +3. **Update import logic** + Modify the language import logic to include your new language: +```ts + private importLanguageFile(language: TLanguage): Promise { + switch (language) { + case "your-lang": + return import("../locales/your-lang/translations.json"); + // ... + } + } +``` ### Quality checklist + Before submitting your contribution, please ensure the following: - All translation keys exist in every language file. @@ -222,6 +232,7 @@ Before submitting your contribution, please ensure the following: - There are no missing or untranslated keys. #### Pro tips + - When in doubt, refer to the English translations for context. - Verify pluralization works with different numbers. - Ensure dynamic values (e.g., `{name}`) are correctly interpolated. diff --git a/ENV_SETUP.md b/ENV_SETUP.md deleted file mode 100644 index 775d6a55f4e..00000000000 --- a/ENV_SETUP.md +++ /dev/null @@ -1,88 +0,0 @@ -# Environment Variables - -Environment variables are distributed in various files. Please refer them carefully. - -## {PROJECT_FOLDER}/.env - -File is available in the project root folder​ - -``` -# Database Settings -POSTGRES_USER="plane" -POSTGRES_PASSWORD="plane" -POSTGRES_DB="plane" -PGDATA="/var/lib/postgresql/data" -# Redis Settings -REDIS_HOST="plane-redis" -REDIS_PORT="6379" -# AWS Settings -AWS_REGION="" -AWS_ACCESS_KEY_ID="access-key" -AWS_SECRET_ACCESS_KEY="secret-key" -AWS_S3_ENDPOINT_URL="http://plane-minio:9000" -# Changing this requires change in the nginx.conf for uploads if using minio setup -AWS_S3_BUCKET_NAME="uploads" -# Maximum file upload limit -FILE_SIZE_LIMIT=5242880 -# GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # deprecated -OPENAI_API_KEY="sk-" # deprecated -GPT_ENGINE="gpt-3.5-turbo" # deprecated -# Settings related to Docker -DOCKERIZED=1 # deprecated -# set to 1 If using the pre-configured minio setup -USE_MINIO=1 -# Nginx Configuration -NGINX_PORT=80 -``` - -## {PROJECT_FOLDER}/apiserver/.env - -``` -# Backend -# Debug value for api server use it as 0 for production use -DEBUG=0 -CORS_ALLOWED_ORIGINS="http://localhost" -# Database Settings -POSTGRES_USER="plane" -POSTGRES_PASSWORD="plane" -POSTGRES_HOST="plane-db" -POSTGRES_DB="plane" -POSTGRES_PORT=5432 -DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} -# Redis Settings -REDIS_HOST="plane-redis" -REDIS_PORT="6379" -REDIS_URL="redis://${REDIS_HOST}:6379/" -# AWS Settings -AWS_REGION="" -AWS_ACCESS_KEY_ID="access-key" -AWS_SECRET_ACCESS_KEY="secret-key" -AWS_S3_ENDPOINT_URL="http://plane-minio:9000" -# Changing this requires change in the nginx.conf for uploads if using minio setup -AWS_S3_BUCKET_NAME="uploads" -# Maximum file upload limit -FILE_SIZE_LIMIT=5242880 -# Settings related to Docker -DOCKERIZED=1 # deprecated -# set to 1 If using the pre-configured minio setup -USE_MINIO=1 -# Nginx Configuration -NGINX_PORT=80 -# Email redirections and minio domain settings -WEB_URL="http://localhost" -# Gunicorn Workers -GUNICORN_WORKERS=2 -# Base URLs -ADMIN_BASE_URL= -SPACE_BASE_URL= -APP_BASE_URL= -SECRET_KEY="gxoytl7dmnc1y37zahah820z5iq3iozu38cnfjtu3yaau9cd9z" -``` - -## Updates​ - -- The naming convention for containers and images has been updated. -- The plane-worker image will no longer be maintained, as it has been merged with plane-backend. -- The Tiptap pro-extension dependency has been removed, eliminating the need for Tiptap API keys. -- The image name for Plane deployment has been changed to plane-space. diff --git a/admin/Dockerfile.dev b/admin/Dockerfile.dev deleted file mode 100644 index 3bdc71c16d6..00000000000 --- a/admin/Dockerfile.dev +++ /dev/null @@ -1,17 +0,0 @@ -FROM node:20-alpine -RUN apk add --no-cache libc6-compat -# Set working directory -WORKDIR /app - -COPY . . - -RUN yarn global add turbo -RUN yarn install - -ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" - -EXPOSE 3000 - -VOLUME [ "/app/node_modules", "/app/admin/node_modules" ] - -CMD ["yarn", "dev", "--filter=admin"] diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx deleted file mode 100644 index 47ab9480eaf..00000000000 --- a/admin/app/ai/form.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; -import { FC } from "react"; -import { useForm } from "react-hook-form"; -import { Lightbulb } from "lucide-react"; -import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { ControllerInput, TControllerInputFormField } from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; - -type IInstanceAIForm = { - config: IFormattedInstanceConfiguration; -}; - -type AIFormValues = Record; - -export const InstanceAIForm: FC = (props) => { - const { config } = props; - // store - const { updateInstanceConfigurations } = useInstance(); - // form data - const { - handleSubmit, - control, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues: { - LLM_API_KEY: config["LLM_API_KEY"], - LLM_MODEL: config["LLM_MODEL"], - }, - }); - - const aiFormFields: TControllerInputFormField[] = [ - { - key: "LLM_MODEL", - type: "text", - label: "LLM Model", - description: ( - <> - Choose an OpenAI engine.{" "} - - Learn more - - - ), - placeholder: "gpt-4o-mini", - error: Boolean(errors.LLM_MODEL), - required: false, - }, - { - key: "LLM_API_KEY", - type: "password", - label: "API key", - description: ( - <> - You will find your API key{" "} - - here. - - - ), - placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", - error: Boolean(errors.LLM_API_KEY), - required: false, - }, - ]; - - const onSubmit = async (formData: AIFormValues) => { - const payload: Partial = { ...formData }; - - await updateInstanceConfigurations(payload) - .then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success", - message: "AI Settings updated successfully", - }) - ) - .catch((err) => console.error(err)); - }; - - return ( -
-
-
-
OpenAI
-
If you use ChatGPT, this is for you.
-
-
- {aiFormFields.map((field) => ( - - ))} -
-
- -
- - -
- -
- If you have a preferred AI models vendor, please get in{" "} - - touch with us. - -
-
-
-
- ); -}; diff --git a/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx deleted file mode 100644 index d461a626aa2..00000000000 --- a/admin/app/ai/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; -import { AdminLayout } from "@/layouts/admin-layout"; - -export const metadata: Metadata = { - title: "Artificial Intelligence Settings - Plane Web", -}; - -export default function AILayout({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx deleted file mode 100644 index 0c6d81ae6af..00000000000 --- a/admin/app/authentication/github/form.tsx +++ /dev/null @@ -1,227 +0,0 @@ -"use client"; - -import { FC, useState } from "react"; -import isEmpty from "lodash/isEmpty"; -import Link from "next/link"; -import { useForm } from "react-hook-form"; -// plane internal packages -import { API_BASE_URL } from "@plane/constants"; -import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -import { cn } from "@plane/utils"; -// components -import { - CodeBlock, - ConfirmDiscardModal, - ControllerInput, - CopyField, - TControllerInputFormField, - TCopyField, -} from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; - -type Props = { - config: IFormattedInstanceConfiguration; -}; - -type GithubConfigFormValues = Record; - -export const InstanceGithubConfigForm: FC = (props) => { - const { config } = props; - // states - const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); - // store hooks - const { updateInstanceConfigurations } = useInstance(); - // form data - const { - handleSubmit, - control, - reset, - formState: { errors, isDirty, isSubmitting }, - } = useForm({ - defaultValues: { - GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], - GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], - GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"], - }, - }); - - const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; - - const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [ - { - key: "GITHUB_CLIENT_ID", - type: "text", - label: "Client ID", - description: ( - <> - You will get this from your{" "} - - GitHub OAuth application settings. - - - ), - placeholder: "70a44354520df8bd9bcd", - error: Boolean(errors.GITHUB_CLIENT_ID), - required: true, - }, - { - key: "GITHUB_CLIENT_SECRET", - type: "password", - label: "Client secret", - description: ( - <> - Your client secret is also found in your{" "} - - GitHub OAuth application settings. - - - ), - placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", - error: Boolean(errors.GITHUB_CLIENT_SECRET), - required: true, - }, - { - key: "GITHUB_ORGANIZATION_ID", - type: "text", - label: "Organization ID", - description: <>The organization github ID., - placeholder: "123456789", - error: Boolean(errors.GITHUB_ORGANIZATION_ID), - required: false, - }, - ]; - - const GITHUB_SERVICE_FIELD: TCopyField[] = [ - { - key: "Origin_URL", - label: "Origin URL", - url: originURL, - description: ( - <> - We will auto-generate this. Paste this into the Authorized origin URL field{" "} - - here. - - - ), - }, - { - key: "Callback_URI", - label: "Callback URI", - url: `${originURL}/auth/github/callback/`, - description: ( - <> - We will auto-generate this. Paste this into your Authorized Callback URI{" "} - field{" "} - - here. - - - ), - }, - ]; - - const onSubmit = async (formData: GithubConfigFormValues) => { - const payload: Partial = { ...formData }; - - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your GitHub authentication is configured. You should test it now.", - }); - reset({ - GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, - GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, - GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, - }); - }) - .catch((err) => console.error(err)); - }; - - const handleGoBack = (e: React.MouseEvent) => { - if (isDirty) { - e.preventDefault(); - setIsDiscardChangesModalOpen(true); - } - }; - - return ( - <> - setIsDiscardChangesModalOpen(false)} - /> -
-
-
-
GitHub-provided details for Plane
- {GITHUB_FORM_FIELDS.map((field) => ( - - ))} -
-
- - - Go back - -
-
-
-
-
-
Plane-provided details for GitHub
- {GITHUB_SERVICE_FIELD.map((field) => ( - - ))} -
-
-
-
- - ); -}; diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx deleted file mode 100644 index 986a5ebd24e..00000000000 --- a/admin/app/authentication/github/page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -import { useTheme } from "next-themes"; -import useSWR from "swr"; -// plane internal packages -import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; -import { resolveGeneralTheme } from "@plane/utils"; -// components -import { AuthenticationMethodCard } from "@/components/authentication"; -import { PageHeader } from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; -// icons -import githubLightModeImage from "@/public/logos/github-black.png"; -import githubDarkModeImage from "@/public/logos/github-white.png"; -// local components -import { InstanceGithubConfigForm } from "./form"; - -const InstanceGithubAuthenticationPage = observer(() => { - // store - const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); - // state - const [isSubmitting, setIsSubmitting] = useState(false); - // theme - const { resolvedTheme } = useTheme(); - // config - const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? ""; - - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - - const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => { - setIsSubmitting(true); - - const payload = { - [key]: value, - }; - - const updateConfigPromise = updateInstanceConfigurations(payload); - - setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", - success: { - title: "Configuration saved", - message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`, - }, - error: { - title: "Error", - message: () => "Failed to save configuration", - }, - }); - - await updateConfigPromise - .then(() => { - setIsSubmitting(false); - }) - .catch((err) => { - console.error(err); - setIsSubmitting(false); - }); - }; - return ( - <> - -
-
- - } - config={ - { - Boolean(parseInt(enableGithubConfig)) === true - ? updateConfig("IS_GITHUB_ENABLED", "0") - : updateConfig("IS_GITHUB_ENABLED", "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- - ); -}); - -export default InstanceGithubAuthenticationPage; diff --git a/admin/app/authentication/gitlab/form.tsx b/admin/app/authentication/gitlab/form.tsx deleted file mode 100644 index f64158744a4..00000000000 --- a/admin/app/authentication/gitlab/form.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { FC, useState } from "react"; -import isEmpty from "lodash/isEmpty"; -import Link from "next/link"; -import { useForm } from "react-hook-form"; -// plane internal packages -import { API_BASE_URL } from "@plane/constants"; -import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -import { cn } from "@plane/utils"; -// components -import { - CodeBlock, - ConfirmDiscardModal, - ControllerInput, - CopyField, - TControllerInputFormField, - TCopyField, -} from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; - -type Props = { - config: IFormattedInstanceConfiguration; -}; - -type GitlabConfigFormValues = Record; - -export const InstanceGitlabConfigForm: FC = (props) => { - const { config } = props; - // states - const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); - // store hooks - const { updateInstanceConfigurations } = useInstance(); - // form data - const { - handleSubmit, - control, - reset, - formState: { errors, isDirty, isSubmitting }, - } = useForm({ - defaultValues: { - GITLAB_HOST: config["GITLAB_HOST"], - GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"], - GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"], - }, - }); - - const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; - - const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [ - { - key: "GITLAB_HOST", - type: "text", - label: "Host", - description: ( - <> - This is either https://gitlab.com or the domain.tld where you host GitLab. - - ), - placeholder: "https://gitlab.com", - error: Boolean(errors.GITLAB_HOST), - required: true, - }, - { - key: "GITLAB_CLIENT_ID", - type: "text", - label: "Application ID", - description: ( - <> - Get this from your{" "} - - GitLab OAuth application settings - - . - - ), - placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3", - error: Boolean(errors.GITLAB_CLIENT_ID), - required: true, - }, - { - key: "GITLAB_CLIENT_SECRET", - type: "password", - label: "Secret", - description: ( - <> - The client secret is also found in your{" "} - - GitLab OAuth application settings - - . - - ), - placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28", - error: Boolean(errors.GITLAB_CLIENT_SECRET), - required: true, - }, - ]; - - const GITLAB_SERVICE_FIELD: TCopyField[] = [ - { - key: "Callback_URL", - label: "Callback URL", - url: `${originURL}/auth/gitlab/callback/`, - description: ( - <> - We will auto-generate this. Paste this into the Redirect URI field of your{" "} - - GitLab OAuth application - - . - - ), - }, - ]; - - const onSubmit = async (formData: GitlabConfigFormValues) => { - const payload: Partial = { ...formData }; - - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your GitLab authentication is configured. You should test it now.", - }); - reset({ - GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, - GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, - GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); - }; - - const handleGoBack = (e: React.MouseEvent) => { - if (isDirty) { - e.preventDefault(); - setIsDiscardChangesModalOpen(true); - } - }; - - return ( - <> - setIsDiscardChangesModalOpen(false)} - /> -
-
-
-
GitLab-provided details for Plane
- {GITLAB_FORM_FIELDS.map((field) => ( - - ))} -
-
- - - Go back - -
-
-
-
-
-
Plane-provided details for GitLab
- {GITLAB_SERVICE_FIELD.map((field) => ( - - ))} -
-
-
-
- - ); -}; diff --git a/admin/app/authentication/gitlab/page.tsx b/admin/app/authentication/gitlab/page.tsx deleted file mode 100644 index 7a4d8248ef2..00000000000 --- a/admin/app/authentication/gitlab/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -import useSWR from "swr"; -import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; -// components -import { AuthenticationMethodCard } from "@/components/authentication"; -import { PageHeader } from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; -// icons -import GitlabLogo from "@/public/logos/gitlab-logo.svg"; -// local components -import { InstanceGitlabConfigForm } from "./form"; - -const InstanceGitlabAuthenticationPage = observer(() => { - // store - const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); - // state - const [isSubmitting, setIsSubmitting] = useState(false); - // config - const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? ""; - - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - - const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => { - setIsSubmitting(true); - - const payload = { - [key]: value, - }; - - const updateConfigPromise = updateInstanceConfigurations(payload); - - setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", - success: { - title: "Configuration saved", - message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`, - }, - error: { - title: "Error", - message: () => "Failed to save configuration", - }, - }); - - await updateConfigPromise - .then(() => { - setIsSubmitting(false); - }) - .catch((err) => { - console.error(err); - setIsSubmitting(false); - }); - }; - return ( - <> - -
-
- } - config={ - { - Boolean(parseInt(enableGitlabConfig)) === true - ? updateConfig("IS_GITLAB_ENABLED", "0") - : updateConfig("IS_GITLAB_ENABLED", "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- - ); -}); - -export default InstanceGitlabAuthenticationPage; diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx deleted file mode 100644 index 10decdcb05f..00000000000 --- a/admin/app/authentication/google/form.tsx +++ /dev/null @@ -1,214 +0,0 @@ -"use client"; -import { FC, useState } from "react"; -import isEmpty from "lodash/isEmpty"; -import Link from "next/link"; -import { useForm } from "react-hook-form"; -// plane internal packages -import { API_BASE_URL } from "@plane/constants"; -import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -import { cn } from "@plane/utils"; -// components -import { - CodeBlock, - ConfirmDiscardModal, - ControllerInput, - CopyField, - TControllerInputFormField, - TCopyField, -} from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; - -type Props = { - config: IFormattedInstanceConfiguration; -}; - -type GoogleConfigFormValues = Record; - -export const InstanceGoogleConfigForm: FC = (props) => { - const { config } = props; - // states - const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); - // store hooks - const { updateInstanceConfigurations } = useInstance(); - // form data - const { - handleSubmit, - control, - reset, - formState: { errors, isDirty, isSubmitting }, - } = useForm({ - defaultValues: { - GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"], - GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"], - }, - }); - - const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; - - const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [ - { - key: "GOOGLE_CLIENT_ID", - type: "text", - label: "Client ID", - description: ( - <> - Your client ID lives in your Google API Console.{" "} - - Learn more - - - ), - placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com", - error: Boolean(errors.GOOGLE_CLIENT_ID), - required: true, - }, - { - key: "GOOGLE_CLIENT_SECRET", - type: "password", - label: "Client secret", - description: ( - <> - Your client secret should also be in your Google API Console.{" "} - - Learn more - - - ), - placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E", - error: Boolean(errors.GOOGLE_CLIENT_SECRET), - required: true, - }, - ]; - - const GOOGLE_SERVICE_DETAILS: TCopyField[] = [ - { - key: "Origin_URL", - label: "Origin URL", - url: originURL, - description: ( -

- We will auto-generate this. Paste this into your{" "} - Authorized JavaScript origins field. For this OAuth client{" "} - - here. - -

- ), - }, - { - key: "Callback_URI", - label: "Callback URI", - url: `${originURL}/auth/google/callback/`, - description: ( -

- We will auto-generate this. Paste this into your Authorized Redirect URI{" "} - field. For this OAuth client{" "} - - here. - -

- ), - }, - ]; - - const onSubmit = async (formData: GoogleConfigFormValues) => { - const payload: Partial = { ...formData }; - - await updateInstanceConfigurations(payload) - .then((response = []) => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Done!", - message: "Your Google authentication is configured. You should test it now.", - }); - reset({ - GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, - GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, - }); - }) - .catch((err) => console.error(err)); - }; - - const handleGoBack = (e: React.MouseEvent) => { - if (isDirty) { - e.preventDefault(); - setIsDiscardChangesModalOpen(true); - } - }; - - return ( - <> - setIsDiscardChangesModalOpen(false)} - /> -
-
-
-
Google-provided details for Plane
- {GOOGLE_FORM_FIELDS.map((field) => ( - - ))} -
-
- - - Go back - -
-
-
-
-
-
Plane-provided details for Google
- {GOOGLE_SERVICE_DETAILS.map((field) => ( - - ))} -
-
-
-
- - ); -}; diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx deleted file mode 100644 index 992c7a8a7a1..00000000000 --- a/admin/app/authentication/google/page.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -import useSWR from "swr"; -import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; -// components -import { AuthenticationMethodCard } from "@/components/authentication"; -import { PageHeader } from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; -// icons -import GoogleLogo from "@/public/logos/google-logo.svg"; -// local components -import { InstanceGoogleConfigForm } from "./form"; - -const InstanceGoogleAuthenticationPage = observer(() => { - // store - const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); - // state - const [isSubmitting, setIsSubmitting] = useState(false); - // config - const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? ""; - - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - - const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => { - setIsSubmitting(true); - - const payload = { - [key]: value, - }; - - const updateConfigPromise = updateInstanceConfigurations(payload); - - setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", - success: { - title: "Configuration saved", - message: () => `Google authentication is now ${value ? "active" : "disabled"}.`, - }, - error: { - title: "Error", - message: () => "Failed to save configuration", - }, - }); - - await updateConfigPromise - .then(() => { - setIsSubmitting(false); - }) - .catch((err) => { - console.error(err); - setIsSubmitting(false); - }); - }; - return ( - <> - -
-
- } - config={ - { - Boolean(parseInt(enableGoogleConfig)) === true - ? updateConfig("IS_GOOGLE_ENABLED", "0") - : updateConfig("IS_GOOGLE_ENABLED", "1"); - }} - size="sm" - disabled={isSubmitting || !formattedConfig} - /> - } - disabled={isSubmitting || !formattedConfig} - withBorder={false} - /> -
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- - ); -}); - -export default InstanceGoogleAuthenticationPage; diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx deleted file mode 100644 index c5b70030d76..00000000000 --- a/admin/app/authentication/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; -import { AdminLayout } from "@/layouts/admin-layout"; - -export const metadata: Metadata = { - title: "Authentication Settings - Plane Web", -}; - -export default function AuthenticationLayout({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/admin/app/email/layout.tsx b/admin/app/email/layout.tsx deleted file mode 100644 index 2084af1ea41..00000000000 --- a/admin/app/email/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; -import { AdminLayout } from "@/layouts/admin-layout"; - -interface EmailLayoutProps { - children: ReactNode; -} - -export const metadata: Metadata = { - title: "Email Settings - Plane Web", -}; - -export default function EmailLayout({ children }: EmailLayoutProps) { - return {children}; -} diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx deleted file mode 100644 index 33fedc052de..00000000000 --- a/admin/app/email/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -import useSWR from "swr"; -import { Loader } from "@plane/ui"; -// hooks -import { useInstance } from "@/hooks/store"; -// components -import { InstanceEmailForm } from "./email-config-form"; - -const InstanceEmailPage = observer(() => { - // store - const { fetchInstanceConfigurations, formattedConfig } = useInstance(); - - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - - return ( - <> -
-
-
Secure emails from your own instance
-
- Plane can send useful emails to you and your users from your own instance without talking to the Internet. -
- Set it up below and please test your settings before you save them.  - Misconfigs can lead to email bounces and errors. -
-
-
-
- {formattedConfig ? ( - - ) : ( - - - - - - - - )} -
-
- - ); -}); - -export default InstanceEmailPage; diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx deleted file mode 100644 index 4fbd7053561..00000000000 --- a/admin/app/general/form.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; -import { FC } from "react"; -import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; -import { Telescope } from "lucide-react"; -// types -import { IInstance, IInstanceAdmin } from "@plane/types"; -// ui -import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; -// components -import { ControllerInput } from "@/components/common"; -import { useInstance } from "@/hooks/store"; -import { IntercomConfig } from "./intercom"; -// hooks - -export interface IGeneralConfigurationForm { - instance: IInstance; - instanceAdmins: IInstanceAdmin[]; -} - -export const GeneralConfigurationForm: FC = observer((props) => { - const { instance, instanceAdmins } = props; - // hooks - const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance(); - - // form data - const { - handleSubmit, - control, - watch, - formState: { errors, isSubmitting }, - } = useForm>({ - defaultValues: { - instance_name: instance?.instance_name, - is_telemetry_enabled: instance?.is_telemetry_enabled, - }, - }); - - const onSubmit = async (formData: Partial) => { - const payload: Partial = { ...formData }; - - // update the intercom configuration - const isIntercomEnabled = - instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"; - if (!payload.is_telemetry_enabled && isIntercomEnabled) { - try { - await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" }); - } catch (error) { - console.error(error); - } - } - - await updateInstanceInfo(payload) - .then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success", - message: "Settings updated successfully", - }) - ) - .catch((err) => console.error(err)); - }; - - return ( -
-
-
Instance details
-
- - -
-

Email

- -
- -
-

Instance ID

- -
-
-
- -
-
Chat + telemetry
- -
-
-
-
- -
-
-
-
- Let Plane collect anonymous usage data -
-
- No PII is collected.This anonymized data is used to understand how you use Plane and build new features - in line with{" "} - - our Telemetry Policy. - -
-
-
-
- ( - - )} - /> -
-
-
- -
- -
-
- ); -}); diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx deleted file mode 100644 index 374257daade..00000000000 --- a/admin/app/general/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; -import { AdminLayout } from "@/layouts/admin-layout"; - -export const metadata: Metadata = { - title: "General Settings - Plane Web", -}; - -export default function GeneralLayout({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/admin/app/image/form.tsx b/admin/app/image/form.tsx deleted file mode 100644 index 61d2875edaa..00000000000 --- a/admin/app/image/form.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; -import { FC } from "react"; -import { useForm } from "react-hook-form"; -import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { ControllerInput } from "@/components/common"; -// hooks -import { useInstance } from "@/hooks/store"; - -type IInstanceImageConfigForm = { - config: IFormattedInstanceConfiguration; -}; - -type ImageConfigFormValues = Record; - -export const InstanceImageConfigForm: FC = (props) => { - const { config } = props; - // store hooks - const { updateInstanceConfigurations } = useInstance(); - // form data - const { - handleSubmit, - control, - formState: { errors, isSubmitting }, - } = useForm({ - defaultValues: { - UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"], - }, - }); - - const onSubmit = async (formData: ImageConfigFormValues) => { - const payload: Partial = { ...formData }; - - await updateInstanceConfigurations(payload) - .then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success", - message: "Image Configuration Settings updated successfully", - }) - ) - .catch((err) => console.error(err)); - }; - - return ( -
-
- - You will find your access key in your Unsplash developer console.  - - Learn more. - - - } - placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd" - error={Boolean(errors.UNSPLASH_ACCESS_KEY)} - required - /> -
- -
- -
-
- ); -}; diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx deleted file mode 100644 index 32233e07837..00000000000 --- a/admin/app/image/layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; -import { AdminLayout } from "@/layouts/admin-layout"; - -interface ImageLayoutProps { - children: ReactNode; -} - -export const metadata: Metadata = { - title: "Images Settings - Plane Web", -}; - -export default function ImageLayout({ children }: ImageLayoutProps) { - return {children}; -} diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx deleted file mode 100644 index b10e9186c3c..00000000000 --- a/admin/app/layout.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import { ReactNode } from "react"; -import { ThemeProvider, useTheme } from "next-themes"; -import { SWRConfig } from "swr"; -// plane imports -import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants"; -import { Toast } from "@plane/ui"; -import { resolveGeneralTheme } from "@plane/utils"; -// lib -import { InstanceProvider } from "@/lib/instance-provider"; -import { StoreProvider } from "@/lib/store-provider"; -import { UserProvider } from "@/lib/user-provider"; -// styles -import "@/styles/globals.css"; - -const ToastWithTheme = () => { - const { resolvedTheme } = useTheme(); - return ; -}; - -export default function RootLayout({ children }: { children: ReactNode }) { - const ASSET_PREFIX = ADMIN_BASE_PATH; - return ( - - - - - - - - - - - - - - - {children} - - - - - - - ); -} diff --git a/admin/app/page.tsx b/admin/app/page.tsx deleted file mode 100644 index 1a274025a0a..00000000000 --- a/admin/app/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Metadata } from "next"; -// components -import { InstanceSignInForm } from "@/components/login"; -// layouts -import { DefaultLayout } from "@/layouts/default-layout"; - -export const metadata: Metadata = { - title: "Plane | Simple, extensible, open-source project management tool.", - description: - "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", - openGraph: { - title: "Plane | Simple, extensible, open-source project management tool.", - description: - "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", - url: "https://plane.so/", - }, - keywords: - "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration", - twitter: { - site: "@planepowers", - }, -}; - -export default async function LoginPage() { - return ( - - - - ); -} diff --git a/admin/app/workspace/layout.tsx b/admin/app/workspace/layout.tsx deleted file mode 100644 index 9f2a63c67d5..00000000000 --- a/admin/app/workspace/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; -// layouts -import { AdminLayout } from "@/layouts/admin-layout"; - -export const metadata: Metadata = { - title: "Workspace Management - Plane Web", -}; - -export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/admin/app/workspace/page.tsx b/admin/app/workspace/page.tsx deleted file mode 100644 index 3ca34b69e39..00000000000 --- a/admin/app/workspace/page.tsx +++ /dev/null @@ -1,167 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import useSWR from "swr"; -import { Loader as LoaderIcon } from "lucide-react"; -// types -import { TInstanceConfigurationKeys } from "@plane/types"; -import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui"; -import { cn } from "@plane/utils"; -// components -import { WorkspaceListItem } from "@/components/workspace"; -// hooks -import { useInstance, useWorkspace } from "@/hooks/store"; - -const WorkspaceManagementPage = observer(() => { - // states - const [isSubmitting, setIsSubmitting] = useState(false); - // store - const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); - const { - workspaceIds, - loader: workspaceLoader, - paginationInfo, - fetchWorkspaces, - fetchNextWorkspaces, - } = useWorkspace(); - // derived values - const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? ""; - const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined; - - // fetch data - useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); - useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces()); - - const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { - setIsSubmitting(true); - - const payload = { - [key]: value, - }; - - const updateConfigPromise = updateInstanceConfigurations(payload); - - setPromiseToast(updateConfigPromise, { - loading: "Saving configuration", - success: { - title: "Success", - message: () => "Configuration saved successfully", - }, - error: { - title: "Error", - message: () => "Failed to save configuration", - }, - }); - - await updateConfigPromise - .then(() => { - setIsSubmitting(false); - }) - .catch((err) => { - console.error(err); - setIsSubmitting(false); - }); - }; - - return ( -
-
-
-
Workspaces on this instance
-
- See all workspaces and control who can create them. -
-
-
-
-
- {formattedConfig ? ( -
-
-
-
Prevent anyone else from creating a workspace.
-
- Toggling this on will let only you create workspaces. You will have to invite users to new - workspaces. -
-
-
-
-
- { - if (Boolean(parseInt(disableWorkspaceCreation)) === true) { - updateConfig("DISABLE_WORKSPACE_CREATION", "0"); - } else { - updateConfig("DISABLE_WORKSPACE_CREATION", "1"); - } - }} - size="sm" - disabled={isSubmitting} - /> -
-
-
- ) : ( - - - - )} - {workspaceLoader !== "init-loader" ? ( - <> -
-
-
- All workspaces on this instance{" "} - • {workspaceIds.length} - {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( - - )} -
-
- You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a - Member. -
-
-
- - Create workspace - -
-
-
- {workspaceIds.map((workspaceId) => ( - - ))} -
- {hasNextPage && ( -
- -
- )} - - ) : ( - - - - - - - )} -
-
-
- ); -}); - -export default WorkspaceManagementPage; diff --git a/admin/ce/components/authentication/authentication-modes.tsx b/admin/ce/components/authentication/authentication-modes.tsx deleted file mode 100644 index 3c7ec111a33..00000000000 --- a/admin/ce/components/authentication/authentication-modes.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { observer } from "mobx-react"; -import Image from "next/image"; -import { useTheme } from "next-themes"; -// types -import { - TGetBaseAuthenticationModeProps, - TInstanceAuthenticationMethodKeys, - TInstanceAuthenticationModes, -} from "@plane/types"; -// components -import { AuthenticationMethodCard } from "@/components/authentication"; -// helpers -import { getBaseAuthenticationModes } from "@/lib/auth-helpers"; -// plane admin components -import { UpgradeButton } from "@/plane-admin/components/common"; -// images -import OIDCLogo from "@/public/logos/oidc-logo.svg"; -import SAMLLogo from "@/public/logos/saml-logo.svg"; - -export type TAuthenticationModeProps = { - disabled: boolean; - updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; -}; - -// Authentication methods -export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ - disabled, - updateConfig, - resolvedTheme, -}) => [ - ...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }), - { - key: "oidc", - name: "OIDC", - description: "Authenticate your users via the OpenID Connect protocol.", - icon: OIDC Logo, - config: , - unavailable: true, - }, - { - key: "saml", - name: "SAML", - description: "Authenticate your users via the Security Assertion Markup Language protocol.", - icon: SAML Logo, - config: , - unavailable: true, - }, -]; - -export const AuthenticationModes: React.FC = observer((props) => { - const { disabled, updateConfig } = props; - // next-themes - const { resolvedTheme } = useTheme(); - - return ( - <> - {getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => ( - - ))} - - ); -}); diff --git a/admin/core/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx deleted file mode 100644 index d776477497b..00000000000 --- a/admin/core/components/admin-sidebar/help-section.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { FC, useState, useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; -import { Transition } from "@headlessui/react"; -// plane internal packages -import { WEB_BASE_URL } from "@plane/constants"; -import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; -import { cn } from "@plane/utils"; -// hooks -import { useTheme } from "@/hooks/store"; -// assets -// eslint-disable-next-line import/order -import packageJson from "package.json"; - -const helpOptions = [ - { - name: "Documentation", - href: "https://docs.plane.so/", - Icon: FileText, - }, - { - name: "Join our Discord", - href: "https://discord.com/invite/A92xrEGCge", - Icon: DiscordIcon, - }, - { - name: "Report a bug", - href: "https://github.com/makeplane/plane/issues/new/choose", - Icon: GithubIcon, - }, -]; - -export const HelpSection: FC = observer(() => { - // states - const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); - // store - const { isSidebarCollapsed, toggleSidebar } = useTheme(); - // refs - const helpOptionsRef = useRef(null); - - const redirectionLink = encodeURI(WEB_BASE_URL + "/"); - - return ( -
-
- - - - {!isSidebarCollapsed && "Redirect to Plane"} - - - - - - - - -
- -
- -
-
- {helpOptions.map(({ name, Icon, href }) => { - if (href) - return ( - -
-
- -
- {name} -
- - ); - else - return ( - - ); - })} -
-
Version: v{packageJson.version}
-
-
-
-
- ); -}); diff --git a/admin/core/components/admin-sidebar/index.ts b/admin/core/components/admin-sidebar/index.ts deleted file mode 100644 index e800fe3c5c0..00000000000 --- a/admin/core/components/admin-sidebar/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./root"; -export * from "./help-section"; -export * from "./sidebar-menu"; -export * from "./sidebar-dropdown"; -export * from "./sidebar-menu-hamburger-toogle"; diff --git a/admin/core/components/admin-sidebar/root.tsx b/admin/core/components/admin-sidebar/root.tsx deleted file mode 100644 index 05dde0d8ab1..00000000000 --- a/admin/core/components/admin-sidebar/root.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { FC, useEffect, useRef } from "react"; -import { observer } from "mobx-react"; -// plane helpers -import { useOutsideClickDetector } from "@plane/hooks"; -// components -import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; -// hooks -import { useTheme } from "@/hooks/store"; - -export const InstanceSidebar: FC = observer(() => { - // store - const { isSidebarCollapsed, toggleSidebar } = useTheme(); - - const ref = useRef(null); - - useOutsideClickDetector(ref, () => { - if (isSidebarCollapsed === false) { - if (window.innerWidth < 768) { - toggleSidebar(!isSidebarCollapsed); - } - } - }); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth <= 768) { - toggleSidebar(true); - } - }; - handleResize(); - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, [toggleSidebar]); - - return ( -
-
- - - -
-
- ); -}); diff --git a/admin/core/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/core/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx deleted file mode 100644 index 337d9baaf34..00000000000 --- a/admin/core/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -// hooks -import { Menu } from "lucide-react"; -import { useTheme } from "@/hooks/store"; -// icons - -export const SidebarHamburgerToggle: FC = observer(() => { - const { isSidebarCollapsed, toggleSidebar } = useTheme(); - return ( -
toggleSidebar(!isSidebarCollapsed)} - > - -
- ); -}); diff --git a/admin/core/components/auth-header.tsx b/admin/core/components/auth-header.tsx deleted file mode 100644 index b97dd7c9eae..00000000000 --- a/admin/core/components/auth-header.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -import { usePathname } from "next/navigation"; -// mobx -// ui -import { Settings } from "lucide-react"; -// icons -import { Breadcrumbs } from "@plane/ui"; -// components -import { SidebarHamburgerToggle } from "@/components/admin-sidebar"; -import { BreadcrumbLink } from "@/components/common"; - -export const InstanceHeader: FC = observer(() => { - const pathName = usePathname(); - - const getHeaderTitle = (pathName: string) => { - switch (pathName) { - case "general": - return "General"; - case "ai": - return "Artificial Intelligence"; - case "email": - return "Email"; - case "authentication": - return "Authentication"; - case "image": - return "Image"; - case "google": - return "Google"; - case "github": - return "GitHub"; - case "gitlab": - return "GitLab"; - case "workspace": - return "Workspace"; - case "create": - return "Create"; - default: - return pathName.toUpperCase(); - } - }; - - // Function to dynamically generate breadcrumb items based on pathname - const generateBreadcrumbItems = (pathname: string) => { - const pathSegments = pathname.split("/").slice(1); // removing the first empty string. - pathSegments.pop(); - - let currentUrl = ""; - const breadcrumbItems = pathSegments.map((segment) => { - currentUrl += "/" + segment; - return { - title: getHeaderTitle(segment), - href: currentUrl, - }; - }); - return breadcrumbItems; - }; - - const breadcrumbItems = generateBreadcrumbItems(pathName); - - return ( -
-
- - {breadcrumbItems.length >= 0 && ( -
- - } - /> - } - /> - {breadcrumbItems.map( - (item) => - item.title && ( - } - /> - ) - )} - -
- )} -
-
- ); -}); diff --git a/admin/core/components/authentication/index.ts b/admin/core/components/authentication/index.ts deleted file mode 100644 index d189a727ba6..00000000000 --- a/admin/core/components/authentication/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./auth-banner"; -export * from "./email-config-switch"; -export * from "./password-config-switch"; -export * from "./authentication-method-card"; -export * from "./gitlab-config"; -export * from "./github-config"; -export * from "./google-config"; diff --git a/admin/core/components/common/index.ts b/admin/core/components/common/index.ts deleted file mode 100644 index 4d664b0a4aa..00000000000 --- a/admin/core/components/common/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from "./breadcrumb-link"; -export * from "./confirm-discard-modal"; -export * from "./controller-input"; -export * from "./copy-field"; -export * from "./password-strength-meter"; -export * from "./banner"; -export * from "./empty-state"; -export * from "./logo-spinner"; -export * from "./page-header"; -export * from "./code-block"; diff --git a/admin/core/components/instance/index.ts b/admin/core/components/instance/index.ts deleted file mode 100644 index 56d1933f419..00000000000 --- a/admin/core/components/instance/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./instance-not-ready"; -export * from "./instance-failure-view"; -export * from "./setup-form"; diff --git a/admin/core/components/login/index.ts b/admin/core/components/login/index.ts deleted file mode 100644 index bdeb387f3fb..00000000000 --- a/admin/core/components/login/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./sign-in-form"; diff --git a/admin/core/components/login/sign-in-form.tsx b/admin/core/components/login/sign-in-form.tsx deleted file mode 100644 index 553ffe6c56f..00000000000 --- a/admin/core/components/login/sign-in-form.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; - -import { FC, useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { Eye, EyeOff } from "lucide-react"; -// plane internal packages -import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; -import { AuthService } from "@plane/services"; -import { Button, Input, Spinner } from "@plane/ui"; -// components -import { Banner } from "@/components/common"; -// helpers -import { authErrorHandler } from "@/lib/auth-helpers"; -// local components -import { AuthBanner } from "../authentication"; - -// service initialization -const authService = new AuthService(); - -// error codes -enum EErrorCodes { - INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED", - REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD", - INVALID_EMAIL = "INVALID_EMAIL", - USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST", - AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED", -} - -type TError = { - type: EErrorCodes | undefined; - message: string | undefined; -}; - -// form data -type TFormData = { - email: string; - password: string; -}; - -const defaultFromData: TFormData = { - email: "", - password: "", -}; - -export const InstanceSignInForm: FC = (props) => { - const {} = props; - // search params - const searchParams = useSearchParams(); - const emailParam = searchParams.get("email") || undefined; - const errorCode = searchParams.get("error_code") || undefined; - const errorMessage = searchParams.get("error_message") || undefined; - // state - const [showPassword, setShowPassword] = useState(false); - const [csrfToken, setCsrfToken] = useState(undefined); - const [formData, setFormData] = useState(defaultFromData); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errorInfo, setErrorInfo] = useState(undefined); - - const handleFormChange = (key: keyof TFormData, value: string | boolean) => - setFormData((prev) => ({ ...prev, [key]: value })); - - useEffect(() => { - if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); - }, [csrfToken]); - - useEffect(() => { - if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam })); - }, [emailParam]); - - // derived values - const errorData: TError = useMemo(() => { - if (errorCode && errorMessage) { - switch (errorCode) { - case EErrorCodes.INSTANCE_NOT_CONFIGURED: - return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; - case EErrorCodes.REQUIRED_EMAIL_PASSWORD: - return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage }; - case EErrorCodes.INVALID_EMAIL: - return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; - case EErrorCodes.USER_DOES_NOT_EXIST: - return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage }; - case EErrorCodes.AUTHENTICATION_FAILED: - return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage }; - default: - return { type: undefined, message: undefined }; - } - } else return { type: undefined, message: undefined }; - }, [errorCode, errorMessage]); - - const isButtonDisabled = useMemo( - () => (!isSubmitting && formData.email && formData.password ? false : true), - [formData.email, formData.password, isSubmitting] - ); - - useEffect(() => { - if (errorCode) { - const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes); - if (errorDetail) { - setErrorInfo(errorDetail); - } - } - }, [errorCode]); - - return ( -
-
-
-

- Manage your Plane instance -

-

- Configure instance-wide settings to secure your instance -

-
- - {errorData.type && errorData?.message ? ( - - ) : ( - <>{errorInfo && setErrorInfo(value)} />} - )} - -
setIsSubmitting(true)} - onError={() => setIsSubmitting(false)} - > - - -
- - handleFormChange("email", e.target.value)} - autoComplete="on" - autoFocus - /> -
- -
- -
- handleFormChange("password", e.target.value)} - autoComplete="on" - /> - {showPassword ? ( - - ) : ( - - )} -
-
-
- -
-
-
-
- ); -}; diff --git a/admin/core/components/workspace/index.ts b/admin/core/components/workspace/index.ts deleted file mode 100644 index 24950c4f20f..00000000000 --- a/admin/core/components/workspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./list-item"; diff --git a/admin/core/layouts/admin-layout.tsx b/admin/core/layouts/admin-layout.tsx deleted file mode 100644 index 88f71aa3c4a..00000000000 --- a/admin/core/layouts/admin-layout.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import { FC, ReactNode, useEffect } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/navigation"; -// components -import { InstanceSidebar } from "@/components/admin-sidebar"; -import { InstanceHeader } from "@/components/auth-header"; -import { LogoSpinner } from "@/components/common"; -import { NewUserPopup } from "@/components/new-user-popup"; -// hooks -import { useUser } from "@/hooks/store"; - -type TAdminLayout = { - children: ReactNode; -}; - -export const AdminLayout: FC = observer((props) => { - const { children } = props; - // router - const router = useRouter(); - // store hooks - const { isUserLoggedIn } = useUser(); - - useEffect(() => { - if (isUserLoggedIn === false) { - router.push("/"); - } - }, [router, isUserLoggedIn]); - - if (isUserLoggedIn === undefined) { - return ( -
- -
- ); - } - - return ( -
- -
- -
{children}
-
- -
- ); -}); diff --git a/admin/core/layouts/default-layout.tsx b/admin/core/layouts/default-layout.tsx deleted file mode 100644 index 1be40ea1296..00000000000 --- a/admin/core/layouts/default-layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { FC, ReactNode } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -// logo/ images -import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg"; -import PlaneBackgroundPattern from "public/auth/background-pattern.svg"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; - -type TDefaultLayout = { - children: ReactNode; - withoutBackground?: boolean; -}; - -export const DefaultLayout: FC = (props) => { - const { children, withoutBackground = false } = props; - // hooks - const { resolvedTheme } = useTheme(); - const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; - - const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; - - return ( -
-
-
-
- - Plane logo - -
-
- {!withoutBackground && ( -
- Plane background pattern -
- )} -
{children}
-
-
- ); -}; diff --git a/admin/core/lib/instance-provider.tsx b/admin/core/lib/instance-provider.tsx deleted file mode 100644 index d021e3b83a2..00000000000 --- a/admin/core/lib/instance-provider.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -import useSWR from "swr"; -// components -import { LogoSpinner } from "@/components/common"; -import { InstanceSetupForm, InstanceFailureView } from "@/components/instance"; -// hooks -import { useInstance } from "@/hooks/store"; -// layout -import { DefaultLayout } from "@/layouts/default-layout"; - -type InstanceProviderProps = { - children: ReactNode; -}; - -export const InstanceProvider: FC = observer((props) => { - const { children } = props; - // store hooks - const { instance, error, fetchInstanceInfo } = useInstance(); - // fetching instance details - useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), { - revalidateOnFocus: false, - revalidateIfStale: false, - errorRetryCount: 0, - }); - - if (!instance && !error) - return ( -
- -
- ); - - if (error) { - return ( - -
- -
-
- ); - } - - if (!instance?.is_setup_done) { - return ( - -
- -
-
- ); - } - - return <>{children}; -}); diff --git a/admin/core/lib/user-provider.tsx b/admin/core/lib/user-provider.tsx deleted file mode 100644 index 17d70262797..00000000000 --- a/admin/core/lib/user-provider.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { FC, ReactNode, useEffect } from "react"; -import { observer } from "mobx-react"; -import useSWR from "swr"; -// hooks -import { useInstance, useTheme, useUser } from "@/hooks/store"; - -interface IUserProvider { - children: ReactNode; -} - -export const UserProvider: FC = observer(({ children }) => { - // hooks - const { isSidebarCollapsed, toggleSidebar } = useTheme(); - const { currentUser, fetchCurrentUser } = useUser(); - const { fetchInstanceAdmins } = useInstance(); - - useSWR("CURRENT_USER", () => fetchCurrentUser(), { - shouldRetryOnError: false, - }); - useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins()); - - useEffect(() => { - const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed"); - const localBoolValue = localValue ? (localValue === "true" ? true : false) : false; - if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue); - }, [isSidebarCollapsed, currentUser, toggleSidebar]); - - return <>{children}; -}); diff --git a/admin/next.config.js b/admin/next.config.js deleted file mode 100644 index 421f645e8b1..00000000000 --- a/admin/next.config.js +++ /dev/null @@ -1,27 +0,0 @@ -/** @type {import('next').NextConfig} */ - -const nextConfig = { - trailingSlash: true, - reactStrictMode: false, - swcMinify: true, - output: "standalone", - images: { - unoptimized: true, - }, - basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", - transpilePackages: [ - "@plane/constants", - "@plane/editor", - "@plane/hooks", - "@plane/i18n", - "@plane/logger", - "@plane/propel", - "@plane/services", - "@plane/shared-state", - "@plane/types", - "@plane/ui", - "@plane/utils", - ], -}; - -module.exports = nextConfig; diff --git a/admin/package.json b/admin/package.json deleted file mode 100644 index 6220dc87646..00000000000 --- a/admin/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "admin", - "description": "Admin UI for Plane", - "version": "0.27.1", - "license": "AGPL-3.0", - "private": true, - "scripts": { - "dev": "turbo run develop", - "develop": "next dev --port 3001", - "build": "next build", - "preview": "next build && next start", - "start": "next start", - "format": "prettier --write .", - "lint": "eslint . --ext .ts,.tsx", - "lint:errors": "eslint . --ext .ts,.tsx --quiet" - }, - "dependencies": { - "@headlessui/react": "^1.7.19", - "@plane/constants": "*", - "@plane/hooks": "*", - "@plane/propel": "*", - "@plane/services": "*", - "@plane/types": "*", - "@plane/ui": "*", - "@plane/utils": "*", - "@tailwindcss/typography": "^0.5.9", - "@types/lodash": "^4.17.0", - "autoprefixer": "10.4.14", - "axios": "^1.8.3", - "lodash": "^4.17.21", - "lucide-react": "^0.469.0", - "mobx": "^6.12.0", - "mobx-react": "^9.1.1", - "next": "14.2.30", - "next-themes": "^0.2.1", - "postcss": "^8.4.38", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hook-form": "7.51.5", - "swr": "^2.2.4", - "uuid": "^9.0.1", - "zxcvbn": "^4.4.2" - }, - "devDependencies": { - "@plane/eslint-config": "*", - "@plane/tailwind-config": "*", - "@plane/typescript-config": "*", - "@types/node": "18.16.1", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.2.18", - "@types/uuid": "^9.0.8", - "@types/zxcvbn": "^4.4.4", - "typescript": "5.8.3" - } -} diff --git a/admin/tsconfig.json b/admin/tsconfig.json deleted file mode 100644 index df72d07b406..00000000000 --- a/admin/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "@plane/typescript-config/nextjs.json", - "compilerOptions": { - "plugins": [ - { - "name": "next" - } - ], - "baseUrl": ".", - "paths": { - "@/*": ["core/*"], - "@/public/*": ["public/*"], - "@/plane-admin/*": ["ce/*"], - "@/styles/*": ["styles/*"] - }, - "strictNullChecks": true - }, - "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/aio/Dockerfile-app b/aio/Dockerfile-app deleted file mode 100644 index 7ffe3f803bc..00000000000 --- a/aio/Dockerfile-app +++ /dev/null @@ -1,182 +0,0 @@ -ARG BASE_TAG=develop -ARG BUILD_TYPE=full -# ***************************************************************************** -# STAGE 1: Build the project -# ***************************************************************************** -FROM node:18-alpine AS builder -RUN apk add --no-cache libc6-compat -# Set working directory -WORKDIR /app - -RUN yarn global add turbo -COPY . . - -RUN turbo prune --scope=web --scope=space --scope=admin --docker - -# ***************************************************************************** -# STAGE 2: Install dependencies & build the project -# ***************************************************************************** -# Add lockfile and package.json's of isolated subworkspace -FROM node:18-alpine AS installer - -RUN apk add --no-cache libc6-compat -WORKDIR /app - -# First install the dependencies (as they change less often) -COPY .gitignore .gitignore -COPY --from=builder /app/out/json/ . -COPY --from=builder /app/out/yarn.lock ./yarn.lock -RUN yarn install - -# # Build the project -COPY --from=builder /app/out/full/ . -COPY turbo.json turbo.json - -ARG NEXT_PUBLIC_API_BASE_URL="" -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -ARG NEXT_PUBLIC_ADMIN_BASE_URL="" -ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL - -ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" -ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH - -ARG NEXT_PUBLIC_SPACE_BASE_URL="" -ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL - -ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" -ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH - -ARG NEXT_PUBLIC_WEB_BASE_URL="" -ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL - -ENV NEXT_TELEMETRY_DISABLED=1 -ENV TURBO_TELEMETRY_DISABLED=1 - -RUN yarn turbo run build --filter=web --filter=space --filter=admin - -# ***************************************************************************** -# STAGE 3: Copy the project and start it -# ***************************************************************************** -FROM makeplane/plane-aio-base:${BUILD_TYPE}-${BASE_TAG} AS runner - -WORKDIR /app - -SHELL [ "/bin/bash", "-c" ] - -# PYTHON APPLICATION SETUP - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 - -COPY apiserver/requirements.txt ./api/ -COPY apiserver/requirements ./api/requirements - -RUN pip install -r ./api/requirements.txt --compile --no-cache-dir - -# Add in Django deps and generate Django's static files -COPY apiserver/manage.py ./api/manage.py -COPY apiserver/plane ./api/plane/ -COPY apiserver/templates ./api/templates/ -COPY package.json ./api/package.json - -COPY apiserver/bin ./api/bin/ - -RUN chmod +x ./api/bin/* -RUN chmod -R 777 ./api/ - -# NEXTJS BUILDS -COPY --from=installer /app/web/next.config.js ./web/ -COPY --from=installer /app/web/package.json ./web/ -COPY --from=installer /app/web/.next/standalone ./web -COPY --from=installer /app/web/.next/static ./web/web/.next/static -COPY --from=installer /app/web/public ./web/web/public - -COPY --from=installer /app/space/next.config.js ./space/ -COPY --from=installer /app/space/package.json ./space/ -COPY --from=installer /app/space/.next/standalone ./space -COPY --from=installer /app/space/.next/static ./space/space/.next/static -COPY --from=installer /app/space/public ./space/space/public - -COPY --from=installer /app/admin/next.config.js ./admin/ -COPY --from=installer /app/admin/package.json ./admin/ -COPY --from=installer /app/admin/.next/standalone ./admin -COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static -COPY --from=installer /app/admin/public ./admin/admin/public - -ARG NEXT_PUBLIC_API_BASE_URL="" -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL - -ARG NEXT_PUBLIC_ADMIN_BASE_URL="" -ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL - -ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" -ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH - -ARG NEXT_PUBLIC_SPACE_BASE_URL="" -ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL - -ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" -ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH - -ARG NEXT_PUBLIC_WEB_BASE_URL="" -ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL - -ENV NEXT_TELEMETRY_DISABLED=1 -ENV TURBO_TELEMETRY_DISABLED=1 - -ARG BUILD_TYPE=full -ENV BUILD_TYPE=$BUILD_TYPE - -COPY aio/supervisord-${BUILD_TYPE}-base /app/supervisord.conf -COPY aio/supervisord-app /app/supervisord-app -RUN cat /app/supervisord-app >> /app/supervisord.conf && \ - rm /app/supervisord-app - -COPY ./aio/nginx.conf /etc/nginx/nginx.conf.template - -# if build type is full, run the below copy pg-setup.sh -COPY aio/postgresql.conf /etc/postgresql/postgresql.conf -COPY aio/pg-setup.sh /app/pg-setup.sh -RUN chmod +x /app/pg-setup.sh - -# ***************************************************************************** -# APPLICATION ENVIRONMENT SETTINGS -# ***************************************************************************** -ENV APP_DOMAIN=localhost -ENV WEB_URL=http://${APP_DOMAIN} -ENV DEBUG=0 -ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN} -# Secret Key -ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 -# Gunicorn Workers -ENV GUNICORN_WORKERS=1 - -ENV POSTGRES_USER="plane" -ENV POSTGRES_PASSWORD="plane" -ENV POSTGRES_DB="plane" -ENV POSTGRES_HOST="localhost" -ENV POSTGRES_PORT="5432" -ENV DATABASE_URL="postgresql://plane:plane@localhost:5432/plane" - -ENV REDIS_HOST="localhost" -ENV REDIS_PORT="6379" -ENV REDIS_URL="redis://localhost:6379" - -ENV USE_MINIO="1" -ENV AWS_REGION="" -ENV AWS_ACCESS_KEY_ID="access-key" -ENV AWS_SECRET_ACCESS_KEY="secret-key" -ENV AWS_S3_ENDPOINT_URL="http://localhost:9000" -ENV AWS_S3_BUCKET_NAME="uploads" -ENV MINIO_ROOT_USER="access-key" -ENV MINIO_ROOT_PASSWORD="secret-key" -ENV BUCKET_NAME="uploads" -ENV FILE_SIZE_LIMIT="5242880" - -# ***************************************************************************** - -RUN /app/pg-setup.sh - -CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/Dockerfile-base-full b/aio/Dockerfile-base-full deleted file mode 100644 index 218530948a2..00000000000 --- a/aio/Dockerfile-base-full +++ /dev/null @@ -1,73 +0,0 @@ -FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt - -FROM python:3.12-slim - -# Set environment variables to non-interactive for apt -ENV DEBIAN_FRONTEND=noninteractive -ENV BUILD_TYPE=full - -SHELL [ "/bin/bash", "-c" ] - -WORKDIR /app - -RUN mkdir -p /app/{data,logs} && \ - mkdir -p /app/data/{redis,pg,minio,nginx} && \ - mkdir -p /app/logs/{access,error} && \ - mkdir -p /etc/supervisor/conf.d - -# Update the package list and install prerequisites -RUN apt-get update && \ - apt-get install -y \ - gnupg2 curl ca-certificates lsb-release software-properties-common \ - build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ - libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \ - tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \ - sudo lsof net-tools libpq-dev procps gettext - -# Install Redis 7.2 -RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \ - curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" > /etc/apt/sources.list.d/redis.list && \ - apt-get update && \ - apt-get install -y redis-server - -# Install PostgreSQL 15 -ENV POSTGRES_VERSION=15 -RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \ - echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ - apt-get update && \ - apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \ - mkdir -p /var/lib/postgresql/data && \ - chown -R postgres:postgres /var/lib/postgresql -COPY postgresql.conf /etc/postgresql/postgresql.conf -RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data - -# Install MinIO -ARG TARGETARCH -RUN if [ "$TARGETARCH" = "amd64" ]; then \ - curl -fSl https://dl.min.io/server/minio/release/linux-amd64/minio -o /usr/local/bin/minio; \ - elif [ "$TARGETARCH" = "arm64" ]; then \ - curl -fSl https://dl.min.io/server/minio/release/linux-arm64/minio -o /usr/local/bin/minio; \ - else \ - echo "Unsupported architecture: $TARGETARCH"; exit 1; \ - fi && \ - chmod +x /usr/local/bin/minio - -# Install Node.js 18 -RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs && \ - python -m pip install --upgrade pip && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# Create Supervisor configuration file -COPY supervisord-full-base /app/supervisord.conf -COPY nginx.conf /etc/nginx/nginx.conf.template -COPY env.sh /app/nginx-start.sh -RUN chmod +x /app/nginx-start.sh - -# Expose ports for Redis, PostgreSQL, and MinIO -EXPOSE 6379 5432 9000 80 443 - -# Start Supervisor -CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/Dockerfile-base-slim b/aio/Dockerfile-base-slim deleted file mode 100644 index c6bc249de69..00000000000 --- a/aio/Dockerfile-base-slim +++ /dev/null @@ -1,45 +0,0 @@ -FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt - -FROM python:3.12-slim - -# Set environment variables to non-interactive for apt -ENV DEBIAN_FRONTEND=noninteractive -ENV BUILD_TYPE=slim - -SHELL [ "/bin/bash", "-c" ] - -WORKDIR /app - -RUN mkdir -p /app/{data,logs} && \ - mkdir -p /app/data/{nginx} && \ - mkdir -p /app/logs/{access,error} && \ - mkdir -p /etc/supervisor/conf.d - -# Update the package list and install prerequisites -RUN apt-get update && \ - apt-get install -y \ - gnupg2 curl ca-certificates lsb-release software-properties-common \ - build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ - libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \ - tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \ - sudo lsof net-tools libpq-dev procps gettext - -# Install Node.js 18 -RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs - -RUN python -m pip install --upgrade pip && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# Create Supervisor configuration file -COPY supervisord-slim-base /app/supervisord.conf -COPY nginx.conf /etc/nginx/nginx.conf.template -COPY env.sh /app/nginx-start.sh -RUN chmod +x /app/nginx-start.sh - -# Expose ports for Redis, PostgreSQL, and MinIO -EXPOSE 80 443 - -# Start Supervisor -CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/env.sh b/aio/env.sh deleted file mode 100644 index ff5f769fd87..00000000000 --- a/aio/env.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -export dollar="$" -export http_upgrade="http_upgrade" -export scheme="scheme" -envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf -exec nginx -g 'daemon off;' diff --git a/aio/nginx.conf b/aio/nginx.conf deleted file mode 100644 index 78ae00d28ce..00000000000 --- a/aio/nginx.conf +++ /dev/null @@ -1,72 +0,0 @@ -events { -} - -http { - sendfile on; - - server { - listen 80; - root /www/data/; - access_log /var/log/nginx/access.log; - - client_max_body_size ${FILE_SIZE_LIMIT}; - - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Permissions-Policy "interest-cohort=()" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Forwarded-Proto "${dollar}scheme"; - add_header X-Forwarded-Host "${dollar}host"; - add_header X-Forwarded-For "${dollar}proxy_add_x_forwarded_for"; - add_header X-Real-IP "${dollar}remote_addr"; - - location / { - proxy_http_version 1.1; - proxy_set_header Upgrade ${dollar}http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host ${dollar}http_host; - proxy_pass http://localhost:3001/; - } - - location /spaces/ { - rewrite ^/spaces/?$ /spaces/login break; - proxy_http_version 1.1; - proxy_set_header Upgrade ${dollar}http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host ${dollar}http_host; - proxy_pass http://localhost:3002/spaces/; - } - - location /god-mode/ { - proxy_http_version 1.1; - proxy_set_header Upgrade ${dollar}http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host ${dollar}http_host; - proxy_pass http://localhost:3003/god-mode/; - } - - location /api/ { - proxy_http_version 1.1; - proxy_set_header Upgrade ${dollar}http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host ${dollar}http_host; - proxy_pass http://localhost:8000/api/; - } - - location /auth/ { - proxy_http_version 1.1; - proxy_set_header Upgrade ${dollar}http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host ${dollar}http_host; - proxy_pass http://localhost:8000/auth/; - } - - location /${BUCKET_NAME}/ { - proxy_http_version 1.1; - proxy_set_header Upgrade ${dollar}http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host ${dollar}http_host; - proxy_pass http://localhost:9000/uploads/; - } - } -} diff --git a/aio/pg-setup.sh b/aio/pg-setup.sh deleted file mode 100644 index b830acc5ec6..00000000000 --- a/aio/pg-setup.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash - -if [ "$BUILD_TYPE" == "full" ]; then - - export PGHOST=localhost - - sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start - sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \ - sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \ - sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;" && \ - sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop - -fi - diff --git a/aio/postgresql.conf b/aio/postgresql.conf deleted file mode 100644 index 8f3c4e8a4c4..00000000000 --- a/aio/postgresql.conf +++ /dev/null @@ -1,815 +0,0 @@ -# ----------------------------- -# PostgreSQL configuration file -# ----------------------------- -# -# This file consists of lines of the form: -# -# name = value -# -# (The "=" is optional.) Whitespace may be used. Comments are introduced with -# "#" anywhere on a line. The complete list of parameter names and allowed -# values can be found in the PostgreSQL documentation. -# -# The commented-out settings shown in this file represent the default values. -# Re-commenting a setting is NOT sufficient to revert it to the default value; -# you need to reload the server. -# -# This file is read on server startup and when the server receives a SIGHUP -# signal. If you edit the file on a running system, you have to SIGHUP the -# server for the changes to take effect, run "pg_ctl reload", or execute -# "SELECT pg_reload_conf()". Some parameters, which are marked below, -# require a server shutdown and restart to take effect. -# -# Any parameter can also be given as a command-line option to the server, e.g., -# "postgres -c log_connections=on". Some parameters can be changed at run time -# with the "SET" SQL command. -# -# Memory units: B = bytes Time units: us = microseconds -# kB = kilobytes ms = milliseconds -# MB = megabytes s = seconds -# GB = gigabytes min = minutes -# TB = terabytes h = hours -# d = days - - -#------------------------------------------------------------------------------ -# FILE LOCATIONS -#------------------------------------------------------------------------------ - -# The default values of these variables are driven from the -D command-line -# option or PGDATA environment variable, represented here as ConfigDir. - -data_directory = '/var/lib/postgresql/data' # use data in another directory - # (change requires restart) -hba_file = '/etc/postgresql/15/main/pg_hba.conf' # host-based authentication file - # (change requires restart) -ident_file = '/etc/postgresql/15/main/pg_ident.conf' # ident configuration file - # (change requires restart) - -# If external_pid_file is not explicitly set, no extra PID file is written. -external_pid_file = '/var/run/postgresql/15-main.pid' # write an extra PID file - # (change requires restart) - - -#------------------------------------------------------------------------------ -# CONNECTIONS AND AUTHENTICATION -#------------------------------------------------------------------------------ - -# - Connection Settings - - -listen_addresses = 'localhost' # what IP address(es) to listen on; - # comma-separated list of addresses; - # defaults to 'localhost'; use '*' for all - # (change requires restart) -port = 5432 # (change requires restart) -max_connections = 200 # (change requires restart) -#superuser_reserved_connections = 3 # (change requires restart) -unix_socket_directories = '/var/run/postgresql' # comma-separated list of directories - # (change requires restart) -#unix_socket_group = '' # (change requires restart) -#unix_socket_permissions = 0777 # begin with 0 to use octal notation - # (change requires restart) -#bonjour = off # advertise server via Bonjour - # (change requires restart) -#bonjour_name = '' # defaults to the computer name - # (change requires restart) - -# - TCP settings - -# see "man tcp" for details - -#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; - # 0 selects the system default -#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; - # 0 selects the system default -#tcp_keepalives_count = 0 # TCP_KEEPCNT; - # 0 selects the system default -#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds; - # 0 selects the system default - -#client_connection_check_interval = 0 # time between checks for client - # disconnection while running queries; - # 0 for never - -# - Authentication - - -#authentication_timeout = 1min # 1s-600s -#password_encryption = scram-sha-256 # scram-sha-256 or md5 -#db_user_namespace = off - -# GSSAPI using Kerberos -#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab' -#krb_caseins_users = off - -# - SSL - - -ssl = on -#ssl_ca_file = '' -ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem' -#ssl_crl_file = '' -#ssl_crl_dir = '' -ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key' -#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers -#ssl_prefer_server_ciphers = on -#ssl_ecdh_curve = 'prime256v1' -#ssl_min_protocol_version = 'TLSv1.2' -#ssl_max_protocol_version = '' -#ssl_dh_params_file = '' -#ssl_passphrase_command = '' -#ssl_passphrase_command_supports_reload = off - - -#------------------------------------------------------------------------------ -# RESOURCE USAGE (except WAL) -#------------------------------------------------------------------------------ - -# - Memory - - -shared_buffers = 256MB # min 128kB - # (change requires restart) -#huge_pages = try # on, off, or try - # (change requires restart) -#huge_page_size = 0 # zero for system default - # (change requires restart) -#temp_buffers = 8MB # min 800kB -#max_prepared_transactions = 0 # zero disables the feature - # (change requires restart) -# Caution: it is not advisable to set max_prepared_transactions nonzero unless -# you actively intend to use prepared transactions. -#work_mem = 4MB # min 64kB -#hash_mem_multiplier = 2.0 # 1-1000.0 multiplier on hash table work_mem -#maintenance_work_mem = 64MB # min 1MB -#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem -#logical_decoding_work_mem = 64MB # min 64kB -#max_stack_depth = 2MB # min 100kB -#shared_memory_type = mmap # the default is the first option - # supported by the operating system: - # mmap - # sysv - # windows - # (change requires restart) -dynamic_shared_memory_type = posix # the default is usually the first option - # supported by the operating system: - # posix - # sysv - # windows - # mmap - # (change requires restart) -#min_dynamic_shared_memory = 0MB # (change requires restart) - -# - Disk - - -#temp_file_limit = -1 # limits per-process temp file space - # in kilobytes, or -1 for no limit - -# - Kernel Resources - - -#max_files_per_process = 1000 # min 64 - # (change requires restart) - -# - Cost-Based Vacuum Delay - - -#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables) -#vacuum_cost_page_hit = 1 # 0-10000 credits -#vacuum_cost_page_miss = 2 # 0-10000 credits -#vacuum_cost_page_dirty = 20 # 0-10000 credits -#vacuum_cost_limit = 200 # 1-10000 credits - -# - Background Writer - - -#bgwriter_delay = 200ms # 10-10000ms between rounds -#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables -#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round -#bgwriter_flush_after = 512kB # measured in pages, 0 disables - -# - Asynchronous Behavior - - -#backend_flush_after = 0 # measured in pages, 0 disables -#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching -#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching -#max_worker_processes = 8 # (change requires restart) -#max_parallel_workers_per_gather = 2 # limited by max_parallel_workers -#max_parallel_maintenance_workers = 2 # limited by max_parallel_workers -#max_parallel_workers = 8 # number of max_worker_processes that - # can be used in parallel operations -#parallel_leader_participation = on -#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate - # (change requires restart) - - -#------------------------------------------------------------------------------ -# WRITE-AHEAD LOG -#------------------------------------------------------------------------------ - -# - Settings - - -#wal_level = replica # minimal, replica, or logical - # (change requires restart) -#fsync = on # flush data to disk for crash safety - # (turning this off can cause - # unrecoverable data corruption) -#synchronous_commit = on # synchronization level; - # off, local, remote_write, remote_apply, or on -#wal_sync_method = fsync # the default is the first option - # supported by the operating system: - # open_datasync - # fdatasync (default on Linux and FreeBSD) - # fsync - # fsync_writethrough - # open_sync -#full_page_writes = on # recover from partial page writes -#wal_log_hints = off # also do full page writes of non-critical updates - # (change requires restart) -#wal_compression = off # enables compression of full-page writes; - # off, pglz, lz4, zstd, or on -#wal_init_zero = on # zero-fill new WAL files -#wal_recycle = on # recycle WAL files -#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers - # (change requires restart) -#wal_writer_delay = 200ms # 1-10000 milliseconds -#wal_writer_flush_after = 1MB # measured in pages, 0 disables -#wal_skip_threshold = 2MB - -#commit_delay = 0 # range 0-100000, in microseconds -#commit_siblings = 5 # range 1-1000 - -# - Checkpoints - - -#checkpoint_timeout = 5min # range 30s-1d -#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0 -#checkpoint_flush_after = 256kB # measured in pages, 0 disables -#checkpoint_warning = 30s # 0 disables -max_wal_size = 1GB -min_wal_size = 80MB - -# - Prefetching during recovery - - -#recovery_prefetch = try # prefetch pages referenced in the WAL? -#wal_decode_buffer_size = 512kB # lookahead window used for prefetching - # (change requires restart) - -# - Archiving - - -#archive_mode = off # enables archiving; off, on, or always - # (change requires restart) -#archive_library = '' # library to use to archive a logfile segment - # (empty string indicates archive_command should - # be used) -#archive_command = '' # command to use to archive a logfile segment - # placeholders: %p = path of file to archive - # %f = file name only - # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' -#archive_timeout = 0 # force a logfile segment switch after this - # number of seconds; 0 disables - -# - Archive Recovery - - -# These are only used in recovery mode. - -#restore_command = '' # command to use to restore an archived logfile segment - # placeholders: %p = path of file to restore - # %f = file name only - # e.g. 'cp /mnt/server/archivedir/%f %p' -#archive_cleanup_command = '' # command to execute at every restartpoint -#recovery_end_command = '' # command to execute at completion of recovery - -# - Recovery Target - - -# Set these only when performing a targeted recovery. - -#recovery_target = '' # 'immediate' to end recovery as soon as a - # consistent state is reached - # (change requires restart) -#recovery_target_name = '' # the named restore point to which recovery will proceed - # (change requires restart) -#recovery_target_time = '' # the time stamp up to which recovery will proceed - # (change requires restart) -#recovery_target_xid = '' # the transaction ID up to which recovery will proceed - # (change requires restart) -#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed - # (change requires restart) -#recovery_target_inclusive = on # Specifies whether to stop: - # just after the specified recovery target (on) - # just before the recovery target (off) - # (change requires restart) -#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID - # (change requires restart) -#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown' - # (change requires restart) - - -#------------------------------------------------------------------------------ -# REPLICATION -#------------------------------------------------------------------------------ - -# - Sending Servers - - -# Set these on the primary and on any standby that will send replication data. - -#max_wal_senders = 10 # max number of walsender processes - # (change requires restart) -#max_replication_slots = 10 # max number of replication slots - # (change requires restart) -#wal_keep_size = 0 # in megabytes; 0 disables -#max_slot_wal_keep_size = -1 # in megabytes; -1 disables -#wal_sender_timeout = 60s # in milliseconds; 0 disables -#track_commit_timestamp = off # collect timestamp of transaction commit - # (change requires restart) - -# - Primary Server - - -# These settings are ignored on a standby server. - -#synchronous_standby_names = '' # standby servers that provide sync rep - # method to choose sync standbys, number of sync standbys, - # and comma-separated list of application_name - # from standby(s); '*' = all -#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed - -# - Standby Servers - - -# These settings are ignored on a primary server. - -#primary_conninfo = '' # connection string to sending server -#primary_slot_name = '' # replication slot on sending server -#promote_trigger_file = '' # file name whose presence ends recovery -#hot_standby = on # "off" disallows queries during recovery - # (change requires restart) -#max_standby_archive_delay = 30s # max delay before canceling queries - # when reading WAL from archive; - # -1 allows indefinite delay -#max_standby_streaming_delay = 30s # max delay before canceling queries - # when reading streaming WAL; - # -1 allows indefinite delay -#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name - # is not set -#wal_receiver_status_interval = 10s # send replies at least this often - # 0 disables -#hot_standby_feedback = off # send info from standby to prevent - # query conflicts -#wal_receiver_timeout = 60s # time that receiver waits for - # communication from primary - # in milliseconds; 0 disables -#wal_retrieve_retry_interval = 5s # time to wait before retrying to - # retrieve WAL after a failed attempt -#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery - -# - Subscribers - - -# These settings are ignored on a publisher. - -#max_logical_replication_workers = 4 # taken from max_worker_processes - # (change requires restart) -#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers - - -#------------------------------------------------------------------------------ -# QUERY TUNING -#------------------------------------------------------------------------------ - -# - Planner Method Configuration - - -#enable_async_append = on -#enable_bitmapscan = on -#enable_gathermerge = on -#enable_hashagg = on -#enable_hashjoin = on -#enable_incremental_sort = on -#enable_indexscan = on -#enable_indexonlyscan = on -#enable_material = on -#enable_memoize = on -#enable_mergejoin = on -#enable_nestloop = on -#enable_parallel_append = on -#enable_parallel_hash = on -#enable_partition_pruning = on -#enable_partitionwise_join = off -#enable_partitionwise_aggregate = off -#enable_seqscan = on -#enable_sort = on -#enable_tidscan = on - -# - Planner Cost Constants - - -#seq_page_cost = 1.0 # measured on an arbitrary scale -#random_page_cost = 4.0 # same scale as above -#cpu_tuple_cost = 0.01 # same scale as above -#cpu_index_tuple_cost = 0.005 # same scale as above -#cpu_operator_cost = 0.0025 # same scale as above -#parallel_setup_cost = 1000.0 # same scale as above -#parallel_tuple_cost = 0.1 # same scale as above -#min_parallel_table_scan_size = 8MB -#min_parallel_index_scan_size = 512kB -#effective_cache_size = 4GB - -#jit_above_cost = 100000 # perform JIT compilation if available - # and query more expensive than this; - # -1 disables -#jit_inline_above_cost = 500000 # inline small functions if query is - # more expensive than this; -1 disables -#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if - # query is more expensive than this; - # -1 disables - -# - Genetic Query Optimizer - - -#geqo = on -#geqo_threshold = 12 -#geqo_effort = 5 # range 1-10 -#geqo_pool_size = 0 # selects default based on effort -#geqo_generations = 0 # selects default based on effort -#geqo_selection_bias = 2.0 # range 1.5-2.0 -#geqo_seed = 0.0 # range 0.0-1.0 - -# - Other Planner Options - - -#default_statistics_target = 100 # range 1-10000 -#constraint_exclusion = partition # on, off, or partition -#cursor_tuple_fraction = 0.1 # range 0.0-1.0 -#from_collapse_limit = 8 -#jit = on # allow JIT compilation -#join_collapse_limit = 8 # 1 disables collapsing of explicit - # JOIN clauses -#plan_cache_mode = auto # auto, force_generic_plan or - # force_custom_plan -#recursive_worktable_factor = 10.0 # range 0.001-1000000 - - -#------------------------------------------------------------------------------ -# REPORTING AND LOGGING -#------------------------------------------------------------------------------ - -# - Where to Log - - -#log_destination = 'stderr' # Valid values are combinations of - # stderr, csvlog, jsonlog, syslog, and - # eventlog, depending on platform. - # csvlog and jsonlog require - # logging_collector to be on. - -# This is used when logging to stderr: -#logging_collector = off # Enable capturing of stderr, jsonlog, - # and csvlog into log files. Required - # to be on for csvlogs and jsonlogs. - # (change requires restart) - -# These are only used if logging_collector is on: -#log_directory = 'log' # directory where log files are written, - # can be absolute or relative to PGDATA -#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, - # can include strftime() escapes -#log_file_mode = 0600 # creation mode for log files, - # begin with 0 to use octal notation -#log_rotation_age = 1d # Automatic rotation of logfiles will - # happen after that time. 0 disables. -#log_rotation_size = 10MB # Automatic rotation of logfiles will - # happen after that much log output. - # 0 disables. -#log_truncate_on_rotation = off # If on, an existing log file with the - # same name as the new log file will be - # truncated rather than appended to. - # But such truncation only occurs on - # time-driven rotation, not on restarts - # or size-driven rotation. Default is - # off, meaning append to existing files - # in all cases. - -# These are relevant when logging to syslog: -#syslog_facility = 'LOCAL0' -#syslog_ident = 'postgres' -#syslog_sequence_numbers = on -#syslog_split_messages = on - -# This is only relevant when logging to eventlog (Windows): -# (change requires restart) -#event_source = 'PostgreSQL' - -# - When to Log - - -#log_min_messages = warning # values in order of decreasing detail: - # debug5 - # debug4 - # debug3 - # debug2 - # debug1 - # info - # notice - # warning - # error - # log - # fatal - # panic - -#log_min_error_statement = error # values in order of decreasing detail: - # debug5 - # debug4 - # debug3 - # debug2 - # debug1 - # info - # notice - # warning - # error - # log - # fatal - # panic (effectively off) - -#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements - # and their durations, > 0 logs only - # statements running at least this number - # of milliseconds - -#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements - # and their durations, > 0 logs only a sample of - # statements running at least this number - # of milliseconds; - # sample fraction is determined by log_statement_sample_rate - -#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding - # log_min_duration_sample to be logged; - # 1.0 logs all such statements, 0.0 never logs - - -#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements - # are logged regardless of their duration; 1.0 logs all - # statements from all transactions, 0.0 never logs - -#log_startup_progress_interval = 10s # Time between progress updates for - # long-running startup operations. - # 0 disables the feature, > 0 indicates - # the interval in milliseconds. - -# - What to Log - - -#debug_print_parse = off -#debug_print_rewritten = off -#debug_print_plan = off -#debug_pretty_print = on -#log_autovacuum_min_duration = 10min # log autovacuum activity; - # -1 disables, 0 logs all actions and - # their durations, > 0 logs only - # actions running at least this number - # of milliseconds. -#log_checkpoints = on -#log_connections = off -#log_disconnections = off -#log_duration = off -#log_error_verbosity = default # terse, default, or verbose messages -#log_hostname = off -log_line_prefix = '%m [%p] %q%u@%d ' # special values: - # %a = application name - # %u = user name - # %d = database name - # %r = remote host and port - # %h = remote host - # %b = backend type - # %p = process ID - # %P = process ID of parallel group leader - # %t = timestamp without milliseconds - # %m = timestamp with milliseconds - # %n = timestamp with milliseconds (as a Unix epoch) - # %Q = query ID (0 if none or not computed) - # %i = command tag - # %e = SQL state - # %c = session ID - # %l = session line number - # %s = session start timestamp - # %v = virtual transaction ID - # %x = transaction ID (0 if none) - # %q = stop here in non-session - # processes - # %% = '%' - # e.g. '<%u%%%d> ' -#log_lock_waits = off # log lock waits >= deadlock_timeout -#log_recovery_conflict_waits = off # log standby recovery conflict waits - # >= deadlock_timeout -#log_parameter_max_length = -1 # when logging statements, limit logged - # bind-parameter values to N bytes; - # -1 means print in full, 0 disables -#log_parameter_max_length_on_error = 0 # when logging an error, limit logged - # bind-parameter values to N bytes; - # -1 means print in full, 0 disables -#log_statement = 'none' # none, ddl, mod, all -#log_replication_commands = off -#log_temp_files = -1 # log temporary files equal or larger - # than the specified size in kilobytes; - # -1 disables, 0 logs all temp files -log_timezone = 'Etc/UTC' - - -#------------------------------------------------------------------------------ -# PROCESS TITLE -#------------------------------------------------------------------------------ - -cluster_name = '15/main' # added to process titles if nonempty - # (change requires restart) -#update_process_title = on - - -#------------------------------------------------------------------------------ -# STATISTICS -#------------------------------------------------------------------------------ - -# - Cumulative Query and Index Statistics - - -#track_activities = on -#track_activity_query_size = 1024 # (change requires restart) -#track_counts = on -#track_io_timing = off -#track_wal_io_timing = off -#track_functions = none # none, pl, all -#stats_fetch_consistency = cache - - -# - Monitoring - - -#compute_query_id = auto -#log_statement_stats = off -#log_parser_stats = off -#log_planner_stats = off -#log_executor_stats = off - - -#------------------------------------------------------------------------------ -# AUTOVACUUM -#------------------------------------------------------------------------------ - -#autovacuum = on # Enable autovacuum subprocess? 'on' - # requires track_counts to also be on. -#autovacuum_max_workers = 3 # max number of autovacuum subprocesses - # (change requires restart) -#autovacuum_naptime = 1min # time between autovacuum runs -#autovacuum_vacuum_threshold = 50 # min number of row updates before - # vacuum -#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts - # before vacuum; -1 disables insert - # vacuums -#autovacuum_analyze_threshold = 50 # min number of row updates before - # analyze -#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum -#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table - # size before insert vacuum -#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze -#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum - # (change requires restart) -#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age - # before forced vacuum - # (change requires restart) -#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for - # autovacuum, in milliseconds; - # -1 means use vacuum_cost_delay -#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for - # autovacuum, -1 means use - # vacuum_cost_limit - - -#------------------------------------------------------------------------------ -# CLIENT CONNECTION DEFAULTS -#------------------------------------------------------------------------------ - -# - Statement Behavior - - -#client_min_messages = notice # values in order of decreasing detail: - # debug5 - # debug4 - # debug3 - # debug2 - # debug1 - # log - # notice - # warning - # error -#search_path = '"$user", public' # schema names -#row_security = on -#default_table_access_method = 'heap' -#default_tablespace = '' # a tablespace name, '' uses the default -#default_toast_compression = 'pglz' # 'pglz' or 'lz4' -#temp_tablespaces = '' # a list of tablespace names, '' uses - # only default tablespace -#check_function_bodies = on -#default_transaction_isolation = 'read committed' -#default_transaction_read_only = off -#default_transaction_deferrable = off -#session_replication_role = 'origin' -#statement_timeout = 0 # in milliseconds, 0 is disabled -#lock_timeout = 0 # in milliseconds, 0 is disabled -#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled -#idle_session_timeout = 0 # in milliseconds, 0 is disabled -#vacuum_freeze_table_age = 150000000 -#vacuum_freeze_min_age = 50000000 -#vacuum_failsafe_age = 1600000000 -#vacuum_multixact_freeze_table_age = 150000000 -#vacuum_multixact_freeze_min_age = 5000000 -#vacuum_multixact_failsafe_age = 1600000000 -#bytea_output = 'hex' # hex, escape -#xmlbinary = 'base64' -#xmloption = 'content' -#gin_pending_list_limit = 4MB - -# - Locale and Formatting - - -datestyle = 'iso, mdy' -#intervalstyle = 'postgres' -timezone = 'Etc/UTC' -#timezone_abbreviations = 'Default' # Select the set of available time zone - # abbreviations. Currently, there are - # Default - # Australia (historical usage) - # India - # You can create your own file in - # share/timezonesets/. -#extra_float_digits = 1 # min -15, max 3; any value >0 actually - # selects precise output mode -#client_encoding = sql_ascii # actually, defaults to database - # encoding - -# These settings are initialized by initdb, but they can be changed. -lc_messages = 'C.UTF-8' # locale for system error message - # strings -lc_monetary = 'C.UTF-8' # locale for monetary formatting -lc_numeric = 'C.UTF-8' # locale for number formatting -lc_time = 'C.UTF-8' # locale for time formatting - -# default configuration for text search -default_text_search_config = 'pg_catalog.english' - -# - Shared Library Preloading - - -#local_preload_libraries = '' -#session_preload_libraries = '' -#shared_preload_libraries = '' # (change requires restart) -#jit_provider = 'llvmjit' # JIT library to use - -# - Other Defaults - - -#dynamic_library_path = '$libdir' -#extension_destdir = '' # prepend path when loading extensions - # and shared objects (added by Debian) -#gin_fuzzy_search_limit = 0 - - -#------------------------------------------------------------------------------ -# LOCK MANAGEMENT -#------------------------------------------------------------------------------ - -#deadlock_timeout = 1s -#max_locks_per_transaction = 64 # min 10 - # (change requires restart) -#max_pred_locks_per_transaction = 64 # min 10 - # (change requires restart) -#max_pred_locks_per_relation = -2 # negative values mean - # (max_pred_locks_per_transaction - # / -max_pred_locks_per_relation) - 1 -#max_pred_locks_per_page = 2 # min 0 - - -#------------------------------------------------------------------------------ -# VERSION AND PLATFORM COMPATIBILITY -#------------------------------------------------------------------------------ - -# - Previous PostgreSQL Versions - - -#array_nulls = on -#backslash_quote = safe_encoding # on, off, or safe_encoding -#escape_string_warning = on -#lo_compat_privileges = off -#quote_all_identifiers = off -#standard_conforming_strings = on -#synchronize_seqscans = on - -# - Other Platforms and Clients - - -#transform_null_equals = off - - -#------------------------------------------------------------------------------ -# ERROR HANDLING -#------------------------------------------------------------------------------ - -#exit_on_error = off # terminate session on any error? -#restart_after_crash = on # reinitialize after backend crash? -#data_sync_retry = off # retry or panic on failure to fsync - # data? - # (change requires restart) -#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+) - - -#------------------------------------------------------------------------------ -# CONFIG FILE INCLUDES -#------------------------------------------------------------------------------ - -# These options allow settings to be loaded from files other than the -# default postgresql.conf. Note that these are directives, not variable -# assignments, so they can usefully be given more than once. - -# include_dir = 'conf.d' # include files ending in '.conf' from - # a directory, e.g., 'conf.d' -#include_if_exists = '...' # include file only if it exists -#include = '...' # include file - - -#------------------------------------------------------------------------------ -# CUSTOMIZED OPTIONS -#------------------------------------------------------------------------------ - -# Add settings for extensions here diff --git a/aio/supervisord-app b/aio/supervisord-app deleted file mode 100644 index e2cf1f04754..00000000000 --- a/aio/supervisord-app +++ /dev/null @@ -1,71 +0,0 @@ - -[program:web] -command=node /app/web/web/server.js -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 -environment=PORT=3001,HOSTNAME=0.0.0.0 - -[program:space] -command=node /app/space/space/server.js -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 -environment=PORT=3002,HOSTNAME=0.0.0.0 - -[program:admin] -command=node /app/admin/admin/server.js -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 -environment=PORT=3003,HOSTNAME=0.0.0.0 - -[program:migrator] -directory=/app/api -command=sh -c "./bin/docker-entrypoint-migrator.sh" -autostart=true -autorestart=false -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:api] -directory=/app/api -command=sh -c "./bin/docker-entrypoint-api.sh" -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:worker] -directory=/app/api -command=sh -c "./bin/docker-entrypoint-worker.sh" -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:beat] -directory=/app/api -command=sh -c "./bin/docker-entrypoint-beat.sh" -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - diff --git a/aio/supervisord-full-base b/aio/supervisord-full-base deleted file mode 100644 index 0a6c27e13a1..00000000000 --- a/aio/supervisord-full-base +++ /dev/null @@ -1,38 +0,0 @@ -[supervisord] -user=root -nodaemon=true -stderr_logfile=/app/logs/error/supervisor.err.log -stdout_logfile=/app/logs/access/supervisor.log - -[program:redis] -directory=/app/data/redis -command=redis-server -autostart=true -autorestart=true -stderr_logfile=/app/logs/error/redis.err.log -stdout_logfile=/app/logs/access/redis.log - -[program:postgresql] -user=postgres -command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/postgresql.conf -autostart=true -autorestart=true -stderr_logfile=/app/logs/error/postgresql.err.log -stdout_logfile=/app/logs/access/postgresql.log - -[program:minio] -directory=/app/data/minio -command=minio server /app/data/minio -autostart=true -autorestart=true -stderr_logfile=/app/logs/error/minio.err.log -stdout_logfile=/app/logs/access/minio.log - -[program:nginx] -directory=/app/data/nginx -command=/app/nginx-start.sh -autostart=true -autorestart=true -stderr_logfile=/app/logs/error/nginx.err.log -stdout_logfile=/app/logs/access/nginx.log - diff --git a/aio/supervisord-slim-base b/aio/supervisord-slim-base deleted file mode 100644 index 24509216e09..00000000000 --- a/aio/supervisord-slim-base +++ /dev/null @@ -1,14 +0,0 @@ -[supervisord] -user=root -nodaemon=true -stderr_logfile=/app/logs/error/supervisor.err.log -stdout_logfile=/app/logs/access/supervisor.log - -[program:nginx] -directory=/app/data/nginx -command=/app/nginx-start.sh -autostart=true -autorestart=true -stderr_logfile=/app/logs/error/nginx.err.log -stdout_logfile=/app/logs/access/nginx.log - diff --git a/apiserver/.env.example b/apiserver/.env.example deleted file mode 100644 index 7fdffd17935..00000000000 --- a/apiserver/.env.example +++ /dev/null @@ -1,73 +0,0 @@ -# Backend -# Debug value for api server use it as 0 for production use -DEBUG=0 -CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100" - -# Database Settings -POSTGRES_USER="plane" -POSTGRES_PASSWORD="plane" -POSTGRES_HOST="plane-db" -POSTGRES_DB="plane" -POSTGRES_PORT=5432 -DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} - -# Redis Settings -REDIS_HOST="plane-redis" -REDIS_PORT="6379" -REDIS_URL="redis://${REDIS_HOST}:6379/" - -# RabbitMQ Settings -RABBITMQ_HOST="plane-mq" -RABBITMQ_PORT="5672" -RABBITMQ_USER="plane" -RABBITMQ_PASSWORD="plane" -RABBITMQ_VHOST="plane" - -# AWS Settings -AWS_REGION="" -AWS_ACCESS_KEY_ID="access-key" -AWS_SECRET_ACCESS_KEY="secret-key" -AWS_S3_ENDPOINT_URL="http://localhost:9000" -# Changing this requires change in the nginx.conf for uploads if using minio setup -AWS_S3_BUCKET_NAME="uploads" -# Maximum file upload limit -FILE_SIZE_LIMIT=5242880 - -# Settings related to Docker -DOCKERIZED=1 # deprecated - -# set to 1 If using the pre-configured minio setup -USE_MINIO=0 - -# Nginx Configuration -NGINX_PORT=80 - -# Email redirections and minio domain settings -WEB_URL="http://localhost:8000" - -# Gunicorn Workers -GUNICORN_WORKERS=2 - -# Base URLs -ADMIN_BASE_URL="http://localhost:3001" -ADMIN_BASE_PATH="/god-mode" - -SPACE_BASE_URL="http://localhost:3002" -SPACE_BASE_PATH="/spaces" - -APP_BASE_URL="http://localhost:3000" -APP_BASE_PATH="" - -LIVE_BASE_URL="http://localhost:3100" -LIVE_BASE_PATH="/live" - -LIVE_SERVER_SECRET_KEY="secret-key" - -# Hard delete files after days -HARD_DELETE_AFTER_DAYS=60 - -# Force HTTPS for handling SSL Termination -MINIO_ENDPOINT_SSL=0 - -# API key rate limit -API_KEY_RATE_LIMIT="60/minute" diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api deleted file mode 100644 index b0fa447885a..00000000000 --- a/apiserver/Dockerfile.api +++ /dev/null @@ -1,52 +0,0 @@ -FROM python:3.12.5-alpine AS backend - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ - -WORKDIR /code - -RUN apk add --no-cache \ - "libpq" \ - "libxslt" \ - "nodejs-current" \ - "xmlsec" - -COPY requirements.txt ./ -COPY requirements ./requirements -RUN apk add --no-cache libffi-dev -RUN apk add --no-cache --virtual .build-deps \ - "bash~=5.2" \ - "g++" \ - "gcc" \ - "cargo" \ - "git" \ - "make" \ - "postgresql-dev" \ - "libc-dev" \ - "linux-headers" \ - && \ - pip install -r requirements.txt --compile --no-cache-dir \ - && \ - apk del .build-deps - - -# Add in Django deps and generate Django's static files -COPY manage.py manage.py -COPY plane plane/ -COPY templates templates/ -COPY package.json package.json - -RUN apk --no-cache add "bash~=5.2" -COPY ./bin ./bin/ - -RUN mkdir -p /code/plane/logs -RUN chmod +x ./bin/* -RUN chmod -R 777 /code - -# Expose container port and run entry point script -EXPOSE 8000 - - diff --git a/apiserver/Procfile b/apiserver/Procfile deleted file mode 100644 index 63736e8e8a8..00000000000 --- a/apiserver/Procfile +++ /dev/null @@ -1,3 +0,0 @@ -web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --max-requests 10000 --max-requests-jitter 1000 --access-logfile - -worker: celery -A plane worker -l info -beat: celery -A plane beat -l INFO \ No newline at end of file diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py deleted file mode 100644 index 69eb2f1df6a..00000000000 --- a/apiserver/back_migration.py +++ /dev/null @@ -1,224 +0,0 @@ -# All the python scripts that are used for back migrations -import uuid -import random -from django.contrib.auth.hashers import make_password -from plane.db.models import ProjectIdentifier -from plane.db.models import ( - Issue, - IssueComment, - User, - Project, - ProjectMember, - Label, - Integration, -) - - -# Update description and description html values for old descriptions -def update_description(): - try: - issues = Issue.objects.all() - updated_issues = [] - - for issue in issues: - issue.description_html = f"

{issue.description}

" - issue.description_stripped = issue.description - updated_issues.append(issue) - - Issue.objects.bulk_update( - updated_issues, ["description_html", "description_stripped"], batch_size=100 - ) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_comments(): - try: - issue_comments = IssueComment.objects.all() - updated_issue_comments = [] - - for issue_comment in issue_comments: - issue_comment.comment_html = f"

{issue_comment.comment_stripped}

" - updated_issue_comments.append(issue_comment) - - IssueComment.objects.bulk_update( - updated_issue_comments, ["comment_html"], batch_size=100 - ) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_project_identifiers(): - try: - project_identifiers = ProjectIdentifier.objects.filter( - workspace_id=None - ).select_related("project", "project__workspace") - updated_identifiers = [] - - for identifier in project_identifiers: - identifier.workspace_id = identifier.project.workspace_id - updated_identifiers.append(identifier) - - ProjectIdentifier.objects.bulk_update( - updated_identifiers, ["workspace_id"], batch_size=50 - ) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_user_empty_password(): - try: - users = User.objects.filter(password="") - updated_users = [] - - for user in users: - user.password = make_password(uuid.uuid4().hex) - user.is_password_autoset = True - updated_users.append(user) - - User.objects.bulk_update(updated_users, ["password"], batch_size=50) - print("Success") - - except Exception as e: - print(e) - print("Failed") - - -def updated_issue_sort_order(): - try: - issues = Issue.objects.all() - updated_issues = [] - - for issue in issues: - issue.sort_order = issue.sequence_id * random.randint(100, 500) - updated_issues.append(issue) - - Issue.objects.bulk_update(updated_issues, ["sort_order"], batch_size=100) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_project_cover_images(): - try: - project_cover_images = [ - "https://images.unsplash.com/photo-1677432658720-3d84f9d657b4?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1661107564401-57497d8fe86f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", - "https://images.unsplash.com/photo-1677352241429-dc90cfc7a623?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", - "https://images.unsplash.com/photo-1677196728306-eeafea692454?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1331&q=80", - "https://images.unsplash.com/photo-1660902179734-c94c944f7830?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1255&q=80", - "https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1677040628614-53936ff66632?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1676920410907-8d5f8dd4b5ba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", - "https://images.unsplash.com/photo-1676846328604-ce831c481346?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1155&q=80", - "https://images.unsplash.com/photo-1676744843212-09b7e64c3a05?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1676798531090-1608bedeac7b?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1597088758740-56fd7ec8a3f0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1169&q=80", - "https://images.unsplash.com/photo-1676638392418-80aad7c87b96?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80", - "https://images.unsplash.com/photo-1649639194967-2fec0b4ea7bc?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1675883086902-b453b3f8146e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80", - "https://images.unsplash.com/photo-1675887057159-40fca28fdc5d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1173&q=80", - "https://images.unsplash.com/photo-1675373980203-f84c5a672aa5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1675191475318-d2bf6bad1200?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", - "https://images.unsplash.com/photo-1675456230532-2194d0c4bcc0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", - "https://images.unsplash.com/photo-1675371788315-60fa0ef48267?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", - ] - - projects = Project.objects.all() - updated_projects = [] - for project in projects: - project.cover_image = project_cover_images[random.randint(0, 19)] - updated_projects.append(project) - - Project.objects.bulk_update(updated_projects, ["cover_image"], batch_size=100) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_user_view_property(): - try: - project_members = ProjectMember.objects.all() - updated_project_members = [] - for project_member in project_members: - project_member.default_props = { - "filters": {"type": None}, - "orderBy": "-created_at", - "collapsed": True, - "issueView": "list", - "filterIssue": None, - "groupByProperty": None, - "showEmptyGroups": True, - } - updated_project_members.append(project_member) - - ProjectMember.objects.bulk_update( - updated_project_members, ["default_props"], batch_size=100 - ) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_label_color(): - try: - labels = Label.objects.filter(color="") - updated_labels = [] - for label in labels: - label.color = f"#{random.randint(0, 0xFFFFFF+1):06X}" - updated_labels.append(label) - - Label.objects.bulk_update(updated_labels, ["color"], batch_size=100) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def create_slack_integration(): - try: - _ = Integration.objects.create(provider="slack", network=2, title="Slack") - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_integration_verified(): - try: - integrations = Integration.objects.all() - updated_integrations = [] - for integration in integrations: - integration.verified = True - updated_integrations.append(integration) - - Integration.objects.bulk_update( - updated_integrations, ["verified"], batch_size=10 - ) - print("Success") - except Exception as e: - print(e) - print("Failed") - - -def update_start_date(): - try: - issues = Issue.objects.filter(state__group__in=["started", "completed"]) - updated_issues = [] - for issue in issues: - issue.start_date = issue.created_at.date() - updated_issues.append(issue) - Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500) - print("Success") - except Exception as e: - print(e) - print("Failed") diff --git a/apiserver/package.json b/apiserver/package.json deleted file mode 100644 index 46d5fc5e614..00000000000 --- a/apiserver/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "plane-api", - "version": "0.27.1", - "license": "AGPL-3.0", - "private": true, - "description": "API server powering Plane's backend" -} diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py deleted file mode 100644 index 10738b97fa9..00000000000 --- a/apiserver/plane/api/serializers/issue.py +++ /dev/null @@ -1,466 +0,0 @@ -# Django imports -from django.utils import timezone -from lxml import html -from django.db import IntegrityError - -# Third party imports -from rest_framework import serializers - -# Module imports -from plane.db.models import ( - Issue, - IssueType, - IssueActivity, - IssueAssignee, - FileAsset, - IssueComment, - IssueLabel, - IssueLink, - Label, - ProjectMember, - State, - User, -) - -from .base import BaseSerializer -from .cycle import CycleLiteSerializer, CycleSerializer -from .module import ModuleLiteSerializer, ModuleSerializer -from .state import StateLiteSerializer -from .user import UserLiteSerializer - -# Django imports -from django.core.exceptions import ValidationError -from django.core.validators import URLValidator - - -class IssueSerializer(BaseSerializer): - assignees = serializers.ListField( - child=serializers.PrimaryKeyRelatedField( - queryset=User.objects.values_list("id", flat=True) - ), - write_only=True, - required=False, - ) - - labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField( - queryset=Label.objects.values_list("id", flat=True) - ), - write_only=True, - required=False, - ) - type_id = serializers.PrimaryKeyRelatedField( - source="type", queryset=IssueType.objects.all(), required=False, allow_null=True - ) - - class Meta: - model = Issue - read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"] - exclude = ["description", "description_stripped"] - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("target_date", None) is not None - and data.get("start_date", None) > data.get("target_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed target date") - - try: - if data.get("description_html", None) is not None: - parsed = html.fromstring(data["description_html"]) - parsed_str = html.tostring(parsed, encoding="unicode") - data["description_html"] = parsed_str - - except Exception: - raise serializers.ValidationError("Invalid HTML passed") - - # Validate assignees are from project - if data.get("assignees", []): - data["assignees"] = ProjectMember.objects.filter( - project_id=self.context.get("project_id"), - is_active=True, - role__gte=15, - member_id__in=data["assignees"], - ).values_list("member_id", flat=True) - - # Validate labels are from project - if data.get("labels", []): - data["labels"] = Label.objects.filter( - project_id=self.context.get("project_id"), id__in=data["labels"] - ).values_list("id", flat=True) - - # Check state is from the project only else raise validation error - if ( - data.get("state") - and not State.objects.filter( - project_id=self.context.get("project_id"), pk=data.get("state").id - ).exists() - ): - raise serializers.ValidationError( - "State is not valid please pass a valid state_id" - ) - - # Check parent issue is from workspace as it can be cross workspace - if ( - data.get("parent") - and not Issue.objects.filter( - workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id - ).exists() - ): - raise serializers.ValidationError( - "Parent is not valid issue_id please pass a valid issue_id" - ) - - return data - - def create(self, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) - - project_id = self.context["project_id"] - workspace_id = self.context["workspace_id"] - default_assignee_id = self.context["default_assignee_id"] - - issue_type = validated_data.pop("type", None) - - if not issue_type: - # Get default issue type - issue_type = IssueType.objects.filter( - project_issue_types__project_id=project_id, is_default=True - ).first() - issue_type = issue_type - - issue = Issue.objects.create( - **validated_data, project_id=project_id, type=issue_type - ) - - # Issue Audit Users - created_by_id = issue.created_by_id - updated_by_id = issue.updated_by_id - - if assignees is not None and len(assignees): - try: - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee_id=assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for assignee_id in assignees - ], - batch_size=10, - ) - except IntegrityError: - pass - else: - try: - # Then assign it to default assignee, if it is a valid assignee - if ( - default_assignee_id is not None - and ProjectMember.objects.filter( - member_id=default_assignee_id, - project_id=project_id, - role__gte=15, - is_active=True, - ).exists() - ): - IssueAssignee.objects.create( - assignee_id=default_assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - except IntegrityError: - pass - - if labels is not None and len(labels): - try: - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label_id=label_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label_id in labels - ], - batch_size=10, - ) - except IntegrityError: - pass - - return issue - - def update(self, instance, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) - - # Related models - project_id = instance.project_id - workspace_id = instance.workspace_id - created_by_id = instance.created_by_id - updated_by_id = instance.updated_by_id - - if assignees is not None: - IssueAssignee.objects.filter(issue=instance).delete() - try: - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee_id=assignee_id, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for assignee_id in assignees - ], - batch_size=10, - ignore_conflicts=True, - ) - except IntegrityError: - pass - - if labels is not None: - IssueLabel.objects.filter(issue=instance).delete() - try: - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label_id=label_id, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label_id in labels - ], - batch_size=10, - ignore_conflicts=True, - ) - except IntegrityError: - pass - - # Time updation occues even when other related models are updated - instance.updated_at = timezone.now() - return super().update(instance, validated_data) - - def to_representation(self, instance): - data = super().to_representation(instance) - if "assignees" in self.fields: - if "assignees" in self.expand: - from .user import UserLiteSerializer - - data["assignees"] = UserLiteSerializer( - User.objects.filter( - pk__in=IssueAssignee.objects.filter(issue=instance).values_list( - "assignee_id", flat=True - ) - ), - many=True, - ).data - else: - data["assignees"] = [ - str(assignee) - for assignee in IssueAssignee.objects.filter( - issue=instance - ).values_list("assignee_id", flat=True) - ] - if "labels" in self.fields: - if "labels" in self.expand: - data["labels"] = LabelSerializer( - Label.objects.filter( - pk__in=IssueLabel.objects.filter(issue=instance).values_list( - "label_id", flat=True - ) - ), - many=True, - ).data - else: - data["labels"] = [ - str(label) - for label in IssueLabel.objects.filter(issue=instance).values_list( - "label_id", flat=True - ) - ] - - return data - - -class IssueLiteSerializer(BaseSerializer): - class Meta: - model = Issue - fields = ["id", "sequence_id", "project_id"] - read_only_fields = fields - - -class LabelSerializer(BaseSerializer): - class Meta: - model = Label - fields = "__all__" - read_only_fields = [ - "id", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - "deleted_at", - ] - - -class IssueLinkSerializer(BaseSerializer): - class Meta: - model = IssueLink - fields = "__all__" - read_only_fields = [ - "id", - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - def validate_url(self, value): - # Check URL format - validate_url = URLValidator() - try: - validate_url(value) - except ValidationError: - raise serializers.ValidationError("Invalid URL format.") - - # Check URL scheme - if not value.startswith(("http://", "https://")): - raise serializers.ValidationError("Invalid URL scheme.") - - return value - - # Validation if url already exists - def create(self, validated_data): - if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) - return IssueLink.objects.create(**validated_data) - - def update(self, instance, validated_data): - if ( - IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=instance.issue_id - ) - .exclude(pk=instance.id) - .exists() - ): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) - - return super().update(instance, validated_data) - - -class IssueAttachmentSerializer(BaseSerializer): - class Meta: - model = FileAsset - fields = "__all__" - read_only_fields = [ - "id", - "workspace", - "project", - "issue", - "updated_by", - "updated_at", - ] - - -class IssueCommentSerializer(BaseSerializer): - is_member = serializers.BooleanField(read_only=True) - - class Meta: - model = IssueComment - read_only_fields = [ - "id", - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - exclude = ["comment_stripped", "comment_json"] - - def validate(self, data): - try: - if data.get("comment_html", None) is not None: - parsed = html.fromstring(data["comment_html"]) - parsed_str = html.tostring(parsed, encoding="unicode") - data["comment_html"] = parsed_str - - except Exception: - raise serializers.ValidationError("Invalid HTML passed") - return data - - -class IssueActivitySerializer(BaseSerializer): - class Meta: - model = IssueActivity - exclude = ["created_by", "updated_by"] - - -class CycleIssueSerializer(BaseSerializer): - cycle = CycleSerializer(read_only=True) - - class Meta: - fields = ["cycle"] - - -class ModuleIssueSerializer(BaseSerializer): - module = ModuleSerializer(read_only=True) - - class Meta: - fields = ["module"] - - -class LabelLiteSerializer(BaseSerializer): - class Meta: - model = Label - fields = ["id", "name", "color"] - - -class IssueExpandSerializer(BaseSerializer): - cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) - module = ModuleLiteSerializer(source="issue_module.module", read_only=True) - labels = LabelLiteSerializer(read_only=True, many=True) - assignees = UserLiteSerializer(read_only=True, many=True) - state = StateLiteSerializer(read_only=True) - - class Meta: - model = Issue - fields = "__all__" - read_only_fields = [ - "id", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py deleted file mode 100644 index c76652e1e7c..00000000000 --- a/apiserver/plane/api/serializers/project.py +++ /dev/null @@ -1,98 +0,0 @@ -# Third party imports -from rest_framework import serializers - -# Module imports -from plane.db.models import Project, ProjectIdentifier, WorkspaceMember - -from .base import BaseSerializer - - -class ProjectSerializer(BaseSerializer): - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) - is_member = serializers.BooleanField(read_only=True) - sort_order = serializers.FloatField(read_only=True) - member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) - cover_image_url = serializers.CharField(read_only=True) - - class Meta: - model = Project - fields = "__all__" - read_only_fields = [ - "id", - "emoji", - "workspace", - "created_at", - "updated_at", - "created_by", - "updated_by", - "deleted_at", - "cover_image_url", - ] - - def validate(self, data): - # Check project lead should be a member of the workspace - if ( - data.get("project_lead", None) is not None - and not WorkspaceMember.objects.filter( - workspace_id=self.context["workspace_id"], - member_id=data.get("project_lead"), - ).exists() - ): - raise serializers.ValidationError( - "Project lead should be a user in the workspace" - ) - - # Check default assignee should be a member of the workspace - if ( - data.get("default_assignee", None) is not None - and not WorkspaceMember.objects.filter( - workspace_id=self.context["workspace_id"], - member_id=data.get("default_assignee"), - ).exists() - ): - raise serializers.ValidationError( - "Default assignee should be a user in the workspace" - ) - - return data - - def create(self, validated_data): - identifier = validated_data.get("identifier", "").strip().upper() - if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") - - if ProjectIdentifier.objects.filter( - name=identifier, workspace_id=self.context["workspace_id"] - ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") - - project = Project.objects.create( - **validated_data, workspace_id=self.context["workspace_id"] - ) - _ = ProjectIdentifier.objects.create( - name=project.identifier, - project=project, - workspace_id=self.context["workspace_id"], - ) - return project - - -class ProjectLiteSerializer(BaseSerializer): - cover_image_url = serializers.CharField(read_only=True) - - class Meta: - model = Project - fields = [ - "id", - "identifier", - "name", - "cover_image", - "icon_prop", - "emoji", - "description", - "cover_image_url", - ] - read_only_fields = fields diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py deleted file mode 100644 index 9005821f381..00000000000 --- a/apiserver/plane/api/views/cycle.py +++ /dev/null @@ -1,1205 +0,0 @@ -# Python imports -import json - -# Django imports -from django.core import serializers -from django.utils import timezone -from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import ( - Count, - F, - Func, - OuterRef, - Q, - Sum, - FloatField, - Case, - When, - Value, -) -from django.db.models.functions import Cast, Concat -from django.db import models - -# Third party imports -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from plane.api.serializers import CycleIssueSerializer, CycleSerializer -from plane.app.permissions import ProjectEntityPermission -from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Cycle, - CycleIssue, - Issue, - Project, - FileAsset, - IssueLink, - ProjectMember, - UserFavorite, -) -from plane.utils.analytics_plot import burndown_plot -from plane.utils.host import base_host -from .base import BaseAPIView -from plane.bgtasks.webhook_task import model_activity - - -class CycleAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to cycle. - - """ - - serializer_class = CycleSerializer - model = Cycle - webhook_event = "cycle" - permission_classes = [ProjectEntityPermission] - - def get_queryset(self): - return ( - Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .order_by(self.kwargs.get("order_by", "-created_at")) - .distinct() - ) - - def get(self, request, slug, project_id, pk=None): - project = Project.objects.get(workspace__slug=slug, pk=project_id) - if pk: - queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) - data = CycleSerializer( - queryset, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data - return Response(data, status=status.HTTP_200_OK) - queryset = self.get_queryset().filter(archived_at__isnull=True) - cycle_view = request.GET.get("cycle_view", "all") - - # Current Cycle - if cycle_view == "current": - queryset = queryset.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() - ) - data = CycleSerializer( - queryset, - many=True, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data - return Response(data, status=status.HTTP_200_OK) - - # Upcoming Cycles - if cycle_view == "upcoming": - queryset = queryset.filter(start_date__gt=timezone.now()) - return self.paginate( - request=request, - queryset=(queryset), - on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data, - ) - - # Completed Cycles - if cycle_view == "completed": - queryset = queryset.filter(end_date__lt=timezone.now()) - return self.paginate( - request=request, - queryset=(queryset), - on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data, - ) - - # Draft Cycles - if cycle_view == "draft": - queryset = queryset.filter(end_date=None, start_date=None) - return self.paginate( - request=request, - queryset=(queryset), - on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data, - ) - - # Incomplete Cycles - if cycle_view == "incomplete": - queryset = queryset.filter( - Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True) - ) - return self.paginate( - request=request, - queryset=(queryset), - on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data, - ) - return self.paginate( - request=request, - queryset=(queryset), - on_results=lambda cycles: CycleSerializer( - cycles, - many=True, - fields=self.fields, - expand=self.expand, - context={"project": project}, - ).data, - ) - - def post(self, request, slug, project_id): - if ( - request.data.get("start_date", None) is None - and request.data.get("end_date", None) is None - ) or ( - request.data.get("start_date", None) is not None - and request.data.get("end_date", None) is not None - ): - serializer = CycleSerializer(data=request.data) - if serializer.is_valid(): - if ( - request.data.get("external_id") - and request.data.get("external_source") - and Cycle.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).exists() - ): - cycle = Cycle.objects.filter( - workspace__slug=slug, - project_id=project_id, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).first() - return Response( - { - "error": "Cycle with the same external id and external source already exists", - "id": str(cycle.id), - }, - status=status.HTTP_409_CONFLICT, - ) - serializer.save(project_id=project_id, owned_by=request.user) - # Send the model activity - model_activity.delay( - model_name="cycle", - model_id=str(serializer.data["id"]), - requested_data=request.data, - current_instance=None, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response( - { - "error": "Both start date and end date are either required or are to be null" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - def patch(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - - current_instance = json.dumps( - CycleSerializer(cycle).data, cls=DjangoJSONEncoder - ) - - if cycle.archived_at: - return Response( - {"error": "Archived cycle cannot be edited"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - request_data = request.data - - if cycle.end_date is not None and cycle.end_date < timezone.now(): - if "sort_order" in request_data: - # Can only change sort order - request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) - } - else: - return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = CycleSerializer(cycle, data=request.data, partial=True) - if serializer.is_valid(): - if ( - request.data.get("external_id") - and (cycle.external_id != request.data.get("external_id")) - and Cycle.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get( - "external_source", cycle.external_source - ), - external_id=request.data.get("external_id"), - ).exists() - ): - return Response( - { - "error": "Cycle with the same external id and external source already exists", - "id": str(cycle.id), - }, - status=status.HTTP_409_CONFLICT, - ) - serializer.save() - - # Send the model activity - model_activity.delay( - model_name="cycle", - model_id=str(serializer.data["id"]), - requested_data=request.data, - current_instance=current_instance, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, pk): - cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - if cycle.owned_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the cycle"}, - status=status.HTTP_403_FORBIDDEN, - ) - - cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) - ) - - issue_activity.delay( - type="cycle.activity.deleted", - requested_data=json.dumps( - { - "cycle_id": str(pk), - "cycle_name": str(cycle.name), - "issues": [str(issue_id) for issue_id in cycle_issues], - } - ), - actor_id=str(request.user.id), - issue_id=None, - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - # Delete the cycle - cycle.delete() - # Delete the user favorite cycle - UserFavorite.objects.filter( - entity_type="cycle", entity_identifier=pk, project_id=project_id - ).delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): - permission_classes = [ProjectEntityPermission] - - def get_queryset(self): - return ( - Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(archived_at__isnull=False) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .order_by(self.kwargs.get("order_by", "-created_at")) - .distinct() - ) - - def get(self, request, slug, project_id): - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand - ).data, - ) - - def post(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) - if cycle.end_date >= timezone.now(): - return Response( - {"error": "Only completed cycles can be archived"}, - status=status.HTTP_400_BAD_REQUEST, - ) - cycle.archived_at = timezone.now() - cycle.save() - UserFavorite.objects.filter( - entity_type="cycle", - entity_identifier=cycle_id, - project_id=project_id, - workspace__slug=slug, - ).delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def delete(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) - cycle.archived_at = None - cycle.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class CycleIssueAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, - and `destroy` actions related to cycle issues. - - """ - - serializer_class = CycleIssueSerializer - model = CycleIssue - webhook_event = "cycle_issue" - bulk = True - permission_classes = [ProjectEntityPermission] - - def get_queryset(self): - return ( - CycleIssue.objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(cycle_id=self.kwargs.get("cycle_id")) - .select_related("project") - .select_related("workspace") - .select_related("cycle") - .select_related("issue", "issue__state", "issue__project") - .prefetch_related("issue__assignees", "issue__labels") - .order_by(self.kwargs.get("order_by", "-created_at")) - .distinct() - ) - - def get(self, request, slug, project_id, cycle_id, issue_id=None): - # Get - if issue_id: - cycle_issue = CycleIssue.objects.get( - workspace__slug=slug, - project_id=project_id, - cycle_id=cycle_id, - issue_id=issue_id, - ) - serializer = CycleIssueSerializer( - cycle_issue, fields=self.fields, expand=self.expand - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - # List - order_by = request.GET.get("order_by", "created_at") - issues = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate(bridge_id=F("issue_cycle__id")) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - return self.paginate( - request=request, - queryset=(issues), - on_results=lambda issues: CycleSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, - ) - - def post(self, request, slug, project_id, cycle_id): - issues = request.data.get("issues", []) - - if not issues: - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) - - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) - - # Get all CycleIssues already created - cycle_issues = list( - CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) - ) - - existing_issues = [ - str(cycle_issue.issue_id) - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues - ] - new_issues = list(set(issues) - set(existing_issues)) - - # New issues to create - created_records = CycleIssue.objects.bulk_create( - [ - CycleIssue( - project_id=project_id, - workspace_id=cycle.workspace_id, - cycle_id=cycle_id, - issue_id=issue, - ) - for issue in new_issues - ], - ignore_conflicts=True, - batch_size=10, - ) - - # Updated Issues - updated_records = [] - update_cycle_issue_activity = [] - # Iterate over each cycle_issue in cycle_issues - for cycle_issue in cycle_issues: - old_cycle_id = cycle_issue.cycle_id - # Update the cycle_issue's cycle_id - cycle_issue.cycle_id = cycle_id - # Add the modified cycle_issue to the records_to_update list - updated_records.append(cycle_issue) - # Record the update activity - update_cycle_issue_activity.append( - { - "old_cycle_id": str(old_cycle_id), - "new_cycle_id": str(cycle_id), - "issue_id": str(cycle_issue.issue_id), - } - ) - - # Update the cycle issues - CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) - - # Capture Issue Activity - issue_activity.delay( - type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": issues}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": serializers.serialize( - "json", created_records - ), - } - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - # Return all Cycle Issues - return Response( - CycleIssueSerializer(self.get_queryset(), many=True).data, - status=status.HTTP_200_OK, - ) - - def delete(self, request, slug, project_id, cycle_id, issue_id): - cycle_issue = CycleIssue.objects.get( - issue_id=issue_id, - workspace__slug=slug, - project_id=project_id, - cycle_id=cycle_id, - ) - issue_id = cycle_issue.issue_id - cycle_issue.delete() - issue_activity.delay( - type="cycle.activity.deleted", - requested_data=json.dumps( - { - "cycle_id": str(self.kwargs.get("cycle_id")), - "issues": [str(issue_id)], - } - ), - actor_id=str(self.request.user.id), - issue_id=str(issue_id), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class TransferCycleIssueAPIEndpoint(BaseAPIView): - """ - This viewset provides `create` actions for transferring the issues into a particular cycle. - - """ - - permission_classes = [ProjectEntityPermission] - - def post(self, request, slug, project_id, cycle_id): - new_cycle_id = request.data.get("new_cycle_id", False) - - if not new_cycle_id: - return Response( - {"error": "New Cycle Id is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - new_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ).first() - - old_cycle = ( - Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - issue_cycle__deleted_at__isnull=True, - issue_cycle__issue__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - issue_cycle__issue__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - .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, - issue_cycle__deleted_at__isnull=True, - ), - ) - ) - ) - old_cycle = old_cycle.first() - - estimate_type = Project.objects.filter( - workspace__slug=slug, - pk=project_id, - estimate__isnull=False, - estimate__type="points", - ).exists() - - if estimate_type: - assignee_estimate_data = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate( - avatar_url=Case( - # If `avatar_asset` exists, use it to generate the asset URL - When( - assignees__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field - Value("/"), - ), - ), - # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, - then="assignees__avatar", - ), - default=Value(None), - output_field=models.CharField(), - ) - ) - .values("display_name", "assignee_id", "avatar", "avatar_url") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - # assignee distribution serialization - assignee_estimate_distribution = [ - { - "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), - "avatar": item.get("avatar", None), - "avatar_url": item.get("avatar_url", None), - "total_estimates": item["total_estimates"], - "completed_estimates": item["completed_estimates"], - "pending_estimates": item["pending_estimates"], - } - for item in assignee_estimate_data - ] - - label_distribution_data = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) - .annotate( - completed_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_estimates=Sum( - Cast("estimate_point__value", FloatField()), - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - estimate_completion_chart = burndown_plot( - queryset=old_cycle, - slug=slug, - project_id=project_id, - plot_type="points", - cycle_id=cycle_id, - ) - # Label distribution serialization - label_estimate_distribution = [ - { - "label_name": item["label_name"], - "color": item["color"], - "label_id": (str(item["label_id"]) if item["label_id"] else None), - "total_estimates": item["total_estimates"], - "completed_estimates": item["completed_estimates"], - "pending_estimates": item["pending_estimates"], - } - for item in label_distribution_data - ] - - # Get the assignee distribution - assignee_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(display_name=F("assignees__display_name")) - .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .annotate( - avatar_url=Case( - # If `avatar_asset` exists, use it to generate the asset URL - When( - assignees__avatar_asset__isnull=False, - then=Concat( - Value("/api/assets/v2/static/"), - "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field - Value("/"), - ), - ), - # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), - default=Value(None), - output_field=models.CharField(), - ) - ) - .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("display_name") - ) - # assignee distribution serialized - assignee_distribution_data = [ - { - "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), - "avatar": item.get("avatar", None), - "avatar_url": item.get("avatar_url", None), - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in assignee_distribution - ] - - # Get the label distribution - label_distribution = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, - issue_cycle__deleted_at__isnull=True, - workspace__slug=slug, - project_id=project_id, - ) - .annotate(label_name=F("labels__name")) - .annotate(color=F("labels__color")) - .annotate(label_id=F("labels__id")) - .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) - .annotate( - completed_issues=Count( - "id", - filter=Q( - completed_at__isnull=False, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .annotate( - pending_issues=Count( - "id", - filter=Q( - completed_at__isnull=True, - archived_at__isnull=True, - is_draft=False, - ), - ) - ) - .order_by("label_name") - ) - - # Label distribution serilization - label_distribution_data = [ - { - "label_name": item["label_name"], - "color": item["color"], - "label_id": (str(item["label_id"]) if item["label_id"] else None), - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in label_distribution - ] - - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle, - slug=slug, - project_id=project_id, - plot_type="issues", - cycle_id=cycle_id, - ) - - current_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ).first() - - current_cycle.progress_snapshot = { - "total_issues": old_cycle.total_issues, - "completed_issues": old_cycle.completed_issues, - "cancelled_issues": old_cycle.cancelled_issues, - "started_issues": old_cycle.started_issues, - "unstarted_issues": old_cycle.unstarted_issues, - "backlog_issues": old_cycle.backlog_issues, - "distribution": { - "labels": label_distribution_data, - "assignees": assignee_distribution_data, - "completion_chart": completion_chart, - }, - "estimate_distribution": ( - {} - if not estimate_type - else { - "labels": label_estimate_distribution, - "assignees": assignee_estimate_distribution, - "completion_chart": estimate_completion_chart, - } - ), - } - current_cycle.save(update_fields=["progress_snapshot"]) - - if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): - return Response( - { - "error": "The cycle where the issues are transferred is already completed" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - cycle_issues = CycleIssue.objects.filter( - cycle_id=cycle_id, - project_id=project_id, - workspace__slug=slug, - issue__state__group__in=["backlog", "unstarted", "started"], - ) - - updated_cycles = [] - update_cycle_issue_activity = [] - for cycle_issue in cycle_issues: - cycle_issue.cycle_id = new_cycle_id - updated_cycles.append(cycle_issue) - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_id), - "new_cycle_id": str(new_cycle_id), - "issue_id": str(cycle_issue.issue_id), - } - ) - - cycle_issues = CycleIssue.objects.bulk_update( - updated_cycles, ["cycle_id"], batch_size=100 - ) - - # Capture Issue Activity - issue_activity.delay( - type="cycle.activity.created", - requested_data=json.dumps({"cycles_list": []}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": "[]", - } - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - - return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py deleted file mode 100644 index 7ba8cb2537b..00000000000 --- a/apiserver/plane/api/views/issue.py +++ /dev/null @@ -1,1161 +0,0 @@ -# Python imports -import json -import uuid - -# Django imports -from django.core.serializers.json import DjangoJSONEncoder -from django.http import HttpResponseRedirect -from django.db import IntegrityError -from django.db.models import ( - Case, - CharField, - Exists, - F, - Func, - Max, - OuterRef, - Q, - Value, - When, - Subquery, -) -from django.utils import timezone -from django.conf import settings - -# Third party imports -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from plane.api.serializers import ( - IssueAttachmentSerializer, - IssueActivitySerializer, - IssueCommentSerializer, - IssueLinkSerializer, - IssueSerializer, - LabelSerializer, -) -from plane.app.permissions import ( - ProjectEntityPermission, - ProjectLitePermission, - ProjectMemberPermission, -) -from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Issue, - IssueActivity, - FileAsset, - IssueComment, - IssueLink, - Label, - Project, - ProjectMember, - CycleIssue, - Workspace, -) -from plane.settings.storage import S3Storage -from plane.bgtasks.storage_metadata_task import get_asset_object_metadata -from .base import BaseAPIView -from plane.utils.host import base_host -from plane.bgtasks.webhook_task import model_activity -from plane.bgtasks.work_item_link_task import crawl_work_item_link_title - -class WorkspaceIssueAPIEndpoint(BaseAPIView): - """ - This viewset provides `retrieveByIssueId` on workspace level - - """ - - model = Issue - webhook_event = "issue" - permission_classes = [ProjectEntityPermission] - serializer_class = IssueSerializer - - @property - def project__identifier(self): - return self.kwargs.get("project__identifier", None) - - def get_queryset(self): - return ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project__identifier=self.kwargs.get("project__identifier")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(self.kwargs.get("order_by", "-created_at")) - ).distinct() - - def get(self, request, slug, project__identifier=None, issue__identifier=None): - if issue__identifier and project__identifier: - issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get( - workspace__slug=slug, - project__identifier=project__identifier, - sequence_id=issue__identifier, - ) - return Response( - IssueSerializer(issue, fields=self.fields, expand=self.expand).data, - status=status.HTTP_200_OK, - ) - - -class IssueAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to issue. - - """ - - model = Issue - webhook_event = "issue" - permission_classes = [ProjectEntityPermission] - serializer_class = IssueSerializer - - def get_queryset(self): - return ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(self.kwargs.get("order_by", "-created_at")) - ).distinct() - - def get(self, request, slug, project_id, pk=None): - external_id = request.GET.get("external_id") - external_source = request.GET.get("external_source") - - if external_id and external_source: - issue = Issue.objects.get( - external_id=external_id, - external_source=external_source, - workspace__slug=slug, - project_id=project_id, - ) - return Response( - IssueSerializer(issue, fields=self.fields, expand=self.expand).data, - status=status.HTTP_200_OK, - ) - - if pk: - issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get(workspace__slug=slug, project_id=project_id, pk=pk) - return Response( - IssueSerializer(issue, fields=self.fields, expand=self.expand).data, - status=status.HTTP_200_OK, - ) - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = ( - self.get_queryset() - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] - ) - ) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=FileAsset.objects.filter( - issue_id=OuterRef("id"), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ) - - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - return self.paginate( - request=request, - queryset=(issue_queryset), - on_results=lambda issues: IssueSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, - ) - - def post(self, request, slug, project_id): - project = Project.objects.get(pk=project_id) - - serializer = IssueSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - if ( - request.data.get("external_id") - and request.data.get("external_source") - and Issue.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).exists() - ): - issue = Issue.objects.filter( - workspace__slug=slug, - project_id=project_id, - external_id=request.data.get("external_id"), - external_source=request.data.get("external_source"), - ).first() - return Response( - { - "error": "Issue with the same external id and external source already exists", - "id": str(issue.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - serializer.save() - # Refetch the issue - issue = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=serializer.data["id"] - ).first() - issue.created_at = request.data.get("created_at", timezone.now()) - issue.created_by_id = request.data.get("created_by", request.user.id) - issue.save(update_fields=["created_at", "created_by"]) - - # Track the issue - issue_activity.delay( - type="issue.activity.created", - requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - - # Send the model activity - model_activity.delay( - model_name="issue", - model_id=str(serializer.data["id"]), - requested_data=request.data, - current_instance=None, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def put(self, request, slug, project_id): - # Get the entities required for putting the issue, external_id and - # external_source are must to identify the issue here - project = Project.objects.get(pk=project_id) - external_id = request.data.get("external_id") - external_source = request.data.get("external_source") - - # If the external_id and source are present, we need to find the exact - # issue that needs to be updated with the provided external_id and - # external_source - if external_id and external_source: - try: - issue = Issue.objects.get( - project_id=project_id, - workspace__slug=slug, - external_id=external_id, - external_source=external_source, - ) - - # Get the current instance of the issue in order to track - # changes and dispatch the issue activity - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - - # Get the requested data, encode it as django object and pass it - # to serializer to validation - requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = IssueSerializer( - issue, - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - }, - partial=True, - ) - if serializer.is_valid(): - # If the serializer is valid, save the issue and dispatch - # the update issue activity worker event. - serializer.save() - issue_activity.delay( - type="issue.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response( - # If the serializer is not valid, respond with 400 bad - # request - serializer.errors, - status=status.HTTP_400_BAD_REQUEST, - ) - except Issue.DoesNotExist: - # If the issue does not exist, a new record needs to be created - # for the requested data. - # Serialize the data with the context of the project and - # workspace - serializer = IssueSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - # If the serializer is valid, save the issue and dispatch the - # issue activity worker event as created - if serializer.is_valid(): - serializer.save() - # Refetch the issue - issue = Issue.objects.filter( - workspace__slug=slug, - project_id=project_id, - pk=serializer.data["id"], - ).first() - - # If any of the created_at or created_by is present, update - # the issue with the provided data, else return with the - # default states given. - issue.created_at = request.data.get("created_at", timezone.now()) - issue.created_by_id = request.data.get( - "created_by", request.user.id - ) - issue.save(update_fields=["created_at", "created_by"]) - - issue_activity.delay( - type="issue.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), - actor_id=str(request.user.id), - issue_id=str(serializer.data.get("id", None)), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response( - {"error": "external_id and external_source are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - def patch(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - project = Project.objects.get(pk=project_id) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = IssueSerializer( - issue, - data=request.data, - context={"project_id": project_id, "workspace_id": project.workspace_id}, - partial=True, - ) - if serializer.is_valid(): - if ( - request.data.get("external_id") - and (issue.external_id != str(request.data.get("external_id"))) - and Issue.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get( - "external_source", issue.external_source - ), - external_id=request.data.get("external_id"), - ).exists() - ): - return Response( - { - "error": "Issue with the same external id and external source already exists", - "id": str(issue.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - serializer.save() - issue_activity.delay( - type="issue.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, pk=None): - issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - if issue.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the issue"}, - status=status.HTTP_403_FORBIDDEN, - ) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) - issue.delete() - issue_activity.delay( - type="issue.activity.deleted", - requested_data=json.dumps({"issue_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class LabelAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to the labels. - - """ - - serializer_class = LabelSerializer - model = Label - permission_classes = [ProjectMemberPermission] - - def get_queryset(self): - return ( - Label.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(project__archived_at__isnull=True) - .select_related("project") - .select_related("workspace") - .select_related("parent") - .distinct() - .order_by(self.kwargs.get("order_by", "-created_at")) - ) - - def post(self, request, slug, project_id): - try: - serializer = LabelSerializer(data=request.data) - if serializer.is_valid(): - if ( - request.data.get("external_id") - and request.data.get("external_source") - and Label.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).exists() - ): - label = Label.objects.filter( - workspace__slug=slug, - project_id=project_id, - external_id=request.data.get("external_id"), - external_source=request.data.get("external_source"), - ).first() - return Response( - { - "error": "Label with the same external id and external source already exists", - "id": str(label.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except IntegrityError: - label = Label.objects.filter( - workspace__slug=slug, - project_id=project_id, - name=request.data.get("name"), - ).first() - return Response( - { - "error": "Label with the same name already exists in the project", - "id": str(label.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - def get(self, request, slug, project_id, pk=None): - if pk is None: - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda labels: LabelSerializer( - labels, many=True, fields=self.fields, expand=self.expand - ).data, - ) - label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer(label, fields=self.fields, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) - - def patch(self, request, slug, project_id, pk=None): - label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer(label, data=request.data, partial=True) - if serializer.is_valid(): - if ( - str(request.data.get("external_id")) - and (label.external_id != str(request.data.get("external_id"))) - and Issue.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get( - "external_source", label.external_source - ), - external_id=request.data.get("external_id"), - ).exists() - ): - return Response( - { - "error": "Label with the same external id and external source already exists", - "id": str(label.id), - }, - status=status.HTTP_409_CONFLICT, - ) - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, pk=None): - label = self.get_queryset().get(pk=pk) - label.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueLinkAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to the links of the particular issue. - - """ - - permission_classes = [ProjectEntityPermission] - - model = IssueLink - serializer_class = IssueLinkSerializer - - def get_queryset(self): - return ( - IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(project__archived_at__isnull=True) - .order_by(self.kwargs.get("order_by", "-created_at")) - .distinct() - ) - - def get(self, request, slug, project_id, issue_id, pk=None): - if pk is None: - issue_links = self.get_queryset() - serializer = IssueLinkSerializer( - issue_links, fields=self.fields, expand=self.expand - ) - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda issue_links: IssueLinkSerializer( - issue_links, many=True, fields=self.fields, expand=self.expand - ).data, - ) - issue_link = self.get_queryset().get(pk=pk) - serializer = IssueLinkSerializer( - issue_link, fields=self.fields, expand=self.expand - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - def post(self, request, slug, project_id, issue_id): - serializer = IssueLinkSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) - crawl_work_item_link_title.delay( - serializer.data.get("id"), serializer.data.get("url") - ) - - link = IssueLink.objects.get(pk=serializer.data["id"]) - link.created_by_id = request.data.get("created_by", request.user.id) - link.save(update_fields=["created_by"]) - issue_activity.delay( - type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - issue_id=str(self.kwargs.get("issue_id")), - project_id=str(self.kwargs.get("project_id")), - actor_id=str(link.created_by_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder - ) - serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - crawl_work_item_link_title.delay( - serializer.data.get("id"), serializer.data.get("url") - ) - issue_activity.delay( - type="link.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder - ) - issue_activity.delay( - type="link.activity.deleted", - requested_data=json.dumps({"link_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - issue_link.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueCommentAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to comments of the particular issue. - - """ - - serializer_class = IssueCommentSerializer - model = IssueComment - webhook_event = "issue_comment" - permission_classes = [ProjectLitePermission] - - def get_queryset(self): - return ( - IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(project__archived_at__isnull=True) - .select_related("workspace", "project", "issue", "actor") - .annotate( - is_member=Exists( - ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - member_id=self.request.user.id, - is_active=True, - ) - ) - ) - .order_by(self.kwargs.get("order_by", "-created_at")) - .distinct() - ) - - def get(self, request, slug, project_id, issue_id, pk=None): - if pk: - issue_comment = self.get_queryset().get(pk=pk) - serializer = IssueCommentSerializer( - issue_comment, fields=self.fields, expand=self.expand - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda issue_comment: IssueCommentSerializer( - issue_comment, many=True, fields=self.fields, expand=self.expand - ).data, - ) - - def post(self, request, slug, project_id, issue_id): - # Validation check if the issue already exists - if ( - request.data.get("external_id") - and request.data.get("external_source") - and IssueComment.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).exists() - ): - issue_comment = IssueComment.objects.filter( - workspace__slug=slug, - project_id=project_id, - external_id=request.data.get("external_id"), - external_source=request.data.get("external_source"), - ).first() - return Response( - { - "error": "Issue Comment with the same external id and external source already exists", - "id": str(issue_comment.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - serializer = IssueCommentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save( - project_id=project_id, issue_id=issue_id, actor=request.user - ) - issue_comment = IssueComment.objects.get(pk=serializer.data.get("id")) - # Update the created_at and the created_by and save the comment - issue_comment.created_at = request.data.get("created_at", timezone.now()) - issue_comment.created_by_id = request.data.get( - "created_by", request.user.id - ) - issue_comment.save(update_fields=["created_at", "created_by"]) - - issue_activity.delay( - type="comment.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - actor_id=str(issue_comment.created_by_id), - issue_id=str(self.kwargs.get("issue_id")), - project_id=str(self.kwargs.get("project_id")), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - # Send the model activity - model_activity.delay( - model_name="issue_comment", - model_id=str(serializer.data["id"]), - requested_data=request.data, - current_instance=None, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, slug, project_id, issue_id, pk): - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder - ) - - # Validation check if the issue already exists - if ( - request.data.get("external_id") - and (issue_comment.external_id != str(request.data.get("external_id"))) - and IssueComment.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get( - "external_source", issue_comment.external_source - ), - external_id=request.data.get("external_id"), - ).exists() - ): - return Response( - { - "error": "Issue Comment with the same external id and external source already exists", - "id": str(issue_comment.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - serializer = IssueCommentSerializer( - issue_comment, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="comment.activity.updated", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - # Send the model activity - model_activity.delay( - model_name="issue_comment", - model_id=str(pk), - requested_data=request.data, - current_instance=current_instance, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, issue_id, pk): - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder - ) - issue_comment.delete() - issue_activity.delay( - type="comment.activity.deleted", - requested_data=json.dumps({"comment_id": str(pk)}), - actor_id=str(request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=current_instance, - epoch=int(timezone.now().timestamp()), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class IssueActivityAPIEndpoint(BaseAPIView): - permission_classes = [ProjectEntityPermission] - - def get(self, request, slug, project_id, issue_id, pk=None): - issue_activities = ( - IssueActivity.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) - .filter( - ~Q(field__in=["comment", "vote", "reaction", "draft"]), - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - ) - .filter(project__archived_at__isnull=True) - .select_related("actor", "workspace", "issue", "project") - ).order_by(request.GET.get("order_by", "created_at")) - - if pk: - issue_activities = issue_activities.get(pk=pk) - serializer = IssueActivitySerializer(issue_activities) - return Response(serializer.data, status=status.HTTP_200_OK) - - return self.paginate( - request=request, - queryset=(issue_activities), - on_results=lambda issue_activity: IssueActivitySerializer( - issue_activity, many=True, fields=self.fields, expand=self.expand - ).data, - ) - - -class IssueAttachmentEndpoint(BaseAPIView): - serializer_class = IssueAttachmentSerializer - permission_classes = [ProjectEntityPermission] - model = FileAsset - - def post(self, request, slug, project_id, issue_id): - name = request.data.get("name") - type = request.data.get("type", False) - size = request.data.get("size") - external_id = request.data.get("external_id") - external_source = request.data.get("external_source") - - # Check if the request is valid - if not name or not size: - return Response( - {"error": "Invalid request.", "status": False}, - status=status.HTTP_400_BAD_REQUEST, - ) - - size_limit = min(size, settings.FILE_SIZE_LIMIT) - - if not type or type not in settings.ATTACHMENT_MIME_TYPES: - return Response( - {"error": "Invalid file type.", "status": False}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the workspace - workspace = Workspace.objects.get(slug=slug) - - # asset key - asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" - - if ( - request.data.get("external_id") - and request.data.get("external_source") - and FileAsset.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - issue_id=issue_id, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ).exists() - ): - asset = FileAsset.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - issue_id=issue_id, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ).first() - return Response( - { - "error": "Issue with the same external id and external source already exists", - "id": str(asset.id), - }, - status=status.HTTP_409_CONFLICT, - ) - - # Create a File Asset - asset = FileAsset.objects.create( - attributes={"name": name, "type": type, "size": size_limit}, - asset=asset_key, - size=size_limit, - workspace_id=workspace.id, - created_by=request.user, - issue_id=issue_id, - project_id=project_id, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - external_id=external_id, - external_source=external_source, - ) - - # Get the presigned URL - storage = S3Storage(request=request) - # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) - # Return the presigned URL - return Response( - { - "upload_data": presigned_url, - "asset_id": str(asset.id), - "attachment": IssueAttachmentSerializer(asset).data, - "asset_url": asset.asset_url, - }, - status=status.HTTP_200_OK, - ) - - def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) - issue_attachment.is_deleted = True - issue_attachment.deleted_at = timezone.now() - issue_attachment.save() - - issue_activity.delay( - type="attachment.activity.deleted", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(issue_id), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - - # Get the storage metadata - if not issue_attachment.storage_metadata: - get_asset_object_metadata.delay(str(issue_attachment.id)) - issue_attachment.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - def get(self, request, slug, project_id, issue_id, pk=None): - if pk: - # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace__slug=slug, project_id=project_id - ) - - # Check if the asset is uploaded - if not asset.is_uploaded: - return Response( - {"error": "The asset is not uploaded.", "status": False}, - status=status.HTTP_400_BAD_REQUEST, - ) - - storage = S3Storage(request=request) - presigned_url = storage.generate_presigned_url( - object_name=asset.asset.name, - disposition="attachment", - filename=asset.attributes.get("name"), - ) - return HttpResponseRedirect(presigned_url) - - # Get all the attachments - issue_attachments = FileAsset.objects.filter( - issue_id=issue_id, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - workspace__slug=slug, - project_id=project_id, - is_uploaded=True, - ) - # Serialize the attachments - serializer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def patch(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) - serializer = IssueAttachmentSerializer(issue_attachment) - - # Send this activity only if the attachment is not uploaded before - if not issue_attachment.is_uploaded: - issue_activity.delay( - type="attachment.activity.created", - requested_data=None, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("issue_id", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - - # Update the attachment - issue_attachment.is_uploaded = True - issue_attachment.created_by = request.user - - # Get the storage metadata - if not issue_attachment.storage_metadata: - get_asset_object_metadata.delay(str(issue_attachment.id)) - issue_attachment.save() - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py deleted file mode 100644 index f0d98886e3b..00000000000 --- a/apiserver/plane/app/serializers/__init__.py +++ /dev/null @@ -1,131 +0,0 @@ -from .base import BaseSerializer -from .user import ( - UserSerializer, - UserLiteSerializer, - ChangePasswordSerializer, - ResetPasswordSerializer, - UserAdminLiteSerializer, - UserMeSerializer, - UserMeSettingsSerializer, - ProfileSerializer, - AccountSerializer, -) -from .workspace import ( - WorkSpaceSerializer, - WorkSpaceMemberSerializer, - WorkSpaceMemberInviteSerializer, - WorkspaceLiteSerializer, - WorkspaceThemeSerializer, - WorkspaceMemberAdminSerializer, - WorkspaceMemberMeSerializer, - WorkspaceUserPropertiesSerializer, - WorkspaceUserLinkSerializer, - WorkspaceRecentVisitSerializer, - WorkspaceHomePreferenceSerializer, - StickySerializer, -) -from .project import ( - ProjectSerializer, - ProjectListSerializer, - ProjectDetailSerializer, - ProjectMemberSerializer, - ProjectMemberInviteSerializer, - ProjectIdentifierSerializer, - ProjectLiteSerializer, - ProjectMemberLiteSerializer, - DeployBoardSerializer, - ProjectMemberAdminSerializer, - ProjectPublicMemberSerializer, - ProjectMemberRoleSerializer, -) -from .state import StateSerializer, StateLiteSerializer -from .view import IssueViewSerializer, ViewIssueListSerializer -from .cycle import ( - CycleSerializer, - CycleIssueSerializer, - CycleWriteSerializer, - CycleUserPropertiesSerializer, -) -from .asset import FileAssetSerializer -from .issue import ( - IssueCreateSerializer, - IssueActivitySerializer, - IssueCommentSerializer, - IssueUserPropertySerializer, - IssueAssigneeSerializer, - LabelSerializer, - IssueSerializer, - IssueFlatSerializer, - IssueStateSerializer, - IssueLinkSerializer, - IssueIntakeSerializer, - IssueLiteSerializer, - IssueAttachmentSerializer, - IssueSubscriberSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssueRelationSerializer, - RelatedIssueSerializer, - IssuePublicSerializer, - IssueDetailSerializer, - IssueReactionLiteSerializer, - IssueAttachmentLiteSerializer, - IssueLinkLiteSerializer, - IssueVersionDetailSerializer, - IssueDescriptionVersionDetailSerializer, - IssueListDetailSerializer, -) - -from .module import ( - ModuleDetailSerializer, - ModuleWriteSerializer, - ModuleSerializer, - ModuleIssueSerializer, - ModuleLinkSerializer, - ModuleUserPropertiesSerializer, -) - -from .api import APITokenSerializer, APITokenReadSerializer - -from .importer import ImporterSerializer - -from .page import ( - PageSerializer, - PageLogSerializer, - SubPageSerializer, - PageDetailSerializer, - PageVersionSerializer, - PageVersionDetailSerializer, -) - -from .estimate import ( - EstimateSerializer, - EstimatePointSerializer, - EstimateReadSerializer, - WorkspaceEstimateSerializer, -) - -from .intake import ( - IntakeSerializer, - IntakeIssueSerializer, - IssueStateIntakeSerializer, - IntakeIssueLiteSerializer, - IntakeIssueDetailSerializer, -) - -from .analytic import AnalyticViewSerializer - -from .notification import NotificationSerializer, UserNotificationPreferenceSerializer - -from .exporter import ExporterHistorySerializer - -from .webhook import WebhookSerializer, WebhookLogSerializer - -from .favorite import UserFavoriteSerializer - -from .draft import ( - DraftIssueCreateSerializer, - DraftIssueSerializer, - DraftIssueDetailSerializer, -) diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py deleted file mode 100644 index b3b69e37531..00000000000 --- a/apiserver/plane/app/serializers/cycle.py +++ /dev/null @@ -1,105 +0,0 @@ -# Third party imports -from rest_framework import serializers - -# Module imports -from .base import BaseSerializer -from .issue import IssueStateSerializer -from plane.db.models import Cycle, CycleIssue, CycleUserProperties -from plane.utils.timezone_converter import convert_to_utc - - -class CycleWriteSerializer(BaseSerializer): - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - and data.get("start_date", None) > data.get("end_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed end date") - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - ): - project_id = ( - self.initial_data.get("project_id", None) - or (self.instance and self.instance.project_id) - or self.context.get("project_id", None) - ) - data["start_date"] = convert_to_utc( - date=str(data.get("start_date").date()), - project_id=project_id, - is_start_date=True, - ) - data["end_date"] = convert_to_utc( - date=str(data.get("end_date", None).date()), - project_id=project_id, - ) - return data - - class Meta: - model = Cycle - fields = "__all__" - read_only_fields = ["workspace", "project", "owned_by", "archived_at"] - - -class CycleSerializer(BaseSerializer): - # favorite - is_favorite = serializers.BooleanField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - # state group wise distribution - cancelled_issues = serializers.IntegerField(read_only=True) - completed_issues = serializers.IntegerField(read_only=True) - started_issues = serializers.IntegerField(read_only=True) - unstarted_issues = serializers.IntegerField(read_only=True) - backlog_issues = serializers.IntegerField(read_only=True) - - # active | draft | upcoming | completed - status = serializers.CharField(read_only=True) - - class Meta: - model = Cycle - fields = [ - # necessary fields - "id", - "workspace_id", - "project_id", - # model fields - "name", - "description", - "start_date", - "end_date", - "owned_by_id", - "view_props", - "sort_order", - "external_source", - "external_id", - "progress_snapshot", - "logo_props", - # meta fields - "is_favorite", - "total_issues", - "cancelled_issues", - "completed_issues", - "started_issues", - "unstarted_issues", - "backlog_issues", - "status", - ] - read_only_fields = fields - - -class CycleIssueSerializer(BaseSerializer): - issue_detail = IssueStateSerializer(read_only=True, source="issue") - sub_issues_count = serializers.IntegerField(read_only=True) - - class Meta: - model = CycleIssue - fields = "__all__" - read_only_fields = ["workspace", "project", "cycle"] - - -class CycleUserPropertiesSerializer(BaseSerializer): - class Meta: - model = CycleUserProperties - fields = "__all__" - read_only_fields = ["workspace", "project", "cycle" "user"] diff --git a/apiserver/plane/app/serializers/draft.py b/apiserver/plane/app/serializers/draft.py deleted file mode 100644 index f308352633b..00000000000 --- a/apiserver/plane/app/serializers/draft.py +++ /dev/null @@ -1,271 +0,0 @@ -# Django imports -from django.utils import timezone - -# Third Party imports -from rest_framework import serializers - -# Module imports -from .base import BaseSerializer -from plane.db.models import ( - User, - Issue, - Label, - State, - DraftIssue, - DraftIssueAssignee, - DraftIssueLabel, - DraftIssueCycle, - DraftIssueModule, -) - - -class DraftIssueCreateSerializer(BaseSerializer): - # ids - state_id = serializers.PrimaryKeyRelatedField( - source="state", queryset=State.objects.all(), required=False, allow_null=True - ) - parent_id = serializers.PrimaryKeyRelatedField( - source="parent", queryset=Issue.objects.all(), required=False, allow_null=True - ) - label_ids = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), - write_only=True, - required=False, - ) - assignee_ids = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - - class Meta: - model = DraftIssue - fields = "__all__" - read_only_fields = [ - "workspace", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - def to_representation(self, instance): - data = super().to_representation(instance) - assignee_ids = self.initial_data.get("assignee_ids") - data["assignee_ids"] = assignee_ids if assignee_ids else [] - label_ids = self.initial_data.get("label_ids") - data["label_ids"] = label_ids if label_ids else [] - return data - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("target_date", None) is not None - and data.get("start_date", None) > data.get("target_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed target date") - return data - - def create(self, validated_data): - assignees = validated_data.pop("assignee_ids", None) - labels = validated_data.pop("label_ids", None) - modules = validated_data.pop("module_ids", None) - cycle_id = self.initial_data.get("cycle_id", None) - modules = self.initial_data.get("module_ids", None) - - workspace_id = self.context["workspace_id"] - project_id = self.context["project_id"] - - # Create Issue - issue = DraftIssue.objects.create( - **validated_data, workspace_id=workspace_id, project_id=project_id - ) - - # Issue Audit Users - created_by_id = issue.created_by_id - updated_by_id = issue.updated_by_id - - if assignees is not None and len(assignees): - DraftIssueAssignee.objects.bulk_create( - [ - DraftIssueAssignee( - assignee=user, - draft_issue=issue, - workspace_id=workspace_id, - project_id=project_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for user in assignees - ], - batch_size=10, - ) - - if labels is not None and len(labels): - DraftIssueLabel.objects.bulk_create( - [ - DraftIssueLabel( - label=label, - draft_issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - - if cycle_id is not None: - DraftIssueCycle.objects.create( - cycle_id=cycle_id, - draft_issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - - if modules is not None and len(modules): - DraftIssueModule.objects.bulk_create( - [ - DraftIssueModule( - module_id=module_id, - draft_issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for module_id in modules - ], - batch_size=10, - ) - - return issue - - def update(self, instance, validated_data): - assignees = validated_data.pop("assignee_ids", None) - labels = validated_data.pop("label_ids", None) - cycle_id = self.context.get("cycle_id", None) - modules = self.initial_data.get("module_ids", None) - - # Related models - workspace_id = instance.workspace_id - project_id = instance.project_id - - created_by_id = instance.created_by_id - updated_by_id = instance.updated_by_id - - if assignees is not None: - DraftIssueAssignee.objects.filter(draft_issue=instance).delete() - DraftIssueAssignee.objects.bulk_create( - [ - DraftIssueAssignee( - assignee=user, - draft_issue=instance, - workspace_id=workspace_id, - project_id=project_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for user in assignees - ], - batch_size=10, - ) - - if labels is not None: - DraftIssueLabel.objects.filter(draft_issue=instance).delete() - DraftIssueLabel.objects.bulk_create( - [ - DraftIssueLabel( - label=label, - draft_issue=instance, - workspace_id=workspace_id, - project_id=project_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - - if cycle_id != "not_provided": - DraftIssueCycle.objects.filter(draft_issue=instance).delete() - if cycle_id: - DraftIssueCycle.objects.create( - cycle_id=cycle_id, - draft_issue=instance, - workspace_id=workspace_id, - project_id=project_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - - if modules is not None: - DraftIssueModule.objects.filter(draft_issue=instance).delete() - DraftIssueModule.objects.bulk_create( - [ - DraftIssueModule( - module_id=module_id, - draft_issue=instance, - workspace_id=workspace_id, - project_id=project_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for module_id in modules - ], - batch_size=10, - ) - - # Time updation occurs even when other related models are updated - instance.updated_at = timezone.now() - return super().update(instance, validated_data) - - -class DraftIssueSerializer(BaseSerializer): - # ids - cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - # Many to many - label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - class Meta: - model = DraftIssue - fields = [ - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "created_at", - "updated_at", - "created_by", - "updated_by", - "type_id", - "description_html", - ] - read_only_fields = fields - - -class DraftIssueDetailSerializer(DraftIssueSerializer): - description_html = serializers.CharField() - - class Meta(DraftIssueSerializer.Meta): - fields = DraftIssueSerializer.Meta.fields + ["description_html"] - read_only_fields = fields diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py deleted file mode 100644 index c2aca4f8129..00000000000 --- a/apiserver/plane/app/serializers/issue.py +++ /dev/null @@ -1,941 +0,0 @@ -# Django imports -from django.utils import timezone -from django.core.validators import URLValidator -from django.core.exceptions import ValidationError -from django.db import IntegrityError - -# Third Party imports -from rest_framework import serializers - -# Module imports -from .base import BaseSerializer, DynamicBaseSerializer -from .user import UserLiteSerializer -from .state import StateLiteSerializer -from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer -from plane.db.models import ( - User, - Issue, - IssueActivity, - IssueComment, - IssueUserProperty, - IssueAssignee, - IssueSubscriber, - IssueLabel, - Label, - CycleIssue, - Cycle, - Module, - ModuleIssue, - IssueLink, - FileAsset, - IssueReaction, - CommentReaction, - IssueVote, - IssueRelation, - State, - IssueVersion, - IssueDescriptionVersion, - ProjectMember, -) - - -class IssueFlatSerializer(BaseSerializer): - ## Contain only flat fields - - class Meta: - model = Issue - fields = [ - "id", - "name", - "description", - "description_html", - "priority", - "start_date", - "target_date", - "sequence_id", - "sort_order", - "is_draft", - ] - - -class IssueProjectLiteSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(source="project", read_only=True) - - class Meta: - model = Issue - fields = ["id", "project_detail", "name", "sequence_id"] - read_only_fields = fields - - -##TODO: Find a better way to write this serializer -## Find a better approach to save manytomany? -class IssueCreateSerializer(BaseSerializer): - # ids - state_id = serializers.PrimaryKeyRelatedField( - source="state", queryset=State.objects.all(), required=False, allow_null=True - ) - parent_id = serializers.PrimaryKeyRelatedField( - source="parent", queryset=Issue.objects.all(), required=False, allow_null=True - ) - label_ids = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), - write_only=True, - required=False, - ) - assignee_ids = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - project_id = serializers.UUIDField(source="project.id", read_only=True) - workspace_id = serializers.UUIDField(source="workspace.id", read_only=True) - - class Meta: - model = Issue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - def to_representation(self, instance): - data = super().to_representation(instance) - assignee_ids = self.initial_data.get("assignee_ids") - data["assignee_ids"] = assignee_ids if assignee_ids else [] - label_ids = self.initial_data.get("label_ids") - data["label_ids"] = label_ids if label_ids else [] - return data - - def validate(self, attrs): - if ( - attrs.get("start_date", None) is not None - and attrs.get("target_date", None) is not None - and attrs.get("start_date", None) > attrs.get("target_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed target date") - - if attrs.get("assignee_ids", []): - attrs["assignee_ids"] = ProjectMember.objects.filter( - project_id=self.context["project_id"], - role__gte=15, - is_active=True, - member_id__in=attrs["assignee_ids"], - ).values_list("member_id", flat=True) - - return attrs - - def create(self, validated_data): - assignees = validated_data.pop("assignee_ids", None) - labels = validated_data.pop("label_ids", None) - - project_id = self.context["project_id"] - workspace_id = self.context["workspace_id"] - default_assignee_id = self.context["default_assignee_id"] - - # Create Issue - issue = Issue.objects.create(**validated_data, project_id=project_id) - - # Issue Audit Users - created_by_id = issue.created_by_id - updated_by_id = issue.updated_by_id - - if assignees is not None and len(assignees): - try: - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee_id=assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for assignee_id in assignees - ], - batch_size=10, - ) - except IntegrityError: - pass - else: - # Then assign it to default assignee, if it is a valid assignee - if ( - default_assignee_id is not None - and ProjectMember.objects.filter( - member_id=default_assignee_id, - project_id=project_id, - role__gte=15, - is_active=True, - ).exists() - ): - try: - IssueAssignee.objects.create( - assignee_id=default_assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - except IntegrityError: - pass - - if labels is not None and len(labels): - try: - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label=label, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - except IntegrityError: - pass - - return issue - - def update(self, instance, validated_data): - assignees = validated_data.pop("assignee_ids", None) - labels = validated_data.pop("label_ids", None) - - # Related models - project_id = instance.project_id - workspace_id = instance.workspace_id - created_by_id = instance.created_by_id - updated_by_id = instance.updated_by_id - - if assignees is not None: - IssueAssignee.objects.filter(issue=instance).delete() - try: - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee_id=assignee_id, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for assignee_id in assignees - ], - batch_size=10, - ignore_conflicts=True, - ) - except IntegrityError: - pass - - if labels is not None: - IssueLabel.objects.filter(issue=instance).delete() - try: - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label=label, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ignore_conflicts=True, - ) - except IntegrityError: - pass - - # Time updation occues even when other related models are updated - instance.updated_at = timezone.now() - return super().update(instance, validated_data) - - -class IssueActivitySerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - issue_detail = IssueFlatSerializer(read_only=True, source="issue") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - source_data = serializers.SerializerMethodField() - - def get_source_data(self, obj): - if ( - hasattr(obj, "issue") - and hasattr(obj.issue, "source_data") - and obj.issue.source_data - ): - return { - "source": obj.issue.source_data[0].source, - "source_email": obj.issue.source_data[0].source_email, - "extra": obj.issue.source_data[0].extra, - } - return None - - class Meta: - model = IssueActivity - fields = "__all__" - - -class IssueUserPropertySerializer(BaseSerializer): - class Meta: - model = IssueUserProperty - fields = "__all__" - read_only_fields = ["user", "workspace", "project"] - - -class LabelSerializer(BaseSerializer): - class Meta: - model = Label - fields = [ - "parent", - "name", - "color", - "id", - "project_id", - "workspace_id", - "sort_order", - ] - read_only_fields = ["workspace", "project"] - - -class LabelLiteSerializer(BaseSerializer): - class Meta: - model = Label - fields = ["id", "name", "color"] - - -class IssueLabelSerializer(BaseSerializer): - class Meta: - model = IssueLabel - fields = "__all__" - read_only_fields = ["workspace", "project"] - - -class IssueRelationSerializer(BaseSerializer): - id = serializers.UUIDField(source="related_issue.id", read_only=True) - project_id = serializers.PrimaryKeyRelatedField( - source="related_issue.project_id", read_only=True - ) - sequence_id = serializers.IntegerField( - source="related_issue.sequence_id", read_only=True - ) - name = serializers.CharField(source="related_issue.name", read_only=True) - relation_type = serializers.CharField(read_only=True) - state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True) - priority = serializers.CharField(source="related_issue.priority", read_only=True) - assignee_ids = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - - class Meta: - model = IssueRelation - fields = [ - "id", - "project_id", - "sequence_id", - "relation_type", - "name", - "state_id", - "priority", - "assignee_ids", - "created_by", - "created_at", - "updated_at", - "updated_by", - ] - read_only_fields = [ - "workspace", - "project", - "created_by", - "created_at", - "updated_by", - "updated_at", - ] - - -class RelatedIssueSerializer(BaseSerializer): - id = serializers.UUIDField(source="issue.id", read_only=True) - project_id = serializers.PrimaryKeyRelatedField( - source="issue.project_id", read_only=True - ) - sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True) - name = serializers.CharField(source="issue.name", read_only=True) - relation_type = serializers.CharField(read_only=True) - state_id = serializers.UUIDField(source="issue.state.id", read_only=True) - priority = serializers.CharField(source="issue.priority", read_only=True) - assignee_ids = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - - class Meta: - model = IssueRelation - fields = [ - "id", - "project_id", - "sequence_id", - "relation_type", - "name", - "state_id", - "priority", - "assignee_ids", - "created_by", - "created_at", - "updated_by", - "updated_at", - ] - read_only_fields = [ - "workspace", - "project", - "created_by", - "created_at", - "updated_by", - "updated_at", - ] - - -class IssueAssigneeSerializer(BaseSerializer): - assignee_details = UserLiteSerializer(read_only=True, source="assignee") - - class Meta: - model = IssueAssignee - fields = "__all__" - - -class CycleBaseSerializer(BaseSerializer): - class Meta: - model = Cycle - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueCycleDetailSerializer(BaseSerializer): - cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") - - class Meta: - model = CycleIssue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class ModuleBaseSerializer(BaseSerializer): - class Meta: - model = Module - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueModuleDetailSerializer(BaseSerializer): - module_detail = ModuleBaseSerializer(read_only=True, source="module") - - class Meta: - model = ModuleIssue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - - class Meta: - model = IssueLink - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - "issue", - ] - - def to_internal_value(self, data): - # Modify the URL before validation by appending http:// if missing - url = data.get("url", "") - if url and not url.startswith(("http://", "https://")): - data["url"] = "http://" + url - - return super().to_internal_value(data) - - def validate_url(self, value): - # Use Django's built-in URLValidator for validation - url_validator = URLValidator() - try: - url_validator(value) - except ValidationError: - raise serializers.ValidationError({"error": "Invalid URL format."}) - - return value - - # Validation if url already exists - def create(self, validated_data): - if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) - return IssueLink.objects.create(**validated_data) - - def update(self, instance, validated_data): - if ( - IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=instance.issue_id - ) - .exclude(pk=instance.id) - .exists() - ): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) - - return super().update(instance, validated_data) - - -class IssueLinkLiteSerializer(BaseSerializer): - class Meta: - model = IssueLink - fields = [ - "id", - "issue_id", - "title", - "url", - "metadata", - "created_by_id", - "created_at", - ] - read_only_fields = fields - - -class IssueAttachmentSerializer(BaseSerializer): - asset_url = serializers.CharField(read_only=True) - - class Meta: - model = FileAsset - fields = "__all__" - read_only_fields = [ - "created_by", - "updated_by", - "created_at", - "updated_at", - "workspace", - "project", - "issue", - ] - - -class IssueAttachmentLiteSerializer(DynamicBaseSerializer): - class Meta: - model = FileAsset - fields = [ - "id", - "asset", - "attributes", - # "issue_id", - "created_by", - "updated_at", - "updated_by", - "asset_url", - ] - read_only_fields = fields - - -class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = IssueReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "issue", "actor", "deleted_at"] - - -class IssueReactionLiteSerializer(DynamicBaseSerializer): - class Meta: - model = IssueReaction - fields = ["id", "actor", "issue", "reaction"] - - -class CommentReactionSerializer(BaseSerializer): - class Meta: - model = CommentReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"] - - -class IssueVoteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] - read_only_fields = fields - - -class IssueCommentSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - issue_detail = IssueFlatSerializer(read_only=True, source="issue") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionSerializer(read_only=True, many=True) - is_member = serializers.BooleanField(read_only=True) - - class Meta: - model = IssueComment - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueStateFlatSerializer(BaseSerializer): - state_detail = StateLiteSerializer(read_only=True, source="state") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - - class Meta: - model = Issue - fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] - - -# Issue Serializer with state details -class IssueStateSerializer(DynamicBaseSerializer): - label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) - state_detail = StateLiteSerializer(read_only=True, source="state") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Issue - fields = "__all__" - - -class IssueIntakeSerializer(DynamicBaseSerializer): - label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - class Meta: - model = Issue - fields = [ - "id", - "name", - "priority", - "sequence_id", - "project_id", - "created_at", - "label_ids", - "created_by", - ] - read_only_fields = fields - - -class IssueSerializer(DynamicBaseSerializer): - # ids - cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - # Many to many - label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - # Count items - sub_issues_count = serializers.IntegerField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Issue - fields = [ - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ] - read_only_fields = fields - - -class IssueListDetailSerializer(serializers.Serializer): - - def __init__(self, *args, **kwargs): - # Extract expand parameter and store it as instance variable - self.expand = kwargs.pop("expand", []) or [] - # Extract fields parameter and store it as instance variable - self.fields = kwargs.pop("fields", []) or [] - super().__init__(*args, **kwargs) - - def get_module_ids(self, obj): - return [module.module_id for module in obj.issue_module.all()] - - def get_label_ids(self, obj): - return [label.label_id for label in obj.label_issue.all()] - - def get_assignee_ids(self, obj): - return [assignee.assignee_id for assignee in obj.issue_assignee.all()] - - def to_representation(self, instance): - data = { - # Basic fields - "id": instance.id, - "name": instance.name, - "state_id": instance.state_id, - "sort_order": instance.sort_order, - "completed_at": instance.completed_at, - "estimate_point": instance.estimate_point_id, - "priority": instance.priority, - "start_date": instance.start_date, - "target_date": instance.target_date, - "sequence_id": instance.sequence_id, - "project_id": instance.project_id, - "parent_id": instance.parent_id, - "created_at": instance.created_at, - "updated_at": instance.updated_at, - "created_by": instance.created_by_id, - "updated_by": instance.updated_by_id, - "is_draft": instance.is_draft, - "archived_at": instance.archived_at, - # Computed fields - "cycle_id": instance.cycle_id, - "module_ids": self.get_module_ids(instance), - "label_ids": self.get_label_ids(instance), - "assignee_ids": self.get_assignee_ids(instance), - "sub_issues_count": instance.sub_issues_count, - "attachment_count": instance.attachment_count, - "link_count": instance.link_count, - } - - # Handle expanded fields only when requested - using direct field access - if self.expand: - if "issue_relation" in self.expand: - relations = [] - for relation in instance.issue_relation.all(): - related_issue = relation.related_issue - # If the related issue is deleted, skip it - if not related_issue: - continue - # Add the related issue to the relations list - relations.append( - { - "id": related_issue.id, - "project_id": related_issue.project_id, - "sequence_id": related_issue.sequence_id, - "name": related_issue.name, - "relation_type": relation.relation_type, - "state_id": related_issue.state_id, - "priority": related_issue.priority, - "created_by": related_issue.created_by_id, - "created_at": related_issue.created_at, - "updated_at": related_issue.updated_at, - "updated_by": related_issue.updated_by_id, - } - ) - data["issue_relation"] = relations - - if "issue_related" in self.expand: - related = [] - for relation in instance.issue_related.all(): - issue = relation.issue - # If the related issue is deleted, skip it - if not issue: - continue - # Add the related issue to the related list - related.append( - { - "id": issue.id, - "project_id": issue.project_id, - "sequence_id": issue.sequence_id, - "name": issue.name, - "relation_type": relation.relation_type, - "state_id": issue.state_id, - "priority": issue.priority, - "created_by": issue.created_by_id, - "created_at": issue.created_at, - "updated_at": issue.updated_at, - "updated_by": issue.updated_by_id, - } - ) - data["issue_related"] = related - - return data - - -class IssueLiteSerializer(DynamicBaseSerializer): - class Meta: - model = Issue - fields = ["id", "sequence_id", "project_id"] - read_only_fields = fields - - -class IssueDetailSerializer(IssueSerializer): - description_html = serializers.CharField() - is_subscribed = serializers.BooleanField(read_only=True) - - class Meta(IssueSerializer.Meta): - fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"] - read_only_fields = fields - - -class IssuePublicSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") - reactions = IssueReactionSerializer( - read_only=True, many=True, source="issue_reactions" - ) - votes = IssueVoteSerializer(read_only=True, many=True) - - class Meta: - model = Issue - fields = [ - "id", - "name", - "description_html", - "sequence_id", - "state", - "state_detail", - "project", - "project_detail", - "workspace", - "priority", - "target_date", - "reactions", - "votes", - ] - read_only_fields = fields - - -class IssueSubscriberSerializer(BaseSerializer): - class Meta: - model = IssueSubscriber - fields = "__all__" - read_only_fields = ["workspace", "project", "issue"] - - -class IssueVersionDetailSerializer(BaseSerializer): - class Meta: - model = IssueVersion - fields = [ - "id", - "workspace", - "project", - "issue", - "parent", - "state", - "estimate_point", - "name", - "priority", - "start_date", - "target_date", - "assignees", - "sequence_id", - "labels", - "sort_order", - "completed_at", - "archived_at", - "is_draft", - "external_source", - "external_id", - "type", - "cycle", - "modules", - "meta", - "name", - "last_saved_at", - "owned_by", - "created_at", - "updated_at", - "created_by", - "updated_by", - ] - read_only_fields = ["workspace", "project", "issue"] - - -class IssueDescriptionVersionDetailSerializer(BaseSerializer): - class Meta: - model = IssueDescriptionVersion - fields = [ - "id", - "workspace", - "project", - "issue", - "description_binary", - "description_html", - "description_stripped", - "description_json", - "last_saved_at", - "owned_by", - "created_at", - "updated_at", - "created_by", - "updated_by", - ] - read_only_fields = ["workspace", "project", "issue"] diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py deleted file mode 100644 index 1fd2f4d3c84..00000000000 --- a/apiserver/plane/app/serializers/page.py +++ /dev/null @@ -1,188 +0,0 @@ -# Third party imports -from rest_framework import serializers - -# Module imports -from .base import BaseSerializer -from plane.db.models import ( - Page, - PageLog, - PageLabel, - Label, - ProjectPage, - Project, - PageVersion, -) - - -class PageSerializer(BaseSerializer): - is_favorite = serializers.BooleanField(read_only=True) - labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), - write_only=True, - required=False, - ) - # Many to many - label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - project_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - class Meta: - model = Page - fields = [ - "id", - "name", - "owned_by", - "access", - "color", - "labels", - "parent", - "is_favorite", - "is_locked", - "archived_at", - "workspace", - "created_at", - "updated_at", - "created_by", - "updated_by", - "view_props", - "logo_props", - "label_ids", - "project_ids", - ] - read_only_fields = ["workspace", "owned_by"] - - def create(self, validated_data): - labels = validated_data.pop("labels", None) - project_id = self.context["project_id"] - owned_by_id = self.context["owned_by_id"] - description = self.context["description"] - description_binary = self.context["description_binary"] - description_html = self.context["description_html"] - - # Get the workspace id from the project - project = Project.objects.get(pk=project_id) - - # Create the page - page = Page.objects.create( - **validated_data, - description=description, - description_binary=description_binary, - description_html=description_html, - owned_by_id=owned_by_id, - workspace_id=project.workspace_id, - ) - - # Create the project page - ProjectPage.objects.create( - workspace_id=page.workspace_id, - project_id=project_id, - page_id=page.id, - created_by_id=page.created_by_id, - updated_by_id=page.updated_by_id, - ) - - # Create page labels - if labels is not None: - PageLabel.objects.bulk_create( - [ - PageLabel( - label=label, - page=page, - workspace_id=page.workspace_id, - created_by_id=page.created_by_id, - updated_by_id=page.updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - return page - - def update(self, instance, validated_data): - labels = validated_data.pop("labels", None) - if labels is not None: - PageLabel.objects.filter(page=instance).delete() - PageLabel.objects.bulk_create( - [ - PageLabel( - label=label, - page=instance, - workspace_id=instance.workspace_id, - created_by_id=instance.created_by_id, - updated_by_id=instance.updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - - return super().update(instance, validated_data) - - -class PageDetailSerializer(PageSerializer): - description_html = serializers.CharField() - - class Meta(PageSerializer.Meta): - fields = PageSerializer.Meta.fields + ["description_html"] - - -class SubPageSerializer(BaseSerializer): - entity_details = serializers.SerializerMethodField() - - class Meta: - model = PageLog - fields = "__all__" - read_only_fields = ["workspace", "page"] - - def get_entity_details(self, obj): - entity_name = obj.entity_name - if entity_name == "forward_link" or entity_name == "back_link": - try: - page = Page.objects.get(pk=obj.entity_identifier) - return PageSerializer(page).data - except Page.DoesNotExist: - return None - return None - - -class PageLogSerializer(BaseSerializer): - class Meta: - model = PageLog - fields = "__all__" - read_only_fields = ["workspace", "page"] - - -class PageVersionSerializer(BaseSerializer): - class Meta: - model = PageVersion - fields = [ - "id", - "workspace", - "page", - "last_saved_at", - "owned_by", - "created_at", - "updated_at", - "created_by", - "updated_by", - ] - read_only_fields = ["workspace", "page"] - - -class PageVersionDetailSerializer(BaseSerializer): - class Meta: - model = PageVersion - fields = [ - "id", - "workspace", - "page", - "last_saved_at", - "description_binary", - "description_html", - "description_json", - "owned_by", - "created_at", - "updated_at", - "created_by", - "updated_by", - ] - read_only_fields = ["workspace", "page"] diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py deleted file mode 100644 index 8d521e8e834..00000000000 --- a/apiserver/plane/app/serializers/project.py +++ /dev/null @@ -1,199 +0,0 @@ -# Third party imports -from rest_framework import serializers - -# Module imports -from .base import BaseSerializer, DynamicBaseSerializer -from plane.app.serializers.workspace import WorkspaceLiteSerializer -from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer -from plane.db.models import ( - Project, - ProjectMember, - ProjectMemberInvite, - ProjectIdentifier, - DeployBoard, - ProjectPublicMember, -) - - -class ProjectSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - inbox_view = serializers.BooleanField(read_only=True, source="intake_view") - - class Meta: - model = Project - fields = "__all__" - read_only_fields = ["workspace", "deleted_at"] - - def create(self, validated_data): - identifier = validated_data.get("identifier", "").strip().upper() - if identifier == "": - raise serializers.ValidationError(detail="Project Identifier is required") - - if ProjectIdentifier.objects.filter( - name=identifier, workspace_id=self.context["workspace_id"] - ).exists(): - raise serializers.ValidationError(detail="Project Identifier is taken") - project = Project.objects.create( - **validated_data, workspace_id=self.context["workspace_id"] - ) - _ = ProjectIdentifier.objects.create( - name=project.identifier, - project=project, - workspace_id=self.context["workspace_id"], - ) - return project - - def update(self, instance, validated_data): - identifier = validated_data.get("identifier", "").strip().upper() - - # If identifier is not passed update the project and return - if identifier == "": - project = super().update(instance, validated_data) - return project - - # If no Project Identifier is found create it - project_identifier = ProjectIdentifier.objects.filter( - name=identifier, workspace_id=instance.workspace_id - ).first() - if project_identifier is None: - project = super().update(instance, validated_data) - project_identifier = ProjectIdentifier.objects.filter( - project=project - ).first() - if project_identifier is not None: - project_identifier.name = identifier - project_identifier.save() - return project - # If found check if the project_id to be updated and identifier project id is same - if project_identifier.project_id == instance.id: - # If same pass update - project = super().update(instance, validated_data) - return project - - # If not same fail update - raise serializers.ValidationError(detail="Project Identifier is already taken") - - -class ProjectLiteSerializer(BaseSerializer): - class Meta: - model = Project - fields = [ - "id", - "identifier", - "name", - "cover_image", - "cover_image_url", - "logo_props", - "description", - ] - read_only_fields = fields - - -class ProjectListSerializer(DynamicBaseSerializer): - is_favorite = serializers.BooleanField(read_only=True) - sort_order = serializers.FloatField(read_only=True) - member_role = serializers.IntegerField(read_only=True) - anchor = serializers.CharField(read_only=True) - members = serializers.SerializerMethodField() - cover_image_url = serializers.CharField(read_only=True) - inbox_view = serializers.BooleanField(read_only=True, source="intake_view") - - def get_members(self, obj): - project_members = getattr(obj, "members_list", None) - if project_members is not None: - # Filter members by the project ID - return [ - member.member_id - for member in project_members - if member.is_active and not member.member.is_bot - ] - return [] - - class Meta: - model = Project - fields = "__all__" - - -class ProjectDetailSerializer(BaseSerializer): - # workspace = WorkSpaceSerializer(read_only=True) - default_assignee = UserLiteSerializer(read_only=True) - project_lead = UserLiteSerializer(read_only=True) - is_favorite = serializers.BooleanField(read_only=True) - sort_order = serializers.FloatField(read_only=True) - member_role = serializers.IntegerField(read_only=True) - anchor = serializers.CharField(read_only=True) - - class Meta: - model = Project - fields = "__all__" - - -class ProjectMemberSerializer(BaseSerializer): - workspace = WorkspaceLiteSerializer(read_only=True) - project = ProjectLiteSerializer(read_only=True) - member = UserLiteSerializer(read_only=True) - - class Meta: - model = ProjectMember - fields = "__all__" - - -class ProjectMemberAdminSerializer(BaseSerializer): - workspace = WorkspaceLiteSerializer(read_only=True) - project = ProjectLiteSerializer(read_only=True) - member = UserAdminLiteSerializer(read_only=True) - - class Meta: - model = ProjectMember - fields = "__all__" - - -class ProjectMemberRoleSerializer(DynamicBaseSerializer): - original_role = serializers.IntegerField(source='role', read_only=True) - - class Meta: - model = ProjectMember - fields = ("id", "role", "member", "project", "original_role", "created_at") - read_only_fields = ["original_role", "created_at"] - - -class ProjectMemberInviteSerializer(BaseSerializer): - project = ProjectLiteSerializer(read_only=True) - workspace = WorkspaceLiteSerializer(read_only=True) - - class Meta: - model = ProjectMemberInvite - fields = "__all__" - - -class ProjectIdentifierSerializer(BaseSerializer): - class Meta: - model = ProjectIdentifier - fields = "__all__" - - -class ProjectMemberLiteSerializer(BaseSerializer): - member = UserLiteSerializer(read_only=True) - is_subscribed = serializers.BooleanField(read_only=True) - - class Meta: - model = ProjectMember - fields = ["member", "id", "is_subscribed"] - read_only_fields = fields - - -class DeployBoardSerializer(BaseSerializer): - project_details = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - - class Meta: - model = DeployBoard - fields = "__all__" - read_only_fields = ["workspace", "project", "anchor"] - - -class ProjectPublicMemberSerializer(BaseSerializer): - class Meta: - model = ProjectPublicMember - fields = "__all__" - read_only_fields = ["workspace", "project", "member"] diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py deleted file mode 100644 index c5a3d35df0c..00000000000 --- a/apiserver/plane/app/serializers/user.py +++ /dev/null @@ -1,225 +0,0 @@ -# Third party imports -from rest_framework import serializers - -# Module import -from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite -from plane.utils.url import contains_url - -from .base import BaseSerializer - - -class UserSerializer(BaseSerializer): - def validate_first_name(self, value): - if contains_url(value): - raise serializers.ValidationError("First name cannot contain a URL.") - return value - - def validate_last_name(self, value): - if contains_url(value): - raise serializers.ValidationError("Last name cannot contain a URL.") - return value - - class Meta: - model = User - # Exclude password field from the serializer - fields = [field.name for field in User._meta.fields if field.name != "password"] - # Make all system fields and email read only - read_only_fields = [ - "id", - "username", - "mobile_number", - "email", - "token", - "created_at", - "updated_at", - "is_superuser", - "is_staff", - "is_managed", - "last_active", - "last_login_time", - "last_logout_time", - "last_login_ip", - "last_logout_ip", - "last_login_uagent", - "last_location", - "last_login_medium", - "created_location", - "is_bot", - "is_password_autoset", - "is_email_verified", - "is_active", - "token_updated_at", - ] - - # If the user has already filled first name or last name then he is onboarded - def get_is_onboarded(self, obj): - return bool(obj.first_name) or bool(obj.last_name) - - -class UserMeSerializer(BaseSerializer): - class Meta: - model = User - fields = [ - "id", - "avatar", - "cover_image", - "avatar_url", - "cover_image_url", - "date_joined", - "display_name", - "email", - "first_name", - "last_name", - "is_active", - "is_bot", - "is_email_verified", - "user_timezone", - "username", - "is_password_autoset", - "is_email_verified", - "last_login_medium", - ] - read_only_fields = fields - - -class UserMeSettingsSerializer(BaseSerializer): - workspace = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ["id", "email", "workspace"] - read_only_fields = fields - - def get_workspace(self, obj): - workspace_invites = WorkspaceMemberInvite.objects.filter( - email=obj.email - ).count() - - # profile - profile = Profile.objects.get(user=obj) - if ( - profile.last_workspace_id is not None - and Workspace.objects.filter( - pk=profile.last_workspace_id, - workspace_member__member=obj.id, - workspace_member__is_active=True, - ).exists() - ): - workspace = Workspace.objects.filter( - pk=profile.last_workspace_id, - workspace_member__member=obj.id, - workspace_member__is_active=True, - ).first() - logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else "" - return { - "last_workspace_id": profile.last_workspace_id, - "last_workspace_slug": ( - workspace.slug if workspace is not None else "" - ), - "last_workspace_name": ( - workspace.name if workspace is not None else "" - ), - "last_workspace_logo": (logo_asset_url), - "fallback_workspace_id": profile.last_workspace_id, - "fallback_workspace_slug": ( - workspace.slug if workspace is not None else "" - ), - "invites": workspace_invites, - } - else: - fallback_workspace = ( - Workspace.objects.filter( - workspace_member__member_id=obj.id, workspace_member__is_active=True - ) - .order_by("created_at") - .first() - ) - return { - "last_workspace_id": None, - "last_workspace_slug": None, - "fallback_workspace_id": ( - fallback_workspace.id if fallback_workspace is not None else None - ), - "fallback_workspace_slug": ( - fallback_workspace.slug if fallback_workspace is not None else None - ), - "invites": workspace_invites, - } - - -class UserLiteSerializer(BaseSerializer): - class Meta: - model = User - fields = [ - "id", - "first_name", - "last_name", - "avatar", - "avatar_url", - "is_bot", - "display_name", - ] - read_only_fields = ["id", "is_bot"] - - -class UserAdminLiteSerializer(BaseSerializer): - class Meta: - model = User - fields = [ - "id", - "first_name", - "last_name", - "avatar", - "avatar_url", - "is_bot", - "display_name", - "email", - "last_login_medium", - ] - read_only_fields = ["id", "is_bot"] - - -class ChangePasswordSerializer(serializers.Serializer): - model = User - - """ - Serializer for password change endpoint. - """ - old_password = serializers.CharField(required=True) - new_password = serializers.CharField(required=True, min_length=8) - confirm_password = serializers.CharField(required=True, min_length=8) - - def validate(self, data): - if data.get("old_password") == data.get("new_password"): - raise serializers.ValidationError( - {"error": "New password cannot be same as old password."} - ) - - if data.get("new_password") != data.get("confirm_password"): - raise serializers.ValidationError( - {"error": "Confirm password should be same as the new password."} - ) - - return data - - -class ResetPasswordSerializer(serializers.Serializer): - """ - Serializer for password change endpoint. - """ - - new_password = serializers.CharField(required=True, min_length=8) - - -class ProfileSerializer(BaseSerializer): - class Meta: - model = Profile - fields = "__all__" - read_only_fields = ["user"] - - -class AccountSerializer(BaseSerializer): - class Meta: - model = Account - fields = "__all__" - read_only_fields = ["user"] diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py deleted file mode 100644 index d2aa1a02d7b..00000000000 --- a/apiserver/plane/app/views/notification/base.py +++ /dev/null @@ -1,362 +0,0 @@ -# Django imports -from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField -from django.utils import timezone - -# Third party imports -from rest_framework import status -from rest_framework.response import Response - -from plane.app.serializers import ( - NotificationSerializer, - UserNotificationPreferenceSerializer, -) -from plane.db.models import ( - Issue, - IssueAssignee, - IssueSubscriber, - Notification, - UserNotificationPreference, - WorkspaceMember, -) -from plane.utils.paginator import BasePaginator -from plane.app.permissions import allow_permission, ROLE - -# Module imports -from ..base import BaseAPIView, BaseViewSet - - -class NotificationViewSet(BaseViewSet, BasePaginator): - model = Notification - serializer_class = NotificationSerializer - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - receiver_id=self.request.user.id, - ) - .select_related("workspace", "project," "triggered_by", "receiver") - ) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def list(self, request, slug): - # Get query parameters - snoozed = request.GET.get("snoozed", "false") - archived = request.GET.get("archived", "false") - read = request.GET.get("read", None) - type = request.GET.get("type", "all") - mentioned = request.GET.get("mentioned", False) - q_filters = Q() - - intake_issue = Issue.objects.filter( - pk=OuterRef("entity_identifier"), - issue_intake__status__in=[0, 2, -2], - workspace__slug=self.kwargs.get("slug"), - ) - - notifications = ( - Notification.objects.filter( - workspace__slug=slug, receiver_id=request.user.id - ) - .filter(entity_name="issue") - .annotate(is_inbox_issue=Exists(intake_issue)) - .annotate(is_intake_issue=Exists(intake_issue)) - .annotate( - is_mentioned_notification=Case( - When(sender__icontains="mentioned", then=True), - default=False, - output_field=BooleanField(), - ) - ) - .select_related("workspace", "project", "triggered_by", "receiver") - .order_by("snoozed_till", "-created_at") - ) - - # Filters based on query parameters - snoozed_filters = { - "true": Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False), - "false": Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), - } - - notifications = notifications.filter(snoozed_filters[snoozed]) - - archived_filters = { - "true": Q(archived_at__isnull=False), - "false": Q(archived_at__isnull=True), - } - - notifications = notifications.filter(archived_filters[archived]) - - if read == "false": - notifications = notifications.filter(read_at__isnull=True) - - if read == "true": - notifications = notifications.filter(read_at__isnull=False) - - if mentioned: - notifications = notifications.filter(sender__icontains="mentioned") - else: - notifications = notifications.exclude(sender__icontains="mentioned") - - type = type.split(",") - # Subscribed issues - if "subscribed" in type: - issue_ids = ( - IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ) - .annotate( - created=Exists( - Issue.objects.filter( - created_by=request.user, pk=OuterRef("issue_id") - ) - ) - ) - .annotate( - assigned=Exists( - IssueAssignee.objects.filter( - pk=OuterRef("issue_id"), assignee=request.user - ) - ) - ) - .filter(created=False, assigned=False) - .values_list("issue_id", flat=True) - ) - q_filters |= Q(entity_identifier__in=issue_ids) - - # Assigned Issues - if "assigned" in type: - issue_ids = IssueAssignee.objects.filter( - workspace__slug=slug, assignee_id=request.user.id - ).values_list("issue_id", flat=True) - q_filters |= Q(entity_identifier__in=issue_ids) - - # Created issues - if "created" in type: - if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role__lt=15, is_active=True - ).exists(): - notifications = notifications.none() - else: - issue_ids = Issue.objects.filter( - workspace__slug=slug, created_by=request.user - ).values_list("pk", flat=True) - q_filters |= Q(entity_identifier__in=issue_ids) - - # Apply the combined Q object filters - notifications = notifications.filter(q_filters) - - # Pagination - if request.GET.get("per_page", False) and request.GET.get("cursor", False): - return self.paginate( - order_by=request.GET.get("order_by", "-created_at"), - request=request, - queryset=(notifications), - on_results=lambda notifications: NotificationSerializer( - notifications, many=True - ).data, - ) - - serializer = NotificationSerializer(notifications, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def partial_update(self, request, slug, pk): - notification = Notification.objects.get( - workspace__slug=slug, pk=pk, receiver=request.user - ) - # Only read_at and snoozed_till can be updated - notification_data = {"snoozed_till": request.data.get("snoozed_till", None)} - serializer = NotificationSerializer( - notification, data=notification_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) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def mark_read(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) - notification.read_at = timezone.now() - notification.save() - serializer = NotificationSerializer(notification) - return Response(serializer.data, status=status.HTTP_200_OK) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def mark_unread(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) - notification.read_at = None - notification.save() - serializer = NotificationSerializer(notification) - return Response(serializer.data, status=status.HTTP_200_OK) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def archive(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) - notification.archived_at = timezone.now() - notification.save() - serializer = NotificationSerializer(notification) - return Response(serializer.data, status=status.HTTP_200_OK) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def unarchive(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) - notification.archived_at = None - notification.save() - serializer = NotificationSerializer(notification) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class UnreadNotificationEndpoint(BaseAPIView): - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def get(self, request, slug): - # Watching Issues Count - unread_notifications_count = ( - Notification.objects.filter( - workspace__slug=slug, - receiver_id=request.user.id, - read_at__isnull=True, - archived_at__isnull=True, - snoozed_till__isnull=True, - ) - .exclude(sender__icontains="mentioned") - .count() - ) - - mention_notifications_count = Notification.objects.filter( - workspace__slug=slug, - receiver_id=request.user.id, - read_at__isnull=True, - archived_at__isnull=True, - snoozed_till__isnull=True, - sender__icontains="mentioned", - ).count() - - return Response( - { - "total_unread_notifications_count": int(unread_notifications_count), - "mention_unread_notifications_count": int(mention_notifications_count), - }, - status=status.HTTP_200_OK, - ) - - -class MarkAllReadNotificationViewSet(BaseViewSet): - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def create(self, request, slug): - snoozed = request.data.get("snoozed", False) - archived = request.data.get("archived", False) - type = request.data.get("type", "all") - - notifications = ( - Notification.objects.filter( - workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True - ) - .select_related("workspace", "project", "triggered_by", "receiver") - .order_by("snoozed_till", "-created_at") - ) - - # Filter for snoozed notifications - if snoozed: - notifications = notifications.filter( - Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) - ) - else: - notifications = notifications.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True) - ) - - # Filter for archived or unarchive - if archived: - notifications = notifications.filter(archived_at__isnull=False) - else: - notifications = notifications.filter(archived_at__isnull=True) - - # Subscribed issues - if type == "watching": - issue_ids = IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) - - # Assigned Issues - if type == "assigned": - issue_ids = IssueAssignee.objects.filter( - workspace__slug=slug, assignee_id=request.user.id - ).values_list("issue_id", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) - - # Created issues - if type == "created": - if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role__lt=15, is_active=True - ).exists(): - notifications = Notification.objects.none() - else: - issue_ids = Issue.objects.filter( - workspace__slug=slug, created_by=request.user - ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) - - updated_notifications = [] - for notification in notifications: - notification.read_at = timezone.now() - updated_notifications.append(notification) - Notification.objects.bulk_update( - updated_notifications, ["read_at"], batch_size=100 - ) - return Response({"message": "Successful"}, status=status.HTTP_200_OK) - - -class UserNotificationPreferenceEndpoint(BaseAPIView): - model = UserNotificationPreference - serializer_class = UserNotificationPreferenceSerializer - - # request the object - def get(self, request): - user_notification_preference = UserNotificationPreference.objects.get( - user=request.user - ) - serializer = UserNotificationPreferenceSerializer(user_notification_preference) - return Response(serializer.data, status=status.HTTP_200_OK) - - # update the object - def patch(self, request): - user_notification_preference = UserNotificationPreference.objects.get( - user=request.user - ) - serializer = UserNotificationPreferenceSerializer( - user_notification_preference, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py deleted file mode 100644 index 26e9223b820..00000000000 --- a/apiserver/plane/app/views/page/base.py +++ /dev/null @@ -1,630 +0,0 @@ -# Python imports -import json -import base64 -from datetime import datetime -from django.core.serializers.json import DjangoJSONEncoder - -# Django imports -from django.db import connection -from django.db.models import Exists, OuterRef, Q, Value, UUIDField -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page -from django.http import StreamingHttpResponse -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce - -# Third party imports -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from plane.app.permissions import allow_permission, ROLE -from plane.app.serializers import ( - PageLogSerializer, - PageSerializer, - SubPageSerializer, - PageDetailSerializer, -) -from plane.db.models import ( - Page, - PageLog, - UserFavorite, - ProjectMember, - ProjectPage, - Project, - UserRecentVisit, -) -from plane.utils.error_codes import ERROR_CODES -from ..base import BaseAPIView, BaseViewSet -from plane.bgtasks.page_transaction_task import page_transaction -from plane.bgtasks.page_version_task import page_version -from plane.bgtasks.recent_visited_task import recent_visited_task -from plane.bgtasks.copy_s3_object import copy_s3_objects - - -def unarchive_archive_page_and_descendants(page_id, archived_at): - # Your SQL query - sql = """ - WITH RECURSIVE descendants AS ( - SELECT id FROM pages WHERE id = %s - UNION ALL - SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id - ) - UPDATE pages SET archived_at = %s WHERE id IN (SELECT id FROM descendants); - """ - - # Execute the SQL query - with connection.cursor() as cursor: - cursor.execute(sql, [page_id, archived_at]) - - -class PageViewSet(BaseViewSet): - serializer_class = PageSerializer - model = Page - search_fields = ["name"] - - def get_queryset(self): - subquery = UserFavorite.objects.filter( - user=self.request.user, - entity_type="page", - entity_identifier=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ) - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter( - projects__project_projectmember__member=self.request.user, - projects__project_projectmember__is_active=True, - projects__archived_at__isnull=True, - ) - .filter(parent__isnull=True) - .filter(Q(owned_by=self.request.user) | Q(access=0)) - .prefetch_related("projects") - .select_related("workspace") - .select_related("owned_by") - .annotate(is_favorite=Exists(subquery)) - .order_by(self.request.GET.get("order_by", "-created_at")) - .prefetch_related("labels") - .order_by("-is_favorite", "-created_at") - .annotate( - project=Exists( - ProjectPage.objects.filter( - page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") - ) - ) - ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "page_labels__label_id", - distinct=True, - filter=~Q(page_labels__label_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - project_ids=Coalesce( - ArrayAgg( - "projects__id", distinct=True, filter=~Q(projects__id=True) - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) - .filter(project=True) - .distinct() - ) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def create(self, request, slug, project_id): - serializer = PageSerializer( - data=request.data, - context={ - "project_id": project_id, - "owned_by_id": request.user.id, - "description": request.data.get("description", {}), - "description_binary": request.data.get("description_binary", None), - "description_html": request.data.get("description_html", "

"), - }, - ) - - if serializer.is_valid(): - serializer.save() - # capture the page transaction - page_transaction.delay(request.data, None, serializer.data["id"]) - page = self.get_queryset().get(pk=serializer.data["id"]) - serializer = PageDetailSerializer(page) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def partial_update(self, request, slug, project_id, pk): - try: - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id - ) - - if page.is_locked: - return Response( - {"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST - ) - - parent = request.data.get("parent", None) - if parent: - _ = Page.objects.get( - pk=parent, workspace__slug=slug, projects__id=project_id - ) - - # Only update access if the page owner is the requesting user - if ( - page.access != request.data.get("access", page.access) - and page.owned_by_id != request.user.id - ): - return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = PageDetailSerializer(page, data=request.data, partial=True) - page_description = page.description_html - if serializer.is_valid(): - serializer.save() - # capture the page transaction - if request.data.get("description_html"): - page_transaction.delay( - new_value=request.data, - old_value=json.dumps( - {"description_html": page_description}, - cls=DjangoJSONEncoder, - ), - page_id=pk, - ) - - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except Page.DoesNotExist: - return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def retrieve(self, request, slug, project_id, pk=None): - page = self.get_queryset().filter(pk=pk).first() - project = Project.objects.get(pk=project_id) - - """ - if the role is guest and guest_view_all_features is false and owned by is not - the requesting user then dont show the page - """ - - if ( - ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - member=request.user, - role=5, - is_active=True, - ).exists() - and not project.guest_view_all_features - and not page.owned_by == request.user - ): - return Response( - {"error": "You are not allowed to view this page"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if page is None: - return Response( - {"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND - ) - else: - issue_ids = PageLog.objects.filter( - page_id=pk, entity_name="issue" - ).values_list("entity_identifier", flat=True) - data = PageDetailSerializer(page).data - data["issue_ids"] = issue_ids - recent_visited_task.delay( - slug=slug, - entity_name="page", - entity_identifier=pk, - user_id=request.user.id, - project_id=project_id, - ) - return Response(data, status=status.HTTP_200_OK) - - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def lock(self, request, slug, project_id, pk): - page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id - ).first() - - page.is_locked = True - page.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def unlock(self, request, slug, project_id, pk): - page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id - ).first() - - page.is_locked = False - page.save() - - return Response(status=status.HTTP_204_NO_CONTENT) - - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def access(self, request, slug, project_id, pk): - access = request.data.get("access", 0) - page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id - ).first() - - # Only update access if the page owner is the requesting user - if ( - page.access != request.data.get("access", page.access) - and page.owned_by_id != request.user.id - ): - return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - page.access = access - page.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def list(self, request, slug, project_id): - queryset = self.get_queryset() - project = Project.objects.get(pk=project_id) - if ( - ProjectMember.objects.filter( - workspace__slug=slug, - project_id=project_id, - member=request.user, - role=5, - is_active=True, - ).exists() - and not project.guest_view_all_features - ): - queryset = queryset.filter(owned_by=request.user) - pages = PageSerializer(queryset, many=True).data - return Response(pages, status=status.HTTP_200_OK) - - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def archive(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) - - # only the owner or admin can archive the page - if ( - ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__lte=15 - ).exists() - and request.user.id != page.owned_by_id - ): - return Response( - {"error": "Only the owner or admin can archive the page"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - UserFavorite.objects.filter( - entity_type="page", - entity_identifier=pk, - project_id=project_id, - workspace__slug=slug, - ).delete() - - unarchive_archive_page_and_descendants(pk, datetime.now()) - - return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) - - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def unarchive(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) - - # only the owner or admin can un archive the page - if ( - ProjectMember.objects.filter( - project_id=project_id, member=request.user, is_active=True, role__lte=15 - ).exists() - and request.user.id != page.owned_by_id - ): - return Response( - {"error": "Only the owner or admin can un archive the page"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # if parent page is archived then the page will be un archived breaking the hierarchy - if page.parent_id and page.parent.archived_at: - page.parent = None - page.save(update_fields=["parent"]) - - unarchive_archive_page_and_descendants(pk, None) - - return Response(status=status.HTTP_204_NO_CONTENT) - - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def destroy(self, request, slug, project_id, pk): - page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) - - if page.archived_at is None: - return Response( - {"error": "The page should be archived before deleting"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if page.owned_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or owner can delete the page"}, - status=status.HTTP_403_FORBIDDEN, - ) - - # remove parent from all the children - _ = Page.objects.filter( - parent_id=pk, projects__id=project_id, workspace__slug=slug - ).update(parent=None) - - page.delete() - # Delete the user favorite page - UserFavorite.objects.filter( - project=project_id, - workspace__slug=slug, - entity_identifier=pk, - entity_type="page", - ).delete() - # Delete the page from recent visit - UserRecentVisit.objects.filter( - project_id=project_id, - workspace__slug=slug, - entity_identifier=pk, - entity_name="page", - ).delete(soft=False) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class PageFavoriteViewSet(BaseViewSet): - model = UserFavorite - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def create(self, request, slug, project_id, pk): - _ = UserFavorite.objects.create( - project_id=project_id, - entity_identifier=pk, - entity_type="page", - user=request.user, - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def destroy(self, request, slug, project_id, pk): - page_favorite = UserFavorite.objects.get( - project=project_id, - user=request.user, - workspace__slug=slug, - entity_identifier=pk, - entity_type="page", - ) - page_favorite.delete(soft=False) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class PageLogEndpoint(BaseAPIView): - serializer_class = PageLogSerializer - model = PageLog - - def post(self, request, slug, project_id, page_id): - serializer = PageLogSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, page_id=page_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, slug, project_id, page_id, transaction): - page_transaction = PageLog.objects.get( - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - transaction=transaction, - ) - serializer = PageLogSerializer( - page_transaction, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, page_id, transaction): - transaction = PageLog.objects.get( - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - transaction=transaction, - ) - # Delete the transaction object - transaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class SubPagesEndpoint(BaseAPIView): - @method_decorator(gzip_page) - def get(self, request, slug, project_id, page_id): - pages = ( - PageLog.objects.filter( - page_id=page_id, - workspace__slug=slug, - entity_name__in=["forward_link", "back_link"], - ) - .select_related("project") - .select_related("workspace") - ) - return Response( - SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) - - -class PagesDescriptionViewSet(BaseViewSet): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def retrieve(self, request, slug, project_id, pk): - page = ( - Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) - .filter(Q(owned_by=self.request.user) | Q(access=0)) - .first() - ) - if page is None: - return Response({"error": "Page not found"}, status=404) - binary_data = page.description_binary - - def stream_data(): - if binary_data: - yield binary_data - else: - yield b"" - - response = StreamingHttpResponse( - stream_data(), content_type="application/octet-stream" - ) - response["Content-Disposition"] = 'attachment; filename="page_description.bin"' - return response - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def partial_update(self, request, slug, project_id, pk): - page = ( - Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) - .filter(Q(owned_by=self.request.user) | Q(access=0)) - .first() - ) - - if page is None: - return Response({"error": "Page not found"}, status=404) - - if page.is_locked: - return Response( - { - "error_code": ERROR_CODES["PAGE_LOCKED"], - "error_message": "PAGE_LOCKED", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - if page.archived_at: - return Response( - { - "error_code": ERROR_CODES["PAGE_ARCHIVED"], - "error_message": "PAGE_ARCHIVED", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Serialize the existing instance - existing_instance = json.dumps( - {"description_html": page.description_html}, cls=DjangoJSONEncoder - ) - - # Get the base64 data from the request - base64_data = request.data.get("description_binary") - - # If base64 data is provided - if base64_data: - # Decode the base64 data to bytes - new_binary_data = base64.b64decode(base64_data) - # capture the page transaction - if request.data.get("description_html"): - page_transaction.delay( - new_value=request.data, old_value=existing_instance, page_id=pk - ) - # Store the updated binary data - page.description_binary = new_binary_data - page.description_html = request.data.get("description_html") - page.description = request.data.get("description") - page.save() - # Return a success response - page_version.delay( - page_id=page.id, - existing_instance=existing_instance, - user_id=request.user.id, - ) - return Response({"message": "Updated successfully"}) - else: - return Response({"error": "No binary data provided"}) - - -class PageDuplicateEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def post(self, request, slug, project_id, page_id): - page = Page.objects.filter( - pk=page_id, workspace__slug=slug, projects__id=project_id - ).first() - - # check for permission - if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: - return Response( - {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN - ) - - # get all the project ids where page is present - project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( - "project_id", flat=True - ) - - page.pk = None - page.name = f"{page.name} (Copy)" - page.description_binary = None - page.owned_by = request.user - page.created_by = request.user - page.updated_by = request.user - page.save() - - for project_id in project_ids: - ProjectPage.objects.create( - workspace_id=page.workspace_id, - project_id=project_id, - page_id=page.id, - created_by_id=page.created_by_id, - updated_by_id=page.updated_by_id, - ) - - page_transaction.delay( - {"description_html": page.description_html}, None, page.id - ) - - # Copy the s3 objects uploaded in the page - copy_s3_objects.delay( - entity_name="PAGE", - entity_identifier=page.id, - project_id=project_id, - slug=slug, - user_id=request.user.id, - ) - - page = ( - Page.objects.filter(pk=page.id) - .annotate( - project_ids=Coalesce( - ArrayAgg( - "projects__id", distinct=True, filter=~Q(projects__id=True) - ), - Value([], output_field=ArrayField(UUIDField())), - ) - ) - .first() - ) - serializer = PageDetailSerializer(page) - return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py deleted file mode 100644 index 2728bf4de44..00000000000 --- a/apiserver/plane/app/views/project/base.py +++ /dev/null @@ -1,689 +0,0 @@ -# Python imports -import boto3 -from django.conf import settings -from django.utils import timezone -import json - -# Django imports -from django.db import IntegrityError -from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery -from django.core.serializers.json import DjangoJSONEncoder - -# Third Party imports -from rest_framework.response import Response -from rest_framework import serializers, status -from rest_framework.permissions import AllowAny - -# Module imports -from plane.app.views.base import BaseViewSet, BaseAPIView -from plane.app.serializers import ( - ProjectSerializer, - ProjectListSerializer, - DeployBoardSerializer, -) - -from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE -from plane.db.models import ( - UserFavorite, - Intake, - DeployBoard, - IssueUserProperty, - Project, - ProjectIdentifier, - ProjectMember, - State, - Workspace, - WorkspaceMember, -) -from plane.utils.cache import cache_response -from plane.bgtasks.webhook_task import model_activity, webhook_activity -from plane.bgtasks.recent_visited_task import recent_visited_task -from plane.utils.exception_logger import log_exception -from plane.utils.host import base_host - - -class ProjectViewSet(BaseViewSet): - serializer_class = ProjectListSerializer - model = Project - webhook_event = "project" - - def get_queryset(self): - sort_order = ProjectMember.objects.filter( - member=self.request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ).values("sort_order") - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) - .annotate( - is_favorite=Exists( - UserFavorite.objects.filter( - user=self.request.user, - entity_identifier=OuterRef("pk"), - entity_type="project", - project_id=OuterRef("pk"), - ) - ) - ) - .annotate( - member_role=ProjectMember.objects.filter( - project_id=OuterRef("pk"), - member_id=self.request.user.id, - is_active=True, - ).values("role") - ) - .annotate( - anchor=DeployBoard.objects.filter( - entity_name="project", - entity_identifier=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ).values("anchor") - ) - .annotate(sort_order=Subquery(sort_order)) - .prefetch_related( - Prefetch( - "project_projectmember", - queryset=ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), is_active=True - ).select_related("member"), - to_attr="members_list", - ) - ) - .distinct() - ) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def list_detail(self, request, slug): - fields = [field for field in request.GET.get("fields", "").split(",") if field] - projects = self.get_queryset().order_by("sort_order", "name") - if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 - ).exists(): - projects = projects.filter( - project_projectmember__member=self.request.user, - project_projectmember__is_active=True, - ) - - if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 - ).exists(): - projects = projects.filter( - Q( - project_projectmember__member=self.request.user, - project_projectmember__is_active=True, - ) - | Q(network=2) - ) - - if request.GET.get("per_page", False) and request.GET.get("cursor", False): - return self.paginate( - order_by=request.GET.get("order_by", "-created_at"), - request=request, - queryset=(projects), - on_results=lambda projects: ProjectListSerializer( - projects, many=True - ).data, - ) - - projects = ProjectListSerializer( - projects, many=True, fields=fields if fields else None - ).data - return Response(projects, status=status.HTTP_200_OK) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def list(self, request, slug): - sort_order = ProjectMember.objects.filter( - member=self.request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ).values("sort_order") - - projects = ( - Project.objects.filter(workspace__slug=self.kwargs.get("slug")) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) - .annotate( - member_role=ProjectMember.objects.filter( - project_id=OuterRef("pk"), - member_id=self.request.user.id, - is_active=True, - ).values("role") - ) - .annotate(inbox_view=F("intake_view")) - .annotate(sort_order=Subquery(sort_order)) - .distinct() - ).values( - "id", - "name", - "identifier", - "sort_order", - "logo_props", - "member_role", - "archived_at", - "workspace", - "cycle_view", - "issue_views_view", - "module_view", - "page_view", - "inbox_view", - "guest_view_all_features", - "project_lead", - "network", - "created_at", - "updated_at", - "created_by", - "updated_by", - ) - - if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 - ).exists(): - projects = projects.filter( - project_projectmember__member=self.request.user, - project_projectmember__is_active=True, - ) - - if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 - ).exists(): - projects = projects.filter( - Q( - project_projectmember__member=self.request.user, - project_projectmember__is_active=True, - ) - | Q(network=2) - ) - return Response(projects, status=status.HTTP_200_OK) - - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) - def retrieve(self, request, slug, pk): - project = ( - self.get_queryset() - .filter( - project_projectmember__member=self.request.user, - project_projectmember__is_active=True, - ) - .filter(archived_at__isnull=True) - .filter(pk=pk) - ).first() - - if project is None: - return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND - ) - - recent_visited_task.delay( - slug=slug, - project_id=pk, - entity_name="project", - entity_identifier=pk, - user_id=request.user.id, - ) - - serializer = ProjectListSerializer(project) - return Response(serializer.data, status=status.HTTP_200_OK) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") - def create(self, request, slug): - try: - workspace = Workspace.objects.get(slug=slug) - - serializer = ProjectSerializer( - data={**request.data}, context={"workspace_id": workspace.id} - ) - if serializer.is_valid(): - serializer.save() - - # Add the user as Administrator to the project - _ = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 - ) - # Also create the issue property for the user - _ = IssueUserProperty.objects.create( - project_id=serializer.data["id"], user=request.user - ) - - if serializer.data["project_lead"] is not None and str( - serializer.data["project_lead"] - ) != str(request.user.id): - ProjectMember.objects.create( - project_id=serializer.data["id"], - member_id=serializer.data["project_lead"], - role=20, - ) - # Also create the issue property for the user - IssueUserProperty.objects.create( - project_id=serializer.data["id"], - user_id=serializer.data["project_lead"], - ) - - # Default states - states = [ - { - "name": "Backlog", - "color": "#60646C", - "sequence": 15000, - "group": "backlog", - "default": True, - }, - { - "name": "Todo", - "color": "#60646C", - "sequence": 25000, - "group": "unstarted", - }, - { - "name": "In Progress", - "color": "#F59E0B", - "sequence": 35000, - "group": "started", - }, - { - "name": "Done", - "color": "#46A758", - "sequence": 45000, - "group": "completed", - }, - { - "name": "Cancelled", - "color": "#9AA4BC", - "sequence": 55000, - "group": "cancelled", - }, - ] - - State.objects.bulk_create( - [ - State( - name=state["name"], - color=state["color"], - project=serializer.instance, - sequence=state["sequence"], - workspace=serializer.instance.workspace, - group=state["group"], - default=state.get("default", False), - created_by=request.user, - ) - for state in states - ] - ) - - project = self.get_queryset().filter(pk=serializer.data["id"]).first() - - # Create the model activity - model_activity.delay( - model_name="project", - model_id=str(project.id), - requested_data=request.data, - current_instance=None, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - - serializer = ProjectListSerializer(project) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - except IntegrityError as e: - if "already exists" in str(e): - return Response( - { - "name": "The project name is already taken", - "code": "PROJECT_NAME_ALREADY_EXIST", - }, - status=status.HTTP_409_CONFLICT, - ) - except Workspace.DoesNotExist: - return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND - ) - except serializers.ValidationError: - return Response( - { - "identifier": "The project identifier is already taken", - "code": "PROJECT_IDENTIFIER_ALREADY_EXIST", - }, - status=status.HTTP_409_CONFLICT, - ) - - def partial_update(self, request, slug, pk=None): - try: - if not ProjectMember.objects.filter( - member=request.user, - workspace__slug=slug, - project_id=pk, - role=20, - is_active=True, - ).exists(): - return Response( - {"error": "You don't have the required permissions."}, - status=status.HTTP_403_FORBIDDEN, - ) - - workspace = Workspace.objects.get(slug=slug) - - project = Project.objects.get(pk=pk) - intake_view = request.data.get("inbox_view", project.intake_view) - current_instance = json.dumps( - ProjectSerializer(project).data, cls=DjangoJSONEncoder - ) - if project.archived_at: - return Response( - {"error": "Archived projects cannot be updated"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - serializer = ProjectSerializer( - project, - data={**request.data, "intake_view": intake_view}, - context={"workspace_id": workspace.id}, - partial=True, - ) - - if serializer.is_valid(): - serializer.save() - if intake_view: - intake = Intake.objects.filter( - project=project, is_default=True - ).first() - if not intake: - Intake.objects.create( - name=f"{project.name} Intake", - project=project, - is_default=True, - ) - - project = self.get_queryset().filter(pk=serializer.data["id"]).first() - - model_activity.delay( - model_name="project", - model_id=str(project.id), - requested_data=request.data, - current_instance=current_instance, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - serializer = ProjectListSerializer(project) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - except IntegrityError as e: - if "already exists" in str(e): - return Response( - {"name": "The project name is already taken"}, - status=status.HTTP_409_CONFLICT, - ) - except (Project.DoesNotExist, Workspace.DoesNotExist): - return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND - ) - except serializers.ValidationError: - return Response( - {"identifier": "The project identifier is already taken"}, - status=status.HTTP_409_CONFLICT, - ) - - def destroy(self, request, slug, pk): - if ( - WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=20 - ).exists() - or ProjectMember.objects.filter( - member=request.user, - workspace__slug=slug, - project_id=pk, - role=20, - is_active=True, - ).exists() - ): - project = Project.objects.get(pk=pk, workspace__slug=slug) - project.delete() - webhook_activity.delay( - event="project", - verb="deleted", - field=None, - old_value=None, - new_value=None, - actor_id=request.user.id, - slug=slug, - current_site=base_host(request=request, is_app=True), - event_id=project.id, - old_identifier=None, - new_identifier=None, - ) - # Delete the project members - DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete() - - # Delete the user favorite - UserFavorite.objects.filter(project_id=pk, workspace__slug=slug).delete() - - return Response(status=status.HTTP_204_NO_CONTENT) - else: - return Response( - {"error": "You don't have the required permissions."}, - status=status.HTTP_403_FORBIDDEN, - ) - - -class ProjectArchiveUnarchiveEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def post(self, request, slug, project_id): - project = Project.objects.get(pk=project_id, workspace__slug=slug) - project.archived_at = timezone.now() - project.save() - UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() - return Response( - {"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK - ) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def delete(self, request, slug, project_id): - project = Project.objects.get(pk=project_id, workspace__slug=slug) - project.archived_at = None - project.save() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectIdentifierEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") - def get(self, request, slug): - name = request.GET.get("name", "").strip().upper() - - if name == "": - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - exists = ProjectIdentifier.objects.filter( - name=name, workspace__slug=slug - ).values("id", "name", "project") - - return Response( - {"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK - ) - - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") - def delete(self, request, slug): - name = request.data.get("name", "").strip().upper() - - if name == "": - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) - - if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): - return Response( - {"error": "Cannot delete an identifier of an existing project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() - - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectUserViewsEndpoint(BaseAPIView): - def post(self, request, slug, project_id): - project = Project.objects.get(pk=project_id, workspace__slug=slug) - - project_member = ProjectMember.objects.filter( - member=request.user, project=project, is_active=True - ).first() - - if project_member is None: - return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) - - view_props = project_member.view_props - default_props = project_member.default_props - preferences = project_member.preferences - sort_order = project_member.sort_order - - project_member.view_props = request.data.get("view_props", view_props) - project_member.default_props = request.data.get("default_props", default_props) - project_member.preferences = request.data.get("preferences", preferences) - project_member.sort_order = request.data.get("sort_order", sort_order) - - project_member.save() - - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectFavoritesViewSet(BaseViewSet): - model = UserFavorite - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(user=self.request.user) - .select_related( - "project", "project__project_lead", "project__default_assignee" - ) - .select_related("workspace", "workspace__owner") - ) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - def create(self, request, slug): - _ = UserFavorite.objects.create( - user=request.user, - entity_type="project", - entity_identifier=request.data.get("project"), - project_id=request.data.get("project"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - - def destroy(self, request, slug, project_id): - project_favorite = UserFavorite.objects.get( - entity_identifier=project_id, - entity_type="project", - project=project_id, - user=request.user, - workspace__slug=slug, - ) - project_favorite.delete(soft=False) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ProjectPublicCoverImagesEndpoint(BaseAPIView): - permission_classes = [AllowAny] - - # Cache the below api for 24 hours - @cache_response(60 * 60 * 24, user=False) - def get(self, request): - files = [] - if settings.USE_MINIO: - s3 = boto3.client( - "s3", - endpoint_url=settings.AWS_S3_ENDPOINT_URL, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - else: - s3 = boto3.client( - "s3", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - params = { - "Bucket": settings.AWS_STORAGE_BUCKET_NAME, - "Prefix": "static/project-cover/", - } - - try: - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) - - return Response(files, status=status.HTTP_200_OK) - except Exception as e: - log_exception(e) - return Response([], status=status.HTTP_200_OK) - - -class DeployBoardViewSet(BaseViewSet): - permission_classes = [ProjectMemberPermission] - serializer_class = DeployBoardSerializer - model = DeployBoard - - def list(self, request, slug, project_id): - project_deploy_board = DeployBoard.objects.filter( - entity_name="project", entity_identifier=project_id, workspace__slug=slug - ).first() - - serializer = DeployBoardSerializer(project_deploy_board) - return Response(serializer.data, status=status.HTTP_200_OK) - - def create(self, request, slug, project_id): - comments = request.data.get("is_comments_enabled", False) - reactions = request.data.get("is_reactions_enabled", False) - intake = request.data.get("intake", None) - votes = request.data.get("is_votes_enabled", False) - views = request.data.get( - "views", - { - "list": True, - "kanban": True, - "calendar": True, - "gantt": True, - "spreadsheet": True, - }, - ) - - project_deploy_board, _ = DeployBoard.objects.get_or_create( - entity_name="project", entity_identifier=project_id, project_id=project_id - ) - project_deploy_board.intake = intake - project_deploy_board.view_props = views - project_deploy_board.is_votes_enabled = votes - project_deploy_board.is_comments_enabled = comments - project_deploy_board.is_reactions_enabled = reactions - - project_deploy_board.save() - - serializer = DeployBoardSerializer(project_deploy_board) - return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/timezone/base.py b/apiserver/plane/app/views/timezone/base.py deleted file mode 100644 index 21d1d356039..00000000000 --- a/apiserver/plane/app/views/timezone/base.py +++ /dev/null @@ -1,213 +0,0 @@ -# Python imports -import pytz -from datetime import datetime - -# Django imports -from django.utils.decorators import method_decorator -from django.views.decorators.cache import cache_page - -# Third party imports -from rest_framework import status -from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework.views import APIView - -# Module imports -from plane.authentication.rate_limit import AuthenticationThrottle - - -class TimezoneEndpoint(APIView): - permission_classes = [AllowAny] - - throttle_classes = [AuthenticationThrottle] - - @method_decorator(cache_page(60 * 60 * 2)) - def get(self, request): - timezone_locations = [ - ("Midway Island", "Pacific/Midway"), # UTC-11:00 - ("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00 - ("Hawaii", "Pacific/Honolulu"), # UTC-10:00 - ("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00) - ("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30 - ("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00) - ("Gambier Islands", "Pacific/Gambier"), # UTC-09:00 - ( - "Pacific Time (US and Canada)", - "America/Los_Angeles", - ), # UTC-08:00 (DST: UTC-07:00) - ("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00) - ( - "Mountain Time (US and Canada)", - "America/Denver", - ), # UTC-07:00 (DST: UTC-06:00) - ("Arizona", "America/Phoenix"), # UTC-07:00 - ("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00) - ( - "Central Time (US and Canada)", - "America/Chicago", - ), # UTC-06:00 (DST: UTC-05:00) - ("Saskatchewan", "America/Regina"), # UTC-06:00 - ( - "Guadalajara, Mexico City, Monterrey", - "America/Mexico_City", - ), # UTC-06:00 (DST: UTC-05:00) - ("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00 - ("Costa Rica", "America/Costa_Rica"), # UTC-06:00 - ( - "Eastern Time (US and Canada)", - "America/New_York", - ), # UTC-05:00 (DST: UTC-04:00) - ("Lima", "America/Lima"), # UTC-05:00 - ("Bogota", "America/Bogota"), # UTC-05:00 - ("Quito", "America/Guayaquil"), # UTC-05:00 - ("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00) - ("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30 - ("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00) - ("Caracas", "America/Caracas"), # UTC-04:00 - ("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00) - ("La Paz", "America/La_Paz"), # UTC-04:00 - ("Manaus", "America/Manaus"), # UTC-04:00 - ("Georgetown", "America/Guyana"), # UTC-04:00 - ("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00) - ( - "Newfoundland Time (Canada)", - "America/St_Johns", - ), # UTC-03:30 (DST: UTC-02:30) - ("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00 - ("Brasilia", "America/Sao_Paulo"), # UTC-03:00 - ("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00) - ("Montevideo", "America/Montevideo"), # UTC-03:00 - ("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00 - ( - "South Georgia and the South Sandwich Islands", - "Atlantic/South_Georgia", - ), # UTC-02:00 - ("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00) - ("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00 - ("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00) - ("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00 - ("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00) - ("Monrovia", "Africa/Monrovia"), # UTC+00:00 - ("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00) - ( - "Central European Time (Berlin, Rome, Paris)", - "Europe/Paris", - ), # UTC+01:00 (DST: UTC+02:00) - ("West Central Africa", "Africa/Lagos"), # UTC+01:00 - ("Algiers", "Africa/Algiers"), # UTC+01:00 - ("Lagos", "Africa/Lagos"), # UTC+01:00 - ("Tunis", "Africa/Tunis"), # UTC+01:00 - ( - "Eastern European Time (Cairo, Helsinki, Kyiv)", - "Europe/Kiev", - ), # UTC+02:00 (DST: UTC+03:00) - ("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00) - ("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00) - ("Johannesburg", "Africa/Johannesburg"), # UTC+02:00 - ("Harare, Pretoria", "Africa/Harare"), # UTC+02:00 - ("Moscow Time", "Europe/Moscow"), # UTC+03:00 - ("Baghdad", "Asia/Baghdad"), # UTC+03:00 - ("Nairobi", "Africa/Nairobi"), # UTC+03:00 - ("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00 - ("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30) - ("Abu Dhabi", "Asia/Dubai"), # UTC+04:00 - ("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00) - ("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00) - ("Astrakhan", "Europe/Astrakhan"), # UTC+04:00 - ("Tbilisi", "Asia/Tbilisi"), # UTC+04:00 - ("Mauritius", "Indian/Mauritius"), # UTC+04:00 - ("Islamabad", "Asia/Karachi"), # UTC+05:00 - ("Karachi", "Asia/Karachi"), # UTC+05:00 - ("Tashkent", "Asia/Tashkent"), # UTC+05:00 - ("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00 - ("Maldives", "Indian/Maldives"), # UTC+05:00 - ("Chagos", "Indian/Chagos"), # UTC+05:00 - ("Chennai", "Asia/Kolkata"), # UTC+05:30 - ("Kolkata", "Asia/Kolkata"), # UTC+05:30 - ("Mumbai", "Asia/Kolkata"), # UTC+05:30 - ("New Delhi", "Asia/Kolkata"), # UTC+05:30 - ("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30 - ("Kathmandu", "Asia/Kathmandu"), # UTC+05:45 - ("Dhaka", "Asia/Dhaka"), # UTC+06:00 - ("Almaty", "Asia/Almaty"), # UTC+06:00 - ("Bishkek", "Asia/Bishkek"), # UTC+06:00 - ("Thimphu", "Asia/Thimphu"), # UTC+06:00 - ("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30 - ("Cocos Islands", "Indian/Cocos"), # UTC+06:30 - ("Bangkok", "Asia/Bangkok"), # UTC+07:00 - ("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00 - ("Jakarta", "Asia/Jakarta"), # UTC+07:00 - ("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00 - ("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00 - ("Beijing", "Asia/Shanghai"), # UTC+08:00 - ("Singapore", "Asia/Singapore"), # UTC+08:00 - ("Perth", "Australia/Perth"), # UTC+08:00 - ("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00 - ("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00 - ("Palau", "Pacific/Palau"), # UTC+08:00 - ("Eucla", "Australia/Eucla"), # UTC+08:45 - ("Tokyo", "Asia/Tokyo"), # UTC+09:00 - ("Seoul", "Asia/Seoul"), # UTC+09:00 - ("Yakutsk", "Asia/Yakutsk"), # UTC+09:00 - ("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30) - ("Darwin", "Australia/Darwin"), # UTC+09:30 - ("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00) - ("Brisbane", "Australia/Brisbane"), # UTC+10:00 - ("Guam", "Pacific/Guam"), # UTC+10:00 - ("Vladivostok", "Asia/Vladivostok"), # UTC+10:00 - ("Tahiti", "Pacific/Tahiti"), # UTC+10:00 - ("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00) - ("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00 - ("Magadan", "Asia/Magadan"), # UTC+11:00 - ("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00 - ("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00 - ("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00 - ("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) - ("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) - ("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00) - ("Anadyr", "Asia/Anadyr"), # UTC+12:00 - ("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45) - ("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00 - ("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00) - ("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00 - ] - - timezone_list = [] - now = datetime.now() - - # Process timezone mapping - for friendly_name, tz_identifier in timezone_locations: - try: - tz = pytz.timezone(tz_identifier) - current_offset = now.astimezone(tz).strftime("%z") - - # converting and formatting UTC offset to GMT offset - current_utc_offset = now.astimezone(tz).utcoffset() - total_seconds = int(current_utc_offset.total_seconds()) - hours_offset = total_seconds // 3600 - minutes_offset = abs(total_seconds % 3600) // 60 - offset = ( - f"{'+' if hours_offset >= 0 else '-'}" - f"{abs(hours_offset):02}:{minutes_offset:02}" - ) - - timezone_value = { - "offset": int(current_offset), - "utc_offset": f"UTC{offset}", - "gmt_offset": f"GMT{offset}", - "value": tz_identifier, - "label": f"{friendly_name}", - } - - timezone_list.append(timezone_value) - except pytz.exceptions.UnknownTimeZoneError: - continue - - # Sort by offset and then by label - timezone_list.sort(key=lambda x: (x["offset"], x["label"])) - - # Remove offset from final output - for tz in timezone_list: - del tz["offset"] - - return Response({"timezones": timezone_list}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/bgtasks/copy_s3_object.py b/apiserver/plane/bgtasks/copy_s3_object.py deleted file mode 100644 index a92d7fe4e76..00000000000 --- a/apiserver/plane/bgtasks/copy_s3_object.py +++ /dev/null @@ -1,154 +0,0 @@ -# Python imports -import uuid -import base64 -import requests -from bs4 import BeautifulSoup - -# Django imports -from django.conf import settings - -# Module imports -from plane.db.models import FileAsset, Page, Issue -from plane.utils.exception_logger import log_exception -from plane.settings.storage import S3Storage -from celery import shared_task -from plane.utils.url import normalize_url_path - - -def get_entity_id_field(entity_type, entity_id): - entity_mapping = { - FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id}, - FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id}, - FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id}, - FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id}, - FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id}, - FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id}, - FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id}, - FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id}, - FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: { - "draft_issue_id": entity_id - }, - } - return entity_mapping.get(entity_type, {}) - - -def extract_asset_ids(html, tag): - try: - soup = BeautifulSoup(html, "html.parser") - return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")] - except Exception as e: - log_exception(e) - return [] - - -def replace_asset_ids(html, tag, duplicated_assets): - try: - soup = BeautifulSoup(html, "html.parser") - for mention_tag in soup.find_all(tag): - for asset in duplicated_assets: - if mention_tag.get("src") == asset["old_asset_id"]: - mention_tag["src"] = asset["new_asset_id"] - return str(soup) - except Exception as e: - log_exception(e) - return html - - -def update_description(entity, duplicated_assets, tag): - updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets) - entity.description_html = updated_html - entity.save() - return updated_html - - -# Get the description binary and description from the live server -def sync_with_external_service(entity_name, description_html): - try: - data = { - "description_html": description_html, - "variant": "rich" if entity_name == "PAGE" else "document", - } - - live_url = settings.LIVE_URL - if not live_url: - return {} - - url = normalize_url_path(f"{live_url}/convert-document/") - - response = requests.post(url, json=data, headers=None) - if response.status_code == 200: - return response.json() - except requests.RequestException as e: - log_exception(e) - return {} - - -@shared_task -def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id): - """ - Step 1: Extract asset ids from the description_html of the entity - Step 2: Duplicate the assets - Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag) - Step 4: Request the live server to generate the description_binary and description for the entity - - """ - try: - model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name) - if not model_class: - raise ValueError(f"Unsupported entity_name: {entity_name}") - - entity = model_class.objects.get(id=entity_identifier) - asset_ids = extract_asset_ids(entity.description_html, "image-component") - - duplicated_assets = [] - workspace = entity.workspace - storage = S3Storage() - original_assets = FileAsset.objects.filter( - workspace=workspace, project_id=project_id, id__in=asset_ids - ) - - for original_asset in original_assets: - destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" - duplicated_asset = FileAsset.objects.create( - attributes={ - "name": original_asset.attributes.get("name"), - "type": original_asset.attributes.get("type"), - "size": original_asset.attributes.get("size"), - }, - asset=destination_key, - size=original_asset.size, - workspace=workspace, - created_by_id=user_id, - entity_type=original_asset.entity_type, - project_id=project_id, - storage_metadata=original_asset.storage_metadata, - **get_entity_id_field(original_asset.entity_type, entity_identifier), - ) - storage.copy_object(original_asset.asset, destination_key) - duplicated_assets.append( - { - "new_asset_id": str(duplicated_asset.id), - "old_asset_id": str(original_asset.id), - } - ) - - if duplicated_assets: - FileAsset.objects.filter( - pk__in=[item["new_asset_id"] for item in duplicated_assets] - ).update(is_uploaded=True) - updated_html = update_description( - entity, duplicated_assets, "image-component" - ) - external_data = sync_with_external_service(entity_name, updated_html) - - if external_data: - entity.description = external_data.get("description") - entity.description_binary = base64.b64decode( - external_data.get("description_binary") - ) - entity.save() - - return - except Exception as e: - log_exception(e) - return [] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py deleted file mode 100644 index dad7aab3f56..00000000000 --- a/apiserver/plane/db/models/issue.py +++ /dev/null @@ -1,830 +0,0 @@ -# Python import -from uuid import uuid4 - -# Django imports -from django.conf import settings -from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models, transaction, connection -from django.utils import timezone -from django.db.models import Q -from django import apps - -# Module imports -from plane.utils.html_processor import strip_tags -from plane.db.mixins import SoftDeletionManager -from plane.utils.exception_logger import log_exception -from .project import ProjectBaseModel -from plane.utils.uuid import convert_uuid_to_integer - - -def get_default_properties(): - return { - "assignee": True, - "start_date": True, - "due_date": True, - "labels": True, - "key": True, - "priority": True, - "state": True, - "sub_issue_count": True, - "link": True, - "attachment_count": True, - "estimate": True, - "created_on": True, - "updated_on": True, - } - - -def get_default_filters(): - return { - "priority": None, - "state": None, - "state_group": None, - "assignees": None, - "created_by": None, - "labels": None, - "start_date": None, - "target_date": None, - "subscriber": None, - } - - -def get_default_display_filters(): - return { - "group_by": None, - "order_by": "-created_at", - "type": None, - "sub_issue": True, - "show_empty_groups": True, - "layout": "list", - "calendar_date_range": "", - } - - -def get_default_display_properties(): - return { - "assignee": True, - "attachment_count": True, - "created_on": True, - "due_date": True, - "estimate": True, - "key": True, - "labels": True, - "link": True, - "priority": True, - "start_date": True, - "state": True, - "sub_issue_count": True, - "updated_on": True, - } - - -# TODO: Handle identifiers for Bulk Inserts - nk -class IssueManager(SoftDeletionManager): - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - models.Q(issue_intake__status=1) - | models.Q(issue_intake__status=-1) - | models.Q(issue_intake__status=2) - | models.Q(issue_intake__isnull=True) - ) - .filter(deleted_at__isnull=True) - .filter(state__is_triage=False) - .exclude(archived_at__isnull=False) - .exclude(project__archived_at__isnull=False) - .exclude(is_draft=True) - ) - - -class Issue(ProjectBaseModel): - PRIORITY_CHOICES = ( - ("urgent", "Urgent"), - ("high", "High"), - ("medium", "Medium"), - ("low", "Low"), - ("none", "None"), - ) - parent = models.ForeignKey( - "self", - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="parent_issue", - ) - state = models.ForeignKey( - "db.State", - on_delete=models.CASCADE, - null=True, - blank=True, - related_name="state_issue", - ) - point = models.IntegerField( - validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True - ) - estimate_point = models.ForeignKey( - "db.EstimatePoint", - on_delete=models.SET_NULL, - related_name="issue_estimates", - null=True, - blank=True, - ) - name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(blank=True, default=dict) - description_html = models.TextField(blank=True, default="

") - description_stripped = models.TextField(blank=True, null=True) - description_binary = models.BinaryField(null=True) - priority = models.CharField( - max_length=30, - choices=PRIORITY_CHOICES, - verbose_name="Issue Priority", - default="none", - ) - start_date = models.DateField(null=True, blank=True) - target_date = models.DateField(null=True, blank=True) - assignees = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name="assignee", - through="IssueAssignee", - through_fields=("issue", "assignee"), - ) - sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") - labels = models.ManyToManyField( - "db.Label", blank=True, related_name="labels", through="IssueLabel" - ) - sort_order = models.FloatField(default=65535) - completed_at = models.DateTimeField(null=True) - archived_at = models.DateField(null=True) - is_draft = models.BooleanField(default=False) - external_source = models.CharField(max_length=255, null=True, blank=True) - external_id = models.CharField(max_length=255, blank=True, null=True) - type = models.ForeignKey( - "db.IssueType", - on_delete=models.SET_NULL, - related_name="issue_type", - null=True, - blank=True, - ) - - issue_objects = IssueManager() - - class Meta: - verbose_name = "Issue" - verbose_name_plural = "Issues" - db_table = "issues" - ordering = ("-created_at",) - - def save(self, *args, **kwargs): - if self.state is None: - try: - from plane.db.models import State - - default_state = State.objects.filter( - ~models.Q(is_triage=True), project=self.project, default=True - ).first() - if default_state is None: - random_state = State.objects.filter( - ~models.Q(is_triage=True), project=self.project - ).first() - self.state = random_state - else: - self.state = default_state - except ImportError: - pass - else: - try: - from plane.db.models import State - - if self.state.group == "completed": - self.completed_at = timezone.now() - else: - self.completed_at = None - except ImportError: - pass - - if self._state.adding: - with transaction.atomic(): - # Create a lock for this specific project using an advisory lock - # This ensures only one transaction per project can execute this code at a time - lock_key = convert_uuid_to_integer(self.project.id) - - with connection.cursor() as cursor: - # Get an exclusive lock using the project ID as the lock key - cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) - - # Get the last sequence for the project - last_sequence = IssueSequence.objects.filter( - project=self.project - ).aggregate(largest=models.Max("sequence"))["largest"] - self.sequence_id = last_sequence + 1 if last_sequence else 1 - # Strip the html tags using html parser - self.description_stripped = ( - None - if (self.description_html == "" or self.description_html is None) - else strip_tags(self.description_html) - ) - largest_sort_order = Issue.objects.filter( - project=self.project, state=self.state - ).aggregate(largest=models.Max("sort_order"))["largest"] - if largest_sort_order is not None: - self.sort_order = largest_sort_order + 10000 - - super(Issue, self).save(*args, **kwargs) - - IssueSequence.objects.create( - issue=self, sequence=self.sequence_id, project=self.project - ) - else: - # Strip the html tags using html parser - self.description_stripped = ( - None - if (self.description_html == "" or self.description_html is None) - else strip_tags(self.description_html) - ) - super(Issue, self).save(*args, **kwargs) - - def __str__(self): - """Return name of the issue""" - return f"{self.name} <{self.project.name}>" - - -class IssueBlocker(ProjectBaseModel): - block = models.ForeignKey( - Issue, related_name="blocker_issues", on_delete=models.CASCADE - ) - blocked_by = models.ForeignKey( - Issue, related_name="blocked_issues", on_delete=models.CASCADE - ) - - class Meta: - verbose_name = "Issue Blocker" - verbose_name_plural = "Issue Blockers" - db_table = "issue_blockers" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.block.name} {self.blocked_by.name}" - - -class IssueRelation(ProjectBaseModel): - RELATION_CHOICES = ( - ("duplicate", "Duplicate"), - ("relates_to", "Relates To"), - ("blocked_by", "Blocked By"), - ("start_before", "Start Before"), - ("finish_before", "Finish Before"), - ) - - issue = models.ForeignKey( - Issue, related_name="issue_relation", on_delete=models.CASCADE - ) - related_issue = models.ForeignKey( - Issue, related_name="issue_related", on_delete=models.CASCADE - ) - relation_type = models.CharField( - max_length=20, - choices=RELATION_CHOICES, - verbose_name="Issue Relation Type", - default="blocked_by", - ) - - class Meta: - unique_together = ["issue", "related_issue", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["issue", "related_issue"], - condition=Q(deleted_at__isnull=True), - name="issue_relation_unique_issue_related_issue_when_deleted_at_null", - ) - ] - verbose_name = "Issue Relation" - verbose_name_plural = "Issue Relations" - db_table = "issue_relations" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" - - -class IssueMention(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_mention" - ) - mention = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention" - ) - - class Meta: - unique_together = ["issue", "mention", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["issue", "mention"], - condition=Q(deleted_at__isnull=True), - name="issue_mention_unique_issue_mention_when_deleted_at_null", - ) - ] - verbose_name = "Issue Mention" - verbose_name_plural = "Issue Mentions" - db_table = "issue_mentions" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.mention.email}" - - -class IssueAssignee(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_assignee" - ) - assignee = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_assignee", - ) - - class Meta: - unique_together = ["issue", "assignee", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["issue", "assignee"], - condition=Q(deleted_at__isnull=True), - name="issue_assignee_unique_issue_assignee_when_deleted_at_null", - ) - ] - verbose_name = "Issue Assignee" - verbose_name_plural = "Issue Assignees" - db_table = "issue_assignees" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.assignee.email}" - - -class IssueLink(ProjectBaseModel): - title = models.CharField(max_length=255, null=True, blank=True) - url = models.TextField() - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="issue_link" - ) - metadata = models.JSONField(default=dict) - - class Meta: - verbose_name = "Issue Link" - verbose_name_plural = "Issue Links" - db_table = "issue_links" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.url}" - - -def get_upload_path(instance, filename): - return f"{instance.workspace.id}/{uuid4().hex}-{filename}" - - -def file_size(value): - # File limit check is only for cloud hosted - if value.size > settings.FILE_SIZE_LIMIT: - raise ValidationError("File too large. Size should not exceed 5 MB.") - - -class IssueAttachment(ProjectBaseModel): - attributes = models.JSONField(default=dict) - asset = models.FileField(upload_to=get_upload_path, validators=[file_size]) - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" - ) - external_source = models.CharField(max_length=255, null=True, blank=True) - external_id = models.CharField(max_length=255, blank=True, null=True) - - class Meta: - verbose_name = "Issue Attachment" - verbose_name_plural = "Issue Attachments" - db_table = "issue_attachments" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.asset}" - - -class IssueActivity(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" - ) - verb = models.CharField(max_length=255, verbose_name="Action", default="created") - field = models.CharField( - max_length=255, verbose_name="Field Name", blank=True, null=True - ) - old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) - new_value = models.TextField(verbose_name="New Value", blank=True, null=True) - - comment = models.TextField(verbose_name="Comment", blank=True) - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) - issue_comment = models.ForeignKey( - "db.IssueComment", - on_delete=models.SET_NULL, - related_name="issue_comment", - null=True, - ) - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name="issue_activities", - ) - old_identifier = models.UUIDField(null=True) - new_identifier = models.UUIDField(null=True) - epoch = models.FloatField(null=True) - - class Meta: - verbose_name = "Issue Activity" - verbose_name_plural = "Issue Activities" - db_table = "issue_activities" - ordering = ("-created_at",) - - def __str__(self): - """Return issue of the comment""" - return str(self.issue) - - -class IssueComment(ProjectBaseModel): - comment_stripped = models.TextField(verbose_name="Comment", blank=True) - comment_json = models.JSONField(blank=True, default=dict) - comment_html = models.TextField(blank=True, default="

") - attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_comments" - ) - # System can also create comment - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="comments", - null=True, - ) - access = models.CharField( - choices=(("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")), - default="INTERNAL", - max_length=100, - ) - external_source = models.CharField(max_length=255, null=True, blank=True) - external_id = models.CharField(max_length=255, blank=True, null=True) - edited_at = models.DateTimeField(null=True, blank=True) - - def save(self, *args, **kwargs): - self.comment_stripped = ( - strip_tags(self.comment_html) if self.comment_html != "" else "" - ) - return super(IssueComment, self).save(*args, **kwargs) - - class Meta: - verbose_name = "Issue Comment" - verbose_name_plural = "Issue Comments" - db_table = "issue_comments" - ordering = ("-created_at",) - - def __str__(self): - """Return issue of the comment""" - return str(self.issue) - - -class IssueUserProperty(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_property_user", - ) - filters = models.JSONField(default=get_default_filters) - display_filters = models.JSONField(default=get_default_display_filters) - display_properties = models.JSONField(default=get_default_display_properties) - - class Meta: - verbose_name = "Issue User Property" - verbose_name_plural = "Issue User Properties" - db_table = "issue_user_properties" - ordering = ("-created_at",) - unique_together = ["user", "project", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["user", "project"], - condition=Q(deleted_at__isnull=True), - name="issue_user_property_unique_user_project_when_deleted_at_null", - ) - ] - - def __str__(self): - """Return properties status of the issue""" - return str(self.user) - - -class IssueLabel(ProjectBaseModel): - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="label_issue" - ) - label = models.ForeignKey( - "db.Label", on_delete=models.CASCADE, related_name="label_issue" - ) - - class Meta: - verbose_name = "Issue Label" - verbose_name_plural = "Issue Labels" - db_table = "issue_labels" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.label.name}" - - -class IssueSequence(ProjectBaseModel): - issue = models.ForeignKey( - Issue, - on_delete=models.SET_NULL, - related_name="issue_sequence", - null=True, # This is set to null because we want to keep the sequence even if the issue is deleted - ) - sequence = models.PositiveBigIntegerField(default=1, db_index=True) - deleted = models.BooleanField(default=False) - - class Meta: - verbose_name = "Issue Sequence" - verbose_name_plural = "Issue Sequences" - db_table = "issue_sequences" - ordering = ("-created_at",) - - -class IssueSubscriber(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_subscribers" - ) - subscriber = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_subscribers", - ) - - class Meta: - unique_together = ["issue", "subscriber", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["issue", "subscriber"], - condition=models.Q(deleted_at__isnull=True), - name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null", - ) - ] - verbose_name = "Issue Subscriber" - verbose_name_plural = "Issue Subscribers" - db_table = "issue_subscribers" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.subscriber.email}" - - -class IssueReaction(ProjectBaseModel): - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_reactions", - ) - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_reactions" - ) - reaction = models.CharField(max_length=20) - - class Meta: - unique_together = ["issue", "actor", "reaction", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["issue", "actor", "reaction"], - condition=models.Q(deleted_at__isnull=True), - name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null", - ) - ] - verbose_name = "Issue Reaction" - verbose_name_plural = "Issue Reactions" - db_table = "issue_reactions" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.actor.email}" - - -class CommentReaction(ProjectBaseModel): - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="comment_reactions", - ) - comment = models.ForeignKey( - IssueComment, on_delete=models.CASCADE, related_name="comment_reactions" - ) - reaction = models.CharField(max_length=20) - - class Meta: - unique_together = ["comment", "actor", "reaction", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["comment", "actor", "reaction"], - condition=models.Q(deleted_at__isnull=True), - name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null", - ) - ] - verbose_name = "Comment Reaction" - verbose_name_plural = "Comment Reactions" - db_table = "comment_reactions" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.actor.email}" - - -class IssueVote(ProjectBaseModel): - issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes") - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" - ) - vote = models.IntegerField(choices=((-1, "DOWNVOTE"), (1, "UPVOTE")), default=1) - - class Meta: - unique_together = ["issue", "actor", "deleted_at"] - constraints = [ - models.UniqueConstraint( - fields=["issue", "actor"], - condition=models.Q(deleted_at__isnull=True), - name="issue_vote_unique_issue_actor_when_deleted_at_null", - ) - ] - verbose_name = "Issue Vote" - verbose_name_plural = "Issue Votes" - db_table = "issue_votes" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.issue.name} {self.actor.email}" - - -class IssueVersion(ProjectBaseModel): - PRIORITY_CHOICES = ( - ("urgent", "Urgent"), - ("high", "High"), - ("medium", "Medium"), - ("low", "Low"), - ("none", "None"), - ) - - parent = models.UUIDField(blank=True, null=True) - state = models.UUIDField(blank=True, null=True) - estimate_point = models.UUIDField(blank=True, null=True) - name = models.CharField(max_length=255, verbose_name="Issue Name") - priority = models.CharField( - max_length=30, - choices=PRIORITY_CHOICES, - verbose_name="Issue Priority", - default="none", - ) - start_date = models.DateField(null=True, blank=True) - target_date = models.DateField(null=True, blank=True) - assignees = ArrayField(models.UUIDField(), blank=True, default=list) - sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") - labels = ArrayField(models.UUIDField(), blank=True, default=list) - sort_order = models.FloatField(default=65535) - completed_at = models.DateTimeField(null=True) - archived_at = models.DateField(null=True) - is_draft = models.BooleanField(default=False) - external_source = models.CharField(max_length=255, null=True, blank=True) - external_id = models.CharField(max_length=255, blank=True, null=True) - type = models.UUIDField(blank=True, null=True) - cycle = models.UUIDField(null=True, blank=True) - modules = ArrayField(models.UUIDField(), blank=True, default=list) - properties = models.JSONField(default=dict) # issue properties - meta = models.JSONField(default=dict) # issue meta - last_saved_at = models.DateTimeField(default=timezone.now) - - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="versions" - ) - activity = models.ForeignKey( - "db.IssueActivity", - on_delete=models.SET_NULL, - null=True, - related_name="versions", - ) - owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_versions", - ) - - class Meta: - verbose_name = "Issue Version" - verbose_name_plural = "Issue Versions" - db_table = "issue_versions" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.name} <{self.project.name}>" - - @classmethod - def log_issue_version(cls, issue, user): - try: - """ - Log the issue version - """ - - Module = apps.get_model("db.Module") - CycleIssue = apps.get_model("db.CycleIssue") - IssueAssignee = apps.get_model("db.IssueAssignee") - IssueLabel = apps.get_model("db.IssueLabel") - - cycle_issue = CycleIssue.objects.filter(issue=issue).first() - - cls.objects.create( - issue=issue, - parent=issue.parent_id, - state=issue.state_id, - estimate_point=issue.estimate_point_id, - name=issue.name, - priority=issue.priority, - start_date=issue.start_date, - target_date=issue.target_date, - assignees=list( - IssueAssignee.objects.filter(issue=issue).values_list( - "assignee_id", flat=True - ) - ), - sequence_id=issue.sequence_id, - labels=list( - IssueLabel.objects.filter(issue=issue).values_list( - "label_id", flat=True - ) - ), - sort_order=issue.sort_order, - completed_at=issue.completed_at, - archived_at=issue.archived_at, - is_draft=issue.is_draft, - external_source=issue.external_source, - external_id=issue.external_id, - type=issue.type_id, - cycle=cycle_issue.cycle_id if cycle_issue else None, - modules=list( - Module.objects.filter(issue=issue).values_list("id", flat=True) - ), - properties={}, - meta={}, - last_saved_at=timezone.now(), - owned_by=user, - ) - return True - except Exception as e: - log_exception(e) - return False - - -class IssueDescriptionVersion(ProjectBaseModel): - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="description_versions" - ) - description_binary = models.BinaryField(null=True) - description_html = models.TextField(blank=True, default="

") - description_stripped = models.TextField(blank=True, null=True) - description_json = models.JSONField(default=dict, blank=True) - last_saved_at = models.DateTimeField(default=timezone.now) - owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="issue_description_versions", - ) - - class Meta: - verbose_name = "Issue Description Version" - verbose_name_plural = "Issue Description Versions" - db_table = "issue_description_versions" - - @classmethod - def log_issue_description_version(cls, issue, user): - try: - """ - Log the issue description version - """ - cls.objects.create( - workspace_id=issue.workspace_id, - project_id=issue.project_id, - created_by_id=issue.created_by_id, - updated_by_id=issue.updated_by_id, - owned_by_id=user, - last_saved_at=timezone.now(), - issue_id=issue.id, - description_binary=issue.description_binary, - description_html=issue.description_html, - description_stripped=issue.description_stripped, - description_json=issue.description, - ) - return True - except Exception as e: - log_exception(e) - return False diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py deleted file mode 100644 index ad6e858ad9a..00000000000 --- a/apiserver/plane/db/models/user.py +++ /dev/null @@ -1,277 +0,0 @@ -# Python imports -import random -import string -import uuid - -import pytz -from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager - -# Django imports -from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.utils import timezone - -# Module imports -from plane.db.models import FileAsset -from ..mixins import TimeAuditModel - - -def get_default_onboarding(): - return { - "profile_complete": False, - "workspace_create": False, - "workspace_invite": False, - "workspace_join": False, - } - - -def get_mobile_default_onboarding(): - return { - "profile_complete": False, - "workspace_create": False, - "workspace_join": False, - } - - -class User(AbstractBaseUser, PermissionsMixin): - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) - username = models.CharField(max_length=128, unique=True) - # user fields - mobile_number = models.CharField(max_length=255, blank=True, null=True) - email = models.CharField(max_length=255, null=True, blank=True, unique=True) - - # identity - display_name = models.CharField(max_length=255, default="") - first_name = models.CharField(max_length=255, blank=True) - last_name = models.CharField(max_length=255, blank=True) - # avatar - avatar = models.TextField(blank=True) - avatar_asset = models.ForeignKey( - FileAsset, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="user_avatar", - ) - # cover image - cover_image = models.URLField(blank=True, null=True, max_length=800) - cover_image_asset = models.ForeignKey( - FileAsset, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="user_cover_image", - ) - - # tracking metrics - date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") - updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") - last_location = models.CharField(max_length=255, blank=True) - created_location = models.CharField(max_length=255, blank=True) - - # the is' es - is_superuser = models.BooleanField(default=False) - is_managed = models.BooleanField(default=False) - is_password_expired = models.BooleanField(default=False) - is_active = models.BooleanField(default=True) - is_staff = models.BooleanField(default=False) - is_email_verified = models.BooleanField(default=False) - is_password_autoset = models.BooleanField(default=False) - - # random token generated - token = models.CharField(max_length=64, blank=True) - - last_active = models.DateTimeField(default=timezone.now, null=True) - last_login_time = models.DateTimeField(null=True) - last_logout_time = models.DateTimeField(null=True) - last_login_ip = models.CharField(max_length=255, blank=True) - last_logout_ip = models.CharField(max_length=255, blank=True) - last_login_medium = models.CharField(max_length=20, default="email") - last_login_uagent = models.TextField(blank=True) - token_updated_at = models.DateTimeField(null=True) - # my_issues_prop = models.JSONField(null=True) - - is_bot = models.BooleanField(default=False) - bot_type = models.CharField( - max_length=30, verbose_name="Bot Type", blank=True, null=True - ) - - # timezone - USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) - user_timezone = models.CharField( - max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES - ) - - # email validation - is_email_valid = models.BooleanField(default=False) - - # masking - masked_at = models.DateTimeField(null=True) - - USERNAME_FIELD = "email" - REQUIRED_FIELDS = ["username"] - - objects = UserManager() - - class Meta: - verbose_name = "User" - verbose_name_plural = "Users" - db_table = "users" - ordering = ("-created_at",) - - def __str__(self): - return f"{self.username} <{self.email}>" - - @property - def avatar_url(self): - # Return the logo asset url if it exists - if self.avatar_asset: - return self.avatar_asset.asset_url - - # Return the logo url if it exists - if self.avatar: - return self.avatar - return None - - @property - def cover_image_url(self): - # Return the logo asset url if it exists - if self.cover_image_asset: - return self.cover_image_asset.asset_url - - # Return the logo url if it exists - if self.cover_image: - return self.cover_image - return None - - def save(self, *args, **kwargs): - self.email = self.email.lower().strip() - self.mobile_number = self.mobile_number - - if self.token_updated_at is not None: - self.token = uuid.uuid4().hex + uuid.uuid4().hex - self.token_updated_at = timezone.now() - - if not self.display_name: - self.display_name = ( - self.email.split("@")[0] - if len(self.email.split("@")) - else "".join(random.choice(string.ascii_letters) for _ in range(6)) - ) - - if self.is_superuser: - self.is_staff = True - - super(User, self).save(*args, **kwargs) - - -class Profile(TimeAuditModel): - SUNDAY = 0 - MONDAY = 1 - TUESDAY = 2 - WEDNESDAY = 3 - THURSDAY = 4 - FRIDAY = 5 - SATURDAY = 6 - - START_OF_THE_WEEK_CHOICES = ( - (SUNDAY, "Sunday"), - (MONDAY, "Monday"), - (TUESDAY, "Tuesday"), - (WEDNESDAY, "Wednesday"), - (THURSDAY, "Thursday"), - (FRIDAY, "Friday"), - (SATURDAY, "Saturday"), - ) - - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) - # User - user = models.OneToOneField( - "db.User", on_delete=models.CASCADE, related_name="profile" - ) - # General - theme = models.JSONField(default=dict) - # Onboarding - is_tour_completed = models.BooleanField(default=False) - onboarding_step = models.JSONField(default=get_default_onboarding) - use_case = models.TextField(blank=True, null=True) - role = models.CharField(max_length=300, null=True, blank=True) # job role - is_onboarded = models.BooleanField(default=False) - # Last visited workspace - last_workspace_id = models.UUIDField(null=True) - # address data - billing_address_country = models.CharField(max_length=255, default="INDIA") - billing_address = models.JSONField(null=True) - has_billing_address = models.BooleanField(default=False) - company_name = models.CharField(max_length=255, blank=True) - - is_smooth_cursor_enabled = models.BooleanField(default=False) - # mobile - is_mobile_onboarded = models.BooleanField(default=False) - mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding) - mobile_timezone_auto_set = models.BooleanField(default=False) - # language - language = models.CharField(max_length=255, default="en") - start_of_the_week = models.PositiveSmallIntegerField( - choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY - ) - - class Meta: - verbose_name = "Profile" - verbose_name_plural = "Profiles" - db_table = "profiles" - ordering = ("-created_at",) - - -class Account(TimeAuditModel): - PROVIDER_CHOICES = ( - ("google", "Google"), - ("github", "Github"), - ("gitlab", "GitLab"), - ) - - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) - user = models.ForeignKey( - "db.User", on_delete=models.CASCADE, related_name="accounts" - ) - provider_account_id = models.CharField(max_length=255) - provider = models.CharField(choices=PROVIDER_CHOICES) - access_token = models.TextField() - access_token_expired_at = models.DateTimeField(null=True) - refresh_token = models.TextField(null=True, blank=True) - refresh_token_expired_at = models.DateTimeField(null=True) - last_connected_at = models.DateTimeField(default=timezone.now) - id_token = models.TextField(blank=True) - metadata = models.JSONField(default=dict) - - class Meta: - unique_together = ["provider", "provider_account_id"] - verbose_name = "Account" - verbose_name_plural = "Accounts" - db_table = "accounts" - ordering = ("-created_at",) - - -@receiver(post_save, sender=User) -def create_user_notification(sender, instance, created, **kwargs): - # create preferences - if created and not instance.is_bot: - # Module imports - from plane.db.models import UserNotificationPreference - - UserNotificationPreference.objects.create( - user=instance, - property_change=False, - state_change=False, - comment=False, - mention=False, - issue_completed=False, - ) diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py deleted file mode 100644 index a2ef90facb6..00000000000 --- a/apiserver/plane/license/api/views/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint - - -from .configuration import EmailCredentialCheckEndpoint, InstanceConfigurationEndpoint - - -from .admin import ( - InstanceAdminEndpoint, - InstanceAdminSignInEndpoint, - InstanceAdminSignUpEndpoint, - InstanceAdminUserMeEndpoint, - InstanceAdminSignOutEndpoint, - InstanceAdminUserSessionEndpoint, -) - - -from .workspace import ( - InstanceWorkSpaceAvailabilityCheckEndpoint, - InstanceWorkSpaceEndpoint, -) diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py deleted file mode 100644 index e1445b4e636..00000000000 --- a/apiserver/plane/space/serializer/issue.py +++ /dev/null @@ -1,443 +0,0 @@ -# Django imports -from django.utils import timezone - -# Third Party imports -from rest_framework import serializers - -# Module imports -from .base import BaseSerializer -from .user import UserLiteSerializer -from .state import StateSerializer, StateLiteSerializer -from .project import ProjectLiteSerializer -from .cycle import CycleBaseSerializer -from .module import ModuleBaseSerializer -from .workspace import WorkspaceLiteSerializer -from plane.db.models import ( - User, - Issue, - IssueComment, - IssueAssignee, - IssueLabel, - Label, - CycleIssue, - ModuleIssue, - IssueLink, - FileAsset, - IssueReaction, - CommentReaction, - IssueVote, - IssueRelation, -) - - -class IssueStateFlatSerializer(BaseSerializer): - state_detail = StateLiteSerializer(read_only=True, source="state") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - - class Meta: - model = Issue - fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] - - -class LabelSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - - class Meta: - model = Label - fields = "__all__" - read_only_fields = ["workspace", "project"] - - -class IssueProjectLiteSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(source="project", read_only=True) - - class Meta: - model = Issue - fields = ["id", "project_detail", "name", "sequence_id"] - read_only_fields = fields - - -class IssueRelationSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") - - class Meta: - model = IssueRelation - fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] - read_only_fields = ["workspace", "project"] - - -class RelatedIssueSerializer(BaseSerializer): - issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") - - class Meta: - model = IssueRelation - fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] - read_only_fields = ["workspace", "project"] - - -class IssueCycleDetailSerializer(BaseSerializer): - cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") - - class Meta: - model = CycleIssue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueModuleDetailSerializer(BaseSerializer): - module_detail = ModuleBaseSerializer(read_only=True, source="module") - - class Meta: - model = ModuleIssue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - - class Meta: - model = IssueLink - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - "issue", - ] - - # Validation if url already exists - def create(self, validated_data): - if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) - return IssueLink.objects.create(**validated_data) - - -class IssueAttachmentSerializer(BaseSerializer): - class Meta: - model = FileAsset - fields = "__all__" - read_only_fields = [ - "created_by", - "updated_by", - "created_at", - "updated_at", - "workspace", - "project", - "issue", - ] - - -class IssueReactionSerializer(BaseSerializer): - class Meta: - model = IssueReaction - fields = ["issue", "reaction", "workspace", "project", "actor"] - read_only_fields = ["workspace", "project", "issue", "actor"] - - -class IssueSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateSerializer(read_only=True, source="state") - parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") - label_details = LabelSerializer(read_only=True, source="labels", many=True) - assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - related_issues = IssueRelationSerializer( - read_only=True, source="issue_relation", many=True - ) - issue_relations = RelatedIssueSerializer( - read_only=True, source="issue_related", many=True - ) - issue_cycle = IssueCycleDetailSerializer(read_only=True) - issue_module = IssueModuleDetailSerializer(read_only=True) - issue_link = IssueLinkSerializer(read_only=True, many=True) - issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) - sub_issues_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) - - class Meta: - model = Issue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -class IssueFlatSerializer(BaseSerializer): - ## Contain only flat fields - - class Meta: - model = Issue - fields = [ - "id", - "name", - "description", - "description_html", - "priority", - "start_date", - "target_date", - "sequence_id", - "sort_order", - "is_draft", - ] - - -class CommentReactionLiteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = CommentReaction - fields = ["id", "reaction", "comment", "actor_detail"] - - -class IssueCommentSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - issue_detail = IssueFlatSerializer(read_only=True, source="issue") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) - is_member = serializers.BooleanField(read_only=True) - - class Meta: - model = IssueComment - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - -##TODO: Find a better way to write this serializer -## Find a better approach to save manytomany? -class IssueCreateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") - project_detail = ProjectLiteSerializer(read_only=True, source="project") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - - assignees = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), - write_only=True, - required=False, - ) - - labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), - write_only=True, - required=False, - ) - - class Meta: - model = Issue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - def to_representation(self, instance): - data = super().to_representation(instance) - data["assignees"] = [str(assignee.id) for assignee in instance.assignees.all()] - data["labels"] = [str(label.id) for label in instance.labels.all()] - return data - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("target_date", None) is not None - and data.get("start_date", None) > data.get("target_date", None) - ): - raise serializers.ValidationError("Start date cannot exceed target date") - return data - - def create(self, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) - - project_id = self.context["project_id"] - workspace_id = self.context["workspace_id"] - default_assignee_id = self.context["default_assignee_id"] - - issue = Issue.objects.create(**validated_data, project_id=project_id) - - # Issue Audit Users - created_by_id = issue.created_by_id - updated_by_id = issue.updated_by_id - - if assignees is not None and len(assignees): - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee=user, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for user in assignees - ], - batch_size=10, - ) - else: - # Then assign it to default assignee - if default_assignee_id is not None: - IssueAssignee.objects.create( - assignee_id=default_assignee_id, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - - if labels is not None and len(labels): - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label=label, - issue=issue, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - - return issue - - def update(self, instance, validated_data): - assignees = validated_data.pop("assignees", None) - labels = validated_data.pop("labels", None) - - # Related models - project_id = instance.project_id - workspace_id = instance.workspace_id - created_by_id = instance.created_by_id - updated_by_id = instance.updated_by_id - - if assignees is not None: - IssueAssignee.objects.filter(issue=instance).delete() - IssueAssignee.objects.bulk_create( - [ - IssueAssignee( - assignee=user, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for user in assignees - ], - batch_size=10, - ) - - if labels is not None: - IssueLabel.objects.filter(issue=instance).delete() - IssueLabel.objects.bulk_create( - [ - IssueLabel( - label=label, - issue=instance, - project_id=project_id, - workspace_id=workspace_id, - created_by_id=created_by_id, - updated_by_id=updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - - # Time updation occues even when other related models are updated - instance.updated_at = timezone.now() - return super().update(instance, validated_data) - - -class CommentReactionSerializer(BaseSerializer): - class Meta: - model = CommentReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "comment", "actor"] - - -class IssueVoteSerializer(BaseSerializer): - class Meta: - model = IssueVote - fields = ["issue", "vote", "workspace", "project", "actor"] - read_only_fields = fields - - -class IssuePublicSerializer(BaseSerializer): - reactions = IssueReactionSerializer( - read_only=True, many=True, source="issue_reactions" - ) - votes = IssueVoteSerializer(read_only=True, many=True) - module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) - - class Meta: - model = Issue - fields = [ - "id", - "name", - "sequence_id", - "state", - "project", - "workspace", - "priority", - "target_date", - "reactions", - "votes", - "module_ids", - "created_by", - "label_ids", - "assignee_ids", - ] - read_only_fields = fields - - -class LabelLiteSerializer(BaseSerializer): - class Meta: - model = Label - fields = ["id", "name", "color"] diff --git a/apiserver/plane/tests/contract/app/__init__.py b/apiserver/plane/tests/contract/app/__init__.py deleted file mode 100644 index 0519ecba6ea..00000000000 --- a/apiserver/plane/tests/contract/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apiserver/plane/tests/factories.py b/apiserver/plane/tests/factories.py deleted file mode 100644 index 8d95773ded1..00000000000 --- a/apiserver/plane/tests/factories.py +++ /dev/null @@ -1,82 +0,0 @@ -import factory -from uuid import uuid4 -from django.utils import timezone - -from plane.db.models import ( - User, - Workspace, - WorkspaceMember, - Project, - ProjectMember -) - - -class UserFactory(factory.django.DjangoModelFactory): - """Factory for creating User instances""" - class Meta: - model = User - django_get_or_create = ('email',) - - id = factory.LazyFunction(uuid4) - email = factory.Sequence(lambda n: f'user{n}@plane.so') - password = factory.PostGenerationMethodCall('set_password', 'password') - first_name = factory.Sequence(lambda n: f'First{n}') - last_name = factory.Sequence(lambda n: f'Last{n}') - is_active = True - is_superuser = False - is_staff = False - - -class WorkspaceFactory(factory.django.DjangoModelFactory): - """Factory for creating Workspace instances""" - class Meta: - model = Workspace - django_get_or_create = ('slug',) - - id = factory.LazyFunction(uuid4) - name = factory.Sequence(lambda n: f'Workspace {n}') - slug = factory.Sequence(lambda n: f'workspace-{n}') - owner = factory.SubFactory(UserFactory) - created_at = factory.LazyFunction(timezone.now) - updated_at = factory.LazyFunction(timezone.now) - - -class WorkspaceMemberFactory(factory.django.DjangoModelFactory): - """Factory for creating WorkspaceMember instances""" - class Meta: - model = WorkspaceMember - - id = factory.LazyFunction(uuid4) - workspace = factory.SubFactory(WorkspaceFactory) - member = factory.SubFactory(UserFactory) - role = 20 # Admin role by default - created_at = factory.LazyFunction(timezone.now) - updated_at = factory.LazyFunction(timezone.now) - - -class ProjectFactory(factory.django.DjangoModelFactory): - """Factory for creating Project instances""" - class Meta: - model = Project - django_get_or_create = ('name', 'workspace') - - id = factory.LazyFunction(uuid4) - name = factory.Sequence(lambda n: f'Project {n}') - workspace = factory.SubFactory(WorkspaceFactory) - created_by = factory.SelfAttribute('workspace.owner') - updated_by = factory.SelfAttribute('workspace.owner') - created_at = factory.LazyFunction(timezone.now) - updated_at = factory.LazyFunction(timezone.now) - - -class ProjectMemberFactory(factory.django.DjangoModelFactory): - """Factory for creating ProjectMember instances""" - class Meta: - model = ProjectMember - - id = factory.LazyFunction(uuid4) - project = factory.SubFactory(ProjectFactory) - member = factory.SubFactory(UserFactory) - role = 20 # Admin role by default - created_at = factory.LazyFunction(timezone.now) - updated_at = factory.LazyFunction(timezone.now) \ No newline at end of file diff --git a/apiserver/plane/tests/smoke/test_auth_smoke.py b/apiserver/plane/tests/smoke/test_auth_smoke.py deleted file mode 100644 index 4d6de6c35c1..00000000000 --- a/apiserver/plane/tests/smoke/test_auth_smoke.py +++ /dev/null @@ -1,100 +0,0 @@ -import pytest -import requests -from django.urls import reverse - - -@pytest.mark.smoke -class TestAuthSmoke: - """Smoke tests for authentication endpoints""" - - @pytest.mark.django_db - def test_login_endpoint_available(self, plane_server, create_user, user_data): - """Test that the login endpoint is available and responds correctly""" - # Get the sign-in URL - relative_url = reverse("sign-in") - url = f"{plane_server.url}{relative_url}" - - # 1. Test bad login - test with wrong password - response = requests.post( - url, - data={ - "email": user_data["email"], - "password": "wrong-password" - } - ) - - # For bad credentials, any of these status codes would be valid - # The test shouldn't be brittle to minor implementation changes - assert response.status_code != 500, "Authentication should not cause server errors" - assert response.status_code != 404, "Authentication endpoint should exist" - - if response.status_code == 200: - # If API returns 200 for failures, check the response body for error indication - if hasattr(response, 'json'): - try: - data = response.json() - # JSON response might indicate error in its structure - assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \ - "Error response should contain error details" - except ValueError: - # It's ok if response isn't JSON format - pass - elif response.status_code in [302, 303]: - # If it's a redirect, it should redirect to a login page or error page - redirect_url = response.headers.get('Location', '') - assert "error" in redirect_url or "sign-in" in redirect_url, \ - "Failed login should redirect to login page or error page" - - # 2. Test good login with correct credentials - response = requests.post( - url, - data={ - "email": user_data["email"], - "password": user_data["password"] - }, - allow_redirects=False # Don't follow redirects - ) - - # Successful auth should not be a client error or server error - assert response.status_code not in range(400, 600), \ - f"Authentication with valid credentials failed with status {response.status_code}" - - # Specific validation based on response type - if response.status_code in [302, 303]: - # Redirect-based auth: check that redirect URL doesn't contain error - redirect_url = response.headers.get('Location', '') - assert "error" not in redirect_url and "error_code" not in redirect_url, \ - "Successful login redirect should not contain error parameters" - - elif response.status_code == 200: - # API token-based auth: check for tokens or user session - if hasattr(response, 'json'): - try: - data = response.json() - # If it's a token response - if "access_token" in data: - assert "refresh_token" in data, "JWT auth should return both access and refresh tokens" - # If it's a user session response - elif "user" in data: - assert "is_authenticated" in data and data["is_authenticated"], \ - "User session response should indicate authentication" - # Otherwise it should at least indicate success - else: - assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \ - "Success response should not contain error keys" - except ValueError: - # Non-JSON is acceptable if it's a redirect or HTML response - pass - - -@pytest.mark.smoke -class TestHealthCheckSmoke: - """Smoke test for health check endpoint""" - - def test_healthcheck_endpoint(self, plane_server): - """Test that the health check endpoint is available and responds correctly""" - # Make a request to the health check endpoint - response = requests.get(f"{plane_server.url}/") - - # Should be OK - assert response.status_code == 200, "Health check endpoint should return 200 OK" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/serializers/test_workspace.py b/apiserver/plane/tests/unit/serializers/test_workspace.py deleted file mode 100644 index 19767a7c61f..00000000000 --- a/apiserver/plane/tests/unit/serializers/test_workspace.py +++ /dev/null @@ -1,71 +0,0 @@ -import pytest -from uuid import uuid4 - -from plane.api.serializers import WorkspaceLiteSerializer -from plane.db.models import Workspace, User - - -@pytest.mark.unit -class TestWorkspaceLiteSerializer: - """Test the WorkspaceLiteSerializer""" - - def test_workspace_lite_serializer_fields(self, db): - """Test that the serializer includes the correct fields""" - # Create a user to be the owner - owner = User.objects.create( - email="test@example.com", - first_name="Test", - last_name="User" - ) - - # Create a workspace with explicit ID to test serialization - workspace_id = uuid4() - workspace = Workspace.objects.create( - name="Test Workspace", - slug="test-workspace", - id=workspace_id, - owner=owner - ) - - # Serialize the workspace - serialized_data = WorkspaceLiteSerializer(workspace).data - - # Check fields are present and correct - assert "name" in serialized_data - assert "slug" in serialized_data - assert "id" in serialized_data - - assert serialized_data["name"] == "Test Workspace" - assert serialized_data["slug"] == "test-workspace" - assert str(serialized_data["id"]) == str(workspace_id) - - def test_workspace_lite_serializer_read_only(self, db): - """Test that the serializer fields are read-only""" - # Create a user to be the owner - owner = User.objects.create( - email="test2@example.com", - first_name="Test", - last_name="User" - ) - - # Create a workspace - workspace = Workspace.objects.create( - name="Test Workspace", - slug="test-workspace", - id=uuid4(), - owner=owner - ) - - # Try to update via serializer - serializer = WorkspaceLiteSerializer( - workspace, - data={"name": "Updated Name", "slug": "updated-slug"} - ) - - # Serializer should be valid (since read-only fields are ignored) - assert serializer.is_valid() - - # Save should not update the read-only fields - updated_workspace = serializer.save() - assert updated_workspace.name == "Test Workspace" - assert updated_workspace.slug == "test-workspace" \ No newline at end of file diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py deleted file mode 100644 index 6ccccd32d03..00000000000 --- a/apiserver/plane/utils/exception_logger.py +++ /dev/null @@ -1,18 +0,0 @@ -# Python imports -import logging -import traceback - -# Django imports -from django.conf import settings - - -def log_exception(e): - # Log the error - logger = logging.getLogger("plane.exception") - logger.exception(e) - - if settings.DEBUG: - # Print the traceback if in debug mode - print(traceback.format_exc()) - - return diff --git a/apiserver/plane/utils/url.py b/apiserver/plane/utils/url.py deleted file mode 100644 index 1b4a229a82e..00000000000 --- a/apiserver/plane/utils/url.py +++ /dev/null @@ -1,87 +0,0 @@ -# Python imports -import re -from typing import Optional -from urllib.parse import urlparse, urlunparse - - -def contains_url(value: str) -> bool: - """ - Check if the value contains a URL. - """ - url_pattern = re.compile(r"https?://|www\\.") - return bool(url_pattern.search(value)) - - -def is_valid_url(url: str) -> bool: - """ - Validates whether the given string is a well-formed URL. - - Args: - url (str): The URL string to validate. - - Returns: - bool: True if the URL is valid, False otherwise. - - Example: - >>> is_valid_url("https://example.com") - True - >>> is_valid_url("not a url") - False - """ - try: - result = urlparse(url) - # A valid URL should have at least scheme and netloc - return all([result.scheme, result.netloc]) - except TypeError: - return False - - -def get_url_components(url: str) -> Optional[dict]: - """ - Parses the URL and returns its components if valid. - - Args: - url (str): The URL string to parse. - - Returns: - Optional[dict]: A dictionary with URL components if valid, None otherwise. - - Example: - >>> get_url_components("https://example.com/path?query=1") - {'scheme': 'https', 'netloc': 'example.com', 'path': '/path', 'params': '', 'query': 'query=1', 'fragment': ''} - """ - if not is_valid_url(url): - return None - result = urlparse(url) - return { - "scheme": result.scheme, - "netloc": result.netloc, - "path": result.path, - "params": result.params, - "query": result.query, - "fragment": result.fragment, - } - - -def normalize_url_path(url: str) -> str: - """ - Normalize the path component of a URL by replacing multiple consecutive slashes with a single slash. - - This function preserves the protocol, domain, query parameters, and fragments of the URL, - only modifying the path portion to ensure there are no duplicate slashes. - - Args: - url (str): The input URL string to normalize. - - Returns: - str: The normalized URL with redundant slashes in the path removed. - - Example: - >>> normalize_url_path('https://example.com//foo///bar//baz?x=1#frag') - 'https://example.com/foo/bar/baz?x=1#frag' - """ - parts = urlparse(url) - # Normalize the path - normalized_path = re.sub(r"/+", "/", parts.path) - # Reconstruct the URL - return urlunparse(parts._replace(path=normalized_path)) diff --git a/apiserver/run_tests.py b/apiserver/run_tests.py deleted file mode 100755 index f4f0951b199..00000000000 --- a/apiserver/run_tests.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python -import argparse -import subprocess -import sys - - -def main(): - parser = argparse.ArgumentParser(description="Run Plane tests") - parser.add_argument( - "-u", "--unit", - action="store_true", - help="Run unit tests only" - ) - parser.add_argument( - "-c", "--contract", - action="store_true", - help="Run contract tests only" - ) - parser.add_argument( - "-s", "--smoke", - action="store_true", - help="Run smoke tests only" - ) - parser.add_argument( - "-o", "--coverage", - action="store_true", - help="Generate coverage report" - ) - parser.add_argument( - "-p", "--parallel", - action="store_true", - help="Run tests in parallel" - ) - parser.add_argument( - "-v", "--verbose", - action="store_true", - help="Verbose output" - ) - args = parser.parse_args() - - # Build command - cmd = ["python", "-m", "pytest"] - markers = [] - - # Add test markers - if args.unit: - markers.append("unit") - if args.contract: - markers.append("contract") - if args.smoke: - markers.append("smoke") - - # Add markers filter - if markers: - cmd.extend(["-m", " or ".join(markers)]) - - # Add coverage - if args.coverage: - cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"]) - - # Add parallel - if args.parallel: - cmd.extend(["-n", "auto"]) - - # Add verbose - if args.verbose: - cmd.append("-v") - - # Add common flags - cmd.extend(["--reuse-db", "--nomigrations"]) - - # Print command - print(f"Running: {' '.join(cmd)}") - - # Execute command - result = subprocess.run(cmd) - - # Check coverage thresholds if coverage is enabled - if args.coverage: - print("Checking coverage thresholds...") - coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"] - coverage_result = subprocess.run(coverage_cmd) - if coverage_result.returncode != 0: - print("Coverage below threshold (90%)") - sys.exit(coverage_result.returncode) - - sys.exit(result.returncode) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt deleted file mode 100644 index 4937ae149ab..00000000000 --- a/apiserver/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.12.6 \ No newline at end of file diff --git a/app.json b/app.json deleted file mode 100644 index 6b1ab752889..00000000000 --- a/app.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "Plane", - "description": "Plane helps you track your issues, epics, and product roadmaps.", - "repository": "http://github.com/makeplane/plane", - "logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4", - "website": "https://plane.so/", - "success_url": "/", - "stack": "heroku-22", - "keywords": ["plane", "project management", "django", "next"], - "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], - "buildpacks": [ - { - "url": "https://github.com/heroku/heroku-buildpack-python.git" - }, - { - "url": "https://github.com/heroku/heroku-buildpack-nodejs#v176" - } - ], - "env": { - "EMAIL_HOST": { - "description": "Email host to send emails from", - "value": "" - }, - "EMAIL_HOST_USER": { - "description": "Email host to send emails from", - "value": "" - }, - "EMAIL_HOST_PASSWORD": { - "description": "Email host to send emails from", - "value": "" - }, - "EMAIL_FROM": { - "description": "Email Sender", - "value": "" - }, - "EMAIL_PORT": { - "description": "The default Email PORT to use", - "value": "587" - }, - "AWS_REGION": { - "description": "AWS Region to use for S3", - "value": "false" - }, - "AWS_ACCESS_KEY_ID": { - "description": "AWS Access Key ID to use for S3", - "value": "" - }, - "AWS_SECRET_ACCESS_KEY": { - "description": "AWS Secret Access Key to use for S3", - "value": "" - }, - "AWS_S3_BUCKET_NAME": { - "description": "AWS Bucket Name to use for S3", - "value": "" - }, - "WEB_URL": { - "description": "Web URL for Plane this will be used for redirections in the emails", - "value": "" - }, - "GITHUB_CLIENT_SECRET": { - "description": "GitHub Client Secret", - "value": "" - }, - "NEXT_PUBLIC_API_BASE_URL": { - "description": "Next Public API Base URL", - "value": "" - }, - "SECRET_KEY": { - "description": "Django Secret Key", - "value": "" - } - } -} diff --git a/admin/.env.example b/apps/admin/.env.example similarity index 100% rename from admin/.env.example rename to apps/admin/.env.example diff --git a/admin/.eslintrc.js b/apps/admin/.eslintrc.js similarity index 100% rename from admin/.eslintrc.js rename to apps/admin/.eslintrc.js diff --git a/admin/.prettierignore b/apps/admin/.prettierignore similarity index 100% rename from admin/.prettierignore rename to apps/admin/.prettierignore diff --git a/admin/.prettierrc b/apps/admin/.prettierrc similarity index 100% rename from admin/.prettierrc rename to apps/admin/.prettierrc diff --git a/admin/Dockerfile.admin b/apps/admin/Dockerfile.admin similarity index 77% rename from admin/Dockerfile.admin rename to apps/admin/Dockerfile.admin index 8046bf32943..01884206ed4 100644 --- a/admin/Dockerfile.admin +++ b/apps/admin/Dockerfile.admin @@ -1,4 +1,4 @@ -FROM node:20-alpine as base +FROM node:22-alpine AS base # ***************************************************************************** # STAGE 1: Build the project @@ -46,8 +46,8 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 RUN yarn turbo run build --filter=admin @@ -57,12 +57,16 @@ RUN yarn turbo run build --filter=admin FROM base AS runner WORKDIR /app -COPY --from=installer /app/admin/next.config.js . -COPY --from=installer /app/admin/package.json . +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs -COPY --from=installer /app/admin/.next/standalone ./ -COPY --from=installer /app/admin/.next/static ./admin/.next/static -COPY --from=installer /app/admin/public ./admin/public +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer /app/apps/admin/.next/standalone ./ +COPY --from=installer /app/apps/admin/.next/static ./apps/admin/.next/static +COPY --from=installer /app/apps/admin/public ./apps/admin/public ARG NEXT_PUBLIC_API_BASE_URL="" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL @@ -82,7 +86,9 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 -EXPOSE 3000 \ No newline at end of file +EXPOSE 3000 + +CMD ["node", "apps/admin/server.js"] \ No newline at end of file diff --git a/apps/admin/Dockerfile.dev b/apps/admin/Dockerfile.dev new file mode 100644 index 00000000000..edf82d22785 --- /dev/null +++ b/apps/admin/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM node:22-alpine +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . + +RUN yarn global add turbo +RUN yarn install + +ENV NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" + +EXPOSE 3000 + +VOLUME [ "/app/node_modules", "/app/admin/node_modules" ] + +CMD ["yarn", "dev", "--filter=admin"] diff --git a/apps/admin/app/(all)/(dashboard)/ai/form.tsx b/apps/admin/app/(all)/(dashboard)/ai/form.tsx new file mode 100644 index 00000000000..8b7d036ad2f --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/ai/form.tsx @@ -0,0 +1,134 @@ +"use client"; +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { Lightbulb } from "lucide-react"; +import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; +// hooks +import { useInstance } from "@/hooks/store"; + +type IInstanceAIForm = { + config: IFormattedInstanceConfiguration; +}; + +type AIFormValues = Record; + +export const InstanceAIForm: FC = (props) => { + const { config } = props; + // store + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + LLM_API_KEY: config["LLM_API_KEY"], + LLM_MODEL: config["LLM_MODEL"], + }, + }); + + const aiFormFields: TControllerInputFormField[] = [ + { + key: "LLM_MODEL", + type: "text", + label: "LLM Model", + description: ( + <> + Choose an OpenAI engine.{" "} + + Learn more + + + ), + placeholder: "gpt-4o-mini", + error: Boolean(errors.LLM_MODEL), + required: false, + }, + { + key: "LLM_API_KEY", + type: "password", + label: "API key", + description: ( + <> + You will find your API key{" "} + + here. + + + ), + placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", + error: Boolean(errors.LLM_API_KEY), + required: false, + }, + ]; + + const onSubmit = async (formData: AIFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "AI Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
+
OpenAI
+
If you use ChatGPT, this is for you.
+
+
+ {aiFormFields.map((field) => ( + + ))} +
+
+ +
+ + +
+ +
+ If you have a preferred AI models vendor, please get in{" "} + + touch with us. + +
+
+
+
+ ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/ai/layout.tsx b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx new file mode 100644 index 00000000000..42f3796496f --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Artificial Intelligence Settings - God Mode", +}; + +export default function AILayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/admin/app/ai/page.tsx b/apps/admin/app/(all)/(dashboard)/ai/page.tsx similarity index 100% rename from admin/app/ai/page.tsx rename to apps/admin/app/(all)/(dashboard)/ai/page.tsx diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx new file mode 100644 index 00000000000..6e5f2a90387 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { FC, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; +import { CopyField, TCopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GithubConfigFormValues = Record; + +export const InstanceGithubConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"], + GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"], + GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITHUB_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITHUB_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + You will get this from your{" "} + + GitHub OAuth application settings. + + + ), + placeholder: "70a44354520df8bd9bcd", + error: Boolean(errors.GITHUB_CLIENT_ID), + required: true, + }, + { + key: "GITHUB_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret is also found in your{" "} + + GitHub OAuth application settings. + + + ), + placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb", + error: Boolean(errors.GITHUB_CLIENT_SECRET), + required: true, + }, + { + key: "GITHUB_ORGANIZATION_ID", + type: "text", + label: "Organization ID", + description: <>The organization github ID., + placeholder: "123456789", + error: Boolean(errors.GITHUB_ORGANIZATION_ID), + required: false, + }, + ]; + + const GITHUB_SERVICE_FIELD: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( + <> + We will auto-generate this. Paste this into the Authorized origin URL field{" "} + + here. + + + ), + }, + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/github/callback/`, + description: ( + <> + We will auto-generate this. Paste this into your Authorized Callback URI{" "} + field{" "} + + here. + + + ), + }, + ]; + + const onSubmit = async (formData: GithubConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitHub authentication is configured. You should test it now.", + }); + reset({ + GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, + GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value, + GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
GitHub-provided details for Plane
+ {GITHUB_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for GitHub
+ {GITHUB_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx new file mode 100644 index 00000000000..373f9340aff --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "GitHub Authentication - God Mode", +}; + +export default function GitHubAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx new file mode 100644 index 00000000000..75cb84e4afd --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane internal packages +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +import { resolveGeneralTheme } from "@plane/utils"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +// local components +import { InstanceGithubConfigForm } from "./form"; + +const InstanceGithubAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // theme + const { resolvedTheme } = useTheme(); + // config + const enableGithubConfig = formattedConfig?.IS_GITHUB_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITHUB_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + const isGithubEnabled = enableGithubConfig === "1"; + + return ( + <> +
+
+ + } + config={ + { + updateConfig("IS_GITHUB_ENABLED", isGithubEnabled ? "0" : "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGithubAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx new file mode 100644 index 00000000000..888b2533c2c --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -0,0 +1,208 @@ +import { FC, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; +import { CopyField, TCopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GitlabConfigFormValues = Record; + +export const InstanceGitlabConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITLAB_HOST: config["GITLAB_HOST"], + GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"], + GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITLAB_HOST", + type: "text", + label: "Host", + description: ( + <> + This is either https://gitlab.com or the domain.tld where you host GitLab. + + ), + placeholder: "https://gitlab.com", + error: Boolean(errors.GITLAB_HOST), + required: true, + }, + { + key: "GITLAB_CLIENT_ID", + type: "text", + label: "Application ID", + description: ( + <> + Get this from your{" "} + + GitLab OAuth application settings + + . + + ), + placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3", + error: Boolean(errors.GITLAB_CLIENT_ID), + required: true, + }, + { + key: "GITLAB_CLIENT_SECRET", + type: "password", + label: "Secret", + description: ( + <> + The client secret is also found in your{" "} + + GitLab OAuth application settings + + . + + ), + placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28", + error: Boolean(errors.GITLAB_CLIENT_SECRET), + required: true, + }, + ]; + + const GITLAB_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URL", + label: "Callback URL", + url: `${originURL}/auth/gitlab/callback/`, + description: ( + <> + We will auto-generate this. Paste this into the Redirect URI field of your{" "} + + GitLab OAuth application + + . + + ), + }, + ]; + + const onSubmit = async (formData: GitlabConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitLab authentication is configured. You should test it now.", + }); + reset({ + GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, + GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, + GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
GitLab-provided details for Plane
+ {GITLAB_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for GitLab
+ {GITLAB_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx new file mode 100644 index 00000000000..fc89e9752fb --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "GitLab Authentication - God Mode", +}; + +export default function GitlabAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx new file mode 100644 index 00000000000..cdcfcd61bbc --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +// local components +import { InstanceGitlabConfigForm } from "./form"; + +const InstanceGitlabAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> +
+
+ } + config={ + { + Boolean(parseInt(enableGitlabConfig)) === true + ? updateConfig("IS_GITLAB_ENABLED", "0") + : updateConfig("IS_GITLAB_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGitlabAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx new file mode 100644 index 00000000000..f136df308ec --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx @@ -0,0 +1,210 @@ +"use client"; +import { FC, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// plane internal packages +import { API_BASE_URL } from "@plane/constants"; +import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { CodeBlock } from "@/components/common/code-block"; +import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; +import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; +import { CopyField, TCopyField } from "@/components/common/copy-field"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GoogleConfigFormValues = Record; + +export const InstanceGoogleConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"], + GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GOOGLE_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GOOGLE_CLIENT_ID", + type: "text", + label: "Client ID", + description: ( + <> + Your client ID lives in your Google API Console.{" "} + + Learn more + + + ), + placeholder: "840195096245-0p2tstej9j5nc4l8o1ah2dqondscqc1g.apps.googleusercontent.com", + error: Boolean(errors.GOOGLE_CLIENT_ID), + required: true, + }, + { + key: "GOOGLE_CLIENT_SECRET", + type: "password", + label: "Client secret", + description: ( + <> + Your client secret should also be in your Google API Console.{" "} + + Learn more + + + ), + placeholder: "GOCShX-ADp4cI0kPqav1gGCBg5bE02E", + error: Boolean(errors.GOOGLE_CLIENT_SECRET), + required: true, + }, + ]; + + const GOOGLE_SERVICE_DETAILS: TCopyField[] = [ + { + key: "Origin_URL", + label: "Origin URL", + url: originURL, + description: ( +

+ We will auto-generate this. Paste this into your{" "} + Authorized JavaScript origins field. For this OAuth client{" "} + + here. + +

+ ), + }, + { + key: "Callback_URI", + label: "Callback URI", + url: `${originURL}/auth/google/callback/`, + description: ( +

+ We will auto-generate this. Paste this into your Authorized Redirect URI{" "} + field. For this OAuth client{" "} + + here. + +

+ ), + }, + ]; + + const onSubmit = async (formData: GoogleConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your Google authentication is configured. You should test it now.", + }); + reset({ + GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, + GOOGLE_CLIENT_SECRET: response.find((item) => item.key === "GOOGLE_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
Google-provided details for Plane
+ {GOOGLE_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for Google
+ {GOOGLE_SERVICE_DETAILS.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx new file mode 100644 index 00000000000..5b3786b5fae --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Google Authentication - God Mode", +}; + +export default function GoogleAuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx new file mode 100644 index 00000000000..6ac4ea09b9b --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import GoogleLogo from "@/public/logos/google-logo.svg"; +// local components +import { InstanceGoogleConfigForm } from "./form"; + +const InstanceGoogleAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGoogleConfig = formattedConfig?.IS_GOOGLE_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GOOGLE_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `Google authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> +
+
+ } + config={ + { + Boolean(parseInt(enableGoogleConfig)) === true + ? updateConfig("IS_GOOGLE_ENABLED", "0") + : updateConfig("IS_GOOGLE_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGoogleAuthenticationPage; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx new file mode 100644 index 00000000000..69753d9608a --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Authentication Settings - Plane Web", +}; + +export default function AuthenticationLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/admin/app/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx similarity index 100% rename from admin/app/authentication/page.tsx rename to apps/admin/app/(all)/(dashboard)/authentication/page.tsx diff --git a/admin/app/email/email-config-form.tsx b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx similarity index 97% rename from admin/app/email/email-config-form.tsx rename to apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx index 73a1af17442..2bfcc428772 100644 --- a/admin/app/email/email-config-form.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx @@ -7,7 +7,7 @@ import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from // ui import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { ControllerInput, TControllerInputFormField } from "@/components/common"; +import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; // hooks import { useInstance } from "@/hooks/store"; // local components @@ -49,9 +49,9 @@ export const InstanceEmailForm: FC = (props) => { EMAIL_USE_TLS: config["EMAIL_USE_TLS"], EMAIL_USE_SSL: config["EMAIL_USE_SSL"], EMAIL_FROM: config["EMAIL_FROM"], + ENABLE_SMTP: config["ENABLE_SMTP"], }, }); - const emailFormFields: TControllerInputFormField[] = [ { key: "EMAIL_HOST", @@ -101,7 +101,7 @@ export const InstanceEmailForm: FC = (props) => { ]; const onSubmit = async (formData: EmailFormValues) => { - const payload: Partial = { ...formData }; + const payload: Partial = { ...formData, ENABLE_SMTP: "1" }; await updateInstanceConfigurations(payload) .then(() => diff --git a/apps/admin/app/(all)/(dashboard)/email/layout.tsx b/apps/admin/app/(all)/(dashboard)/email/layout.tsx new file mode 100644 index 00000000000..cb321295107 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/email/layout.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +interface EmailLayoutProps { + children: ReactNode; +} + +export const metadata: Metadata = { + title: "Email Settings - God Mode", +}; + +export default function EmailLayout({ children }: EmailLayoutProps) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/email/page.tsx b/apps/admin/app/(all)/(dashboard)/email/page.tsx new file mode 100644 index 00000000000..445ff2ec72b --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/email/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { InstanceEmailForm } from "./email-config-form"; + +const InstanceEmailPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, disableEmail } = useInstance(); + + const { isLoading } = useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSMTPEnabled, setIsSMTPEnabled] = useState(false); + + const handleToggle = async () => { + if (isSMTPEnabled) { + setIsSubmitting(true); + try { + await disableEmail(); + setIsSMTPEnabled(false); + setToast({ + title: "Email feature disabled", + message: "Email feature has been disabled", + type: TOAST_TYPE.SUCCESS, + }); + } catch (error) { + setToast({ + title: "Error disabling email", + message: "Failed to disable email feature. Please try again.", + type: TOAST_TYPE.ERROR, + }); + } finally { + setIsSubmitting(false); + } + return; + } + setIsSMTPEnabled(true); + }; + useEffect(() => { + if (formattedConfig) { + setIsSMTPEnabled(formattedConfig.ENABLE_SMTP === "1"); + } + }, [formattedConfig]); + + return ( + <> +
+
+
+
Secure emails from your own instance
+
+ Plane can send useful emails to you and your users from your own instance without talking to the Internet. +
+ Set it up below and please test your settings before you save them.  + Misconfigs can lead to email bounces and errors. +
+
+
+ {isLoading ? ( + + + + ) : ( + + )} +
+ {isSMTPEnabled && !isLoading && ( +
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+ )} +
+ + ); +}); + +export default InstanceEmailPage; diff --git a/admin/app/email/test-email-modal.tsx b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx similarity index 100% rename from admin/app/email/test-email-modal.tsx rename to apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx new file mode 100644 index 00000000000..0700c4d0d5f --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -0,0 +1,155 @@ +"use client"; +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { Telescope } from "lucide-react"; +// types +import { IInstance, IInstanceAdmin } from "@plane/types"; +// ui +import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ControllerInput } from "@/components/common/controller-input"; +import { useInstance } from "@/hooks/store"; +import { IntercomConfig } from "./intercom"; +// hooks + +export interface IGeneralConfigurationForm { + instance: IInstance; + instanceAdmins: IInstanceAdmin[]; +} + +export const GeneralConfigurationForm: FC = observer((props) => { + const { instance, instanceAdmins } = props; + // hooks + const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance(); + + // form data + const { + handleSubmit, + control, + watch, + formState: { errors, isSubmitting }, + } = useForm>({ + defaultValues: { + instance_name: instance?.instance_name, + is_telemetry_enabled: instance?.is_telemetry_enabled, + }, + }); + + const onSubmit = async (formData: Partial) => { + const payload: Partial = { ...formData }; + + // update the intercom configuration + const isIntercomEnabled = + instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"; + if (!payload.is_telemetry_enabled && isIntercomEnabled) { + try { + await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" }); + } catch (error) { + console.error(error); + } + } + + await updateInstanceInfo(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
Instance details
+
+ + +
+

Email

+ +
+ +
+

Instance ID

+ +
+
+
+ +
+
Chat + telemetry
+ +
+
+
+
+ +
+
+
+
+ Let Plane collect anonymous usage data +
+
+ No PII is collected.This anonymized data is used to understand how you use Plane and build new features + in line with{" "} + + our Telemetry Policy. + +
+
+
+
+ ( + + )} + /> +
+
+
+ +
+ +
+
+ ); +}); diff --git a/admin/app/general/intercom.tsx b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx similarity index 100% rename from admin/app/general/intercom.tsx rename to apps/admin/app/(all)/(dashboard)/general/intercom.tsx diff --git a/apps/admin/app/(all)/(dashboard)/general/layout.tsx b/apps/admin/app/(all)/(dashboard)/general/layout.tsx new file mode 100644 index 00000000000..af300051052 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/general/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "General Settings - God Mode", +}; + +export default function GeneralLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/admin/app/general/page.tsx b/apps/admin/app/(all)/(dashboard)/general/page.tsx similarity index 100% rename from admin/app/general/page.tsx rename to apps/admin/app/(all)/(dashboard)/general/page.tsx diff --git a/apps/admin/app/(all)/(dashboard)/header.tsx b/apps/admin/app/(all)/(dashboard)/header.tsx new file mode 100644 index 00000000000..af7161c2dc9 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/header.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +import { Menu, Settings } from "lucide-react"; +// icons +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; +// hooks +import { useTheme } from "@/hooks/store"; + +export const HamburgerToggle: FC = observer(() => { + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + return ( +
toggleSidebar(!isSidebarCollapsed)} + > + +
+ ); +}); + +export const AdminHeader: FC = observer(() => { + const pathName = usePathname(); + + const getHeaderTitle = (pathName: string) => { + switch (pathName) { + case "general": + return "General"; + case "ai": + return "Artificial Intelligence"; + case "email": + return "Email"; + case "authentication": + return "Authentication"; + case "image": + return "Image"; + case "google": + return "Google"; + case "github": + return "GitHub"; + case "gitlab": + return "GitLab"; + case "workspace": + return "Workspace"; + case "create": + return "Create"; + default: + return pathName.toUpperCase(); + } + }; + + // Function to dynamically generate breadcrumb items based on pathname + const generateBreadcrumbItems = (pathname: string) => { + const pathSegments = pathname.split("/").slice(1); // removing the first empty string. + pathSegments.pop(); + + let currentUrl = ""; + const breadcrumbItems = pathSegments.map((segment) => { + currentUrl += "/" + segment; + return { + title: getHeaderTitle(segment), + href: currentUrl, + }; + }); + return breadcrumbItems; + }; + + const breadcrumbItems = generateBreadcrumbItems(pathName); + + return ( +
+
+ + {breadcrumbItems.length >= 0 && ( +
+ + } + /> + } + /> + {breadcrumbItems.map( + (item) => + item.title && ( + } + /> + ) + )} + +
+ )} +
+
+ ); +}); diff --git a/apps/admin/app/(all)/(dashboard)/image/form.tsx b/apps/admin/app/(all)/(dashboard)/image/form.tsx new file mode 100644 index 00000000000..be77983ec91 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/image/form.tsx @@ -0,0 +1,80 @@ +"use client"; +import { FC } from "react"; +import { useForm } from "react-hook-form"; +import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ControllerInput } from "@/components/common/controller-input"; +// hooks +import { useInstance } from "@/hooks/store"; + +type IInstanceImageConfigForm = { + config: IFormattedInstanceConfiguration; +}; + +type ImageConfigFormValues = Record; + +export const InstanceImageConfigForm: FC = (props) => { + const { config } = props; + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + UNSPLASH_ACCESS_KEY: config["UNSPLASH_ACCESS_KEY"], + }, + }); + + const onSubmit = async (formData: ImageConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then(() => + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success", + message: "Image Configuration Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+ + You will find your access key in your Unsplash developer console.  + + Learn more. + + + } + placeholder="oXgq-sdfadsaeweqasdfasdf3234234rassd" + error={Boolean(errors.UNSPLASH_ACCESS_KEY)} + required + /> +
+ +
+ +
+
+ ); +}; diff --git a/apps/admin/app/(all)/(dashboard)/image/layout.tsx b/apps/admin/app/(all)/(dashboard)/image/layout.tsx new file mode 100644 index 00000000000..7ec0ff54b7c --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/image/layout.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +interface ImageLayoutProps { + children: ReactNode; +} + +export const metadata: Metadata = { + title: "Images Settings - God Mode", +}; + +export default function ImageLayout({ children }: ImageLayoutProps) { + return <>{children}; +} diff --git a/admin/app/image/page.tsx b/apps/admin/app/(all)/(dashboard)/image/page.tsx similarity index 100% rename from admin/app/image/page.tsx rename to apps/admin/app/(all)/(dashboard)/image/page.tsx diff --git a/apps/admin/app/(all)/(dashboard)/layout.tsx b/apps/admin/app/(all)/(dashboard)/layout.tsx new file mode 100644 index 00000000000..17962378375 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/layout.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { FC, ReactNode, useEffect } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// components +import { LogoSpinner } from "@/components/common/logo-spinner"; +import { NewUserPopup } from "@/components/new-user-popup"; +// hooks +import { useUser } from "@/hooks/store"; +// local components +import { AdminHeader } from "./header"; +import { AdminSidebar } from "./sidebar"; + +type TAdminLayout = { + children: ReactNode; +}; + +const AdminLayout: FC = (props) => { + const { children } = props; + // router + const router = useRouter(); + // store hooks + const { isUserLoggedIn } = useUser(); + + useEffect(() => { + if (isUserLoggedIn === false) { + router.push("/"); + } + }, [router, isUserLoggedIn]); + + if (isUserLoggedIn === undefined) { + return ( +
+ +
+ ); + } + + if (isUserLoggedIn) { + return ( +
+ +
+ +
{children}
+
+ +
+ ); + } + + return <>; +}; + +export default observer(AdminLayout); diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx similarity index 96% rename from admin/core/components/admin-sidebar/sidebar-dropdown.tsx rename to apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx index 0cde7f5519d..5554947be39 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-dropdown.tsx @@ -16,7 +16,7 @@ import { useTheme, useUser } from "@/hooks/store"; // service initialization const authService = new AuthService(); -export const SidebarDropdown = observer(() => { +export const AdminSidebarDropdown = observer(() => { // store hooks const { isSidebarCollapsed } = useTheme(); const { currentUser, signOut } = useUser(); @@ -77,7 +77,7 @@ export const SidebarDropdown = observer(() => { }, [csrfToken]); return ( -
+
{ + // states + const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); + // store + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + // refs + const helpOptionsRef = useRef(null); + + const redirectionLink = encodeURI(WEB_BASE_URL + "/"); + + return ( +
+
+ + + + {!isSidebarCollapsed && "Redirect to Plane"} + + + + + + + + +
+ +
+ +
+
+ {helpOptions.map(({ name, Icon, href }) => { + if (href) + return ( + +
+
+ +
+ {name} +
+ + ); + else + return ( + + ); + })} +
+
Version: v{packageJson.version}
+
+
+
+
+ ); +}); diff --git a/admin/core/components/admin-sidebar/sidebar-menu.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx similarity index 98% rename from admin/core/components/admin-sidebar/sidebar-menu.tsx rename to apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx index 618551ae65c..e536a51454f 100644 --- a/admin/core/components/admin-sidebar/sidebar-menu.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-menu.tsx @@ -49,7 +49,7 @@ const INSTANCE_ADMIN_LINKS = [ }, ]; -export const SidebarMenu = observer(() => { +export const AdminSidebarMenu = observer(() => { // store hooks const { isSidebarCollapsed, toggleSidebar } = useTheme(); // router diff --git a/apps/admin/app/(all)/(dashboard)/sidebar.tsx b/apps/admin/app/(all)/(dashboard)/sidebar.tsx new file mode 100644 index 00000000000..09dab86eef2 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/sidebar.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { FC, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +// plane helpers +import { useOutsideClickDetector } from "@plane/hooks"; +// hooks +import { useTheme } from "@/hooks/store"; +// components +import { AdminSidebarDropdown } from "./sidebar-dropdown"; +import { AdminSidebarHelpSection } from "./sidebar-help-section"; +import { AdminSidebarMenu } from "./sidebar-menu"; + +export const AdminSidebar: FC = observer(() => { + // store + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (isSidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(!isSidebarCollapsed); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [toggleSidebar]); + + return ( +
+
+ + + +
+
+ ); +}); diff --git a/admin/app/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx similarity index 100% rename from admin/app/workspace/create/form.tsx rename to apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx diff --git a/admin/app/workspace/create/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx similarity index 100% rename from admin/app/workspace/create/page.tsx rename to apps/admin/app/(all)/(dashboard)/workspace/create/page.tsx diff --git a/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx new file mode 100644 index 00000000000..78b0f3c4036 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Workspace Management - God Mode", +}; + +export default function WorkspaceManagementLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx new file mode 100644 index 00000000000..b8f79db04a6 --- /dev/null +++ b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +import { Loader as LoaderIcon } from "lucide-react"; +// types +import { TInstanceConfigurationKeys } from "@plane/types"; +import { Button, getButtonStyling, Loader, setPromiseToast, ToggleSwitch } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { WorkspaceListItem } from "@/components/workspace/list-item"; +// hooks +import { useInstance, useWorkspace } from "@/hooks/store"; + +const WorkspaceManagementPage = observer(() => { + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // store + const { formattedConfig, fetchInstanceConfigurations, updateInstanceConfigurations } = useInstance(); + const { + workspaceIds, + loader: workspaceLoader, + paginationInfo, + fetchWorkspaces, + fetchNextWorkspaces, + } = useWorkspace(); + // derived values + const disableWorkspaceCreation = formattedConfig?.DISABLE_WORKSPACE_CREATION ?? ""; + const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined; + + // fetch data + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + useSWR("INSTANCE_WORKSPACES", () => fetchWorkspaces()); + + const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving configuration", + success: { + title: "Success", + message: () => "Configuration saved successfully", + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + + return ( +
+
+
+
Workspaces on this instance
+
+ See all workspaces and control who can create them. +
+
+
+
+
+ {formattedConfig ? ( +
+
+
+
Prevent anyone else from creating a workspace.
+
+ Toggling this on will let only you create workspaces. You will have to invite users to new + workspaces. +
+
+
+
+
+ { + if (Boolean(parseInt(disableWorkspaceCreation)) === true) { + updateConfig("DISABLE_WORKSPACE_CREATION", "0"); + } else { + updateConfig("DISABLE_WORKSPACE_CREATION", "1"); + } + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+ ) : ( + + + + )} + {workspaceLoader !== "init-loader" ? ( + <> +
+
+
+ All workspaces on this instance{" "} + • {workspaceIds.length} + {workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && ( + + )} +
+
+ You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a + Member. +
+
+
+ + Create workspace + +
+
+
+ {workspaceIds.map((workspaceId) => ( + + ))} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : ( + + + + + + + )} +
+
+
+ ); +}); + +export default WorkspaceManagementPage; diff --git a/admin/core/components/authentication/auth-banner.tsx b/apps/admin/app/(all)/(home)/auth-banner.tsx similarity index 100% rename from admin/core/components/authentication/auth-banner.tsx rename to apps/admin/app/(all)/(home)/auth-banner.tsx diff --git a/admin/core/lib/auth-helpers.tsx b/apps/admin/app/(all)/(home)/auth-helpers.tsx similarity index 93% rename from admin/core/lib/auth-helpers.tsx rename to apps/admin/app/(all)/(home)/auth-helpers.tsx index f9882b5e512..7613548b969 100644 --- a/admin/core/lib/auth-helpers.tsx +++ b/apps/admin/app/(all)/(home)/auth-helpers.tsx @@ -7,13 +7,11 @@ import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; import { resolveGeneralTheme } from "@plane/utils"; // components -import { - EmailCodesConfiguration, - GithubConfiguration, - GitlabConfiguration, - GoogleConfiguration, - PasswordLoginConfiguration, -} from "@/components/authentication"; +import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; +import { GithubConfiguration } from "@/components/authentication/github-config"; +import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; +import { GoogleConfiguration } from "@/components/authentication/google-config"; +import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; // images import githubLightModeImage from "@/public/logos/github-black.png"; import githubDarkModeImage from "@/public/logos/github-white.png"; diff --git a/apps/admin/app/(all)/(home)/layout.tsx b/apps/admin/app/(all)/(home)/layout.tsx new file mode 100644 index 00000000000..19cab04cb01 --- /dev/null +++ b/apps/admin/app/(all)/(home)/layout.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +// logo assets +import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const { resolvedTheme } = useTheme(); + + const patternBackground = resolvedTheme === "light" ? PlaneBackgroundPattern : PlaneBackgroundPatternDark; + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( +
+
+
+
+ + Plane logo + +
+
+
+ Plane background pattern +
+
{children}
+
+
+ ); +} diff --git a/apps/admin/app/(all)/(home)/page.tsx b/apps/admin/app/(all)/(home)/page.tsx new file mode 100644 index 00000000000..80ea40ee61e --- /dev/null +++ b/apps/admin/app/(all)/(home)/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { InstanceFailureView } from "@/components/instance/failure"; +import { InstanceLoading } from "@/components/instance/loading"; +import { InstanceSetupForm } from "@/components/instance/setup-form"; +// hooks +import { useInstance } from "@/hooks/store"; +// components +import { InstanceSignInForm } from "./sign-in-form"; + +const HomePage = () => { + // store hooks + const { instance, error } = useInstance(); + + // if instance is not fetched, show loading + if (!instance && !error) { + return ( +
+ +
+ ); + } + + // if instance fetch fails, show failure view + if (error) { + return ( +
+ +
+ ); + } + + // if instance is fetched and setup is not done, show setup form + if (instance && !instance?.is_setup_done) { + return ( +
+ +
+ ); + } + + // if instance is fetched and setup is done, show sign in form + return ( +
+
+
+

+ Manage your Plane instance +

+

+ Configure instance-wide settings to secure your instance +

+
+ +
+
+ ); +}; + +export default observer(HomePage); diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx new file mode 100644 index 00000000000..12b250a93b9 --- /dev/null +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { FC, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Eye, EyeOff } from "lucide-react"; +// plane internal packages +import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; +import { AuthService } from "@plane/services"; +import { Button, Input, Spinner } from "@plane/ui"; +// components +import { Banner } from "@/components/common/banner"; +// local components +import { AuthBanner } from "./auth-banner"; +import { authErrorHandler } from "./auth-helpers"; + +// service initialization +const authService = new AuthService(); + +// error codes +enum EErrorCodes { + INSTANCE_NOT_CONFIGURED = "INSTANCE_NOT_CONFIGURED", + REQUIRED_EMAIL_PASSWORD = "REQUIRED_EMAIL_PASSWORD", + INVALID_EMAIL = "INVALID_EMAIL", + USER_DOES_NOT_EXIST = "USER_DOES_NOT_EXIST", + AUTHENTICATION_FAILED = "AUTHENTICATION_FAILED", +} + +type TError = { + type: EErrorCodes | undefined; + message: string | undefined; +}; + +// form data +type TFormData = { + email: string; + password: string; +}; + +const defaultFromData: TFormData = { + email: "", + password: "", +}; + +export const InstanceSignInForm: FC = () => { + // search params + const searchParams = useSearchParams(); + const emailParam = searchParams.get("email") || undefined; + const errorCode = searchParams.get("error_code") || undefined; + const errorMessage = searchParams.get("error_message") || undefined; + // state + const [showPassword, setShowPassword] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [formData, setFormData] = useState(defaultFromData); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + + const handleFormChange = (key: keyof TFormData, value: string | boolean) => + setFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + useEffect(() => { + if (emailParam) setFormData((prev) => ({ ...prev, email: emailParam })); + }, [emailParam]); + + // derived values + const errorData: TError = useMemo(() => { + if (errorCode && errorMessage) { + switch (errorCode) { + case EErrorCodes.INSTANCE_NOT_CONFIGURED: + return { type: EErrorCodes.INSTANCE_NOT_CONFIGURED, message: errorMessage }; + case EErrorCodes.REQUIRED_EMAIL_PASSWORD: + return { type: EErrorCodes.REQUIRED_EMAIL_PASSWORD, message: errorMessage }; + case EErrorCodes.INVALID_EMAIL: + return { type: EErrorCodes.INVALID_EMAIL, message: errorMessage }; + case EErrorCodes.USER_DOES_NOT_EXIST: + return { type: EErrorCodes.USER_DOES_NOT_EXIST, message: errorMessage }; + case EErrorCodes.AUTHENTICATION_FAILED: + return { type: EErrorCodes.AUTHENTICATION_FAILED, message: errorMessage }; + default: + return { type: undefined, message: undefined }; + } + } else return { type: undefined, message: undefined }; + }, [errorCode, errorMessage]); + + const isButtonDisabled = useMemo( + () => (!isSubmitting && formData.email && formData.password ? false : true), + [formData.email, formData.password, isSubmitting] + ); + + useEffect(() => { + if (errorCode) { + const errorDetail = authErrorHandler(errorCode?.toString() as EAdminAuthErrorCodes); + if (errorDetail) { + setErrorInfo(errorDetail); + } + } + }, [errorCode]); + + return ( +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + {errorData.type && errorData?.message ? ( + + ) : ( + <>{errorInfo && setErrorInfo(value)} />} + )} + + +
+ + handleFormChange("email", e.target.value)} + autoComplete="on" + autoFocus + /> +
+ +
+ +
+ handleFormChange("password", e.target.value)} + autoComplete="on" + /> + {showPassword ? ( + + ) : ( + + )} +
+
+
+ +
+ + ); +}; diff --git a/apps/admin/app/(all)/instance.provider.tsx b/apps/admin/app/(all)/instance.provider.tsx new file mode 100644 index 00000000000..ac8fa74e82c --- /dev/null +++ b/apps/admin/app/(all)/instance.provider.tsx @@ -0,0 +1,23 @@ +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// hooks +import { useInstance } from "@/hooks/store"; + +type InstanceProviderProps = { + children: ReactNode; +}; + +export const InstanceProvider: FC = observer((props) => { + const { children } = props; + // store hooks + const { fetchInstanceInfo } = useInstance(); + // fetching instance details + useSWR("INSTANCE_DETAILS", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + + return <>{children}; +}); diff --git a/apps/admin/app/(all)/layout.tsx b/apps/admin/app/(all)/layout.tsx new file mode 100644 index 00000000000..ddfba732a91 --- /dev/null +++ b/apps/admin/app/(all)/layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ThemeProvider } from "next-themes"; +import { SWRConfig } from "swr"; +// providers +import { InstanceProvider } from "./instance.provider"; +import { StoreProvider } from "./store.provider"; +import { ToastWithTheme } from "./toast"; +import { UserProvider } from "./user.provider"; + +const DEFAULT_SWR_CONFIG = { + refreshWhenHidden: false, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnMount: true, + refreshInterval: 600000, + errorRetryCount: 3, +}; + +export default function InstanceLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + + ); +} diff --git a/admin/core/lib/store-provider.tsx b/apps/admin/app/(all)/store.provider.tsx similarity index 100% rename from admin/core/lib/store-provider.tsx rename to apps/admin/app/(all)/store.provider.tsx diff --git a/apps/admin/app/(all)/toast.tsx b/apps/admin/app/(all)/toast.tsx new file mode 100644 index 00000000000..7d7938a9b16 --- /dev/null +++ b/apps/admin/app/(all)/toast.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { Toast } from "@plane/ui"; +import { resolveGeneralTheme } from "@plane/utils"; + +export const ToastWithTheme = () => { + const { resolvedTheme } = useTheme(); + return ; +}; diff --git a/apps/admin/app/(all)/user.provider.tsx b/apps/admin/app/(all)/user.provider.tsx new file mode 100644 index 00000000000..3a50823dcb7 --- /dev/null +++ b/apps/admin/app/(all)/user.provider.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { FC, ReactNode, useEffect } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// hooks +import { useInstance, useTheme, useUser } from "@/hooks/store"; + +interface IUserProvider { + children: ReactNode; +} + +export const UserProvider: FC = observer(({ children }) => { + // hooks + const { isSidebarCollapsed, toggleSidebar } = useTheme(); + const { currentUser, fetchCurrentUser } = useUser(); + const { fetchInstanceAdmins } = useInstance(); + + useSWR("CURRENT_USER", () => fetchCurrentUser(), { + shouldRetryOnError: false, + }); + + useSWR("INSTANCE_ADMINS", () => fetchInstanceAdmins()); + + useEffect(() => { + const localValue = localStorage && localStorage.getItem("god_mode_sidebar_collapsed"); + const localBoolValue = localValue ? (localValue === "true" ? true : false) : false; + if (isSidebarCollapsed === undefined && localBoolValue != isSidebarCollapsed) toggleSidebar(localBoolValue); + }, [isSidebarCollapsed, currentUser, toggleSidebar]); + + return <>{children}; +}); diff --git a/admin/app/error.tsx b/apps/admin/app/error.tsx similarity index 100% rename from admin/app/error.tsx rename to apps/admin/app/error.tsx diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx new file mode 100644 index 00000000000..e735723695e --- /dev/null +++ b/apps/admin/app/layout.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from "react"; +import { Metadata } from "next"; +// plane imports +import { ADMIN_BASE_PATH } from "@plane/constants"; +// styles +import "@/styles/globals.css"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage work items, sprints, and product roadmaps with peace of mind.", + url: "https://plane.so/", + }, + keywords: + "software development, customer feedback, software, accelerate, code management, release management, project management, work items tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + const ASSET_PREFIX = ADMIN_BASE_PATH; + return ( + + + + + + + + + {children} + + ); +} diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/ce/components/authentication/authentication-modes.tsx new file mode 100644 index 00000000000..c9001670292 --- /dev/null +++ b/apps/admin/ce/components/authentication/authentication-modes.tsx @@ -0,0 +1,121 @@ +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { KeyRound, Mails } from "lucide-react"; +// types +import { + TGetBaseAuthenticationModeProps, + TInstanceAuthenticationMethodKeys, + TInstanceAuthenticationModes, +} from "@plane/types"; +import { resolveGeneralTheme } from "@plane/utils"; +// components +import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; +import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; +import { GithubConfiguration } from "@/components/authentication/github-config"; +import { GitlabConfiguration } from "@/components/authentication/gitlab-config"; +import { GoogleConfiguration } from "@/components/authentication/google-config"; +import { PasswordLoginConfiguration } from "@/components/authentication/password-config-switch"; +// plane admin components +import { UpgradeButton } from "@/plane-admin/components/common"; +// assets +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +import GoogleLogo from "@/public/logos/google-logo.svg"; +import OIDCLogo from "@/public/logos/oidc-logo.svg"; +import SAMLLogo from "@/public/logos/saml-logo.svg"; + +export type TAuthenticationModeProps = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +// Authentication methods +export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ + disabled, + updateConfig, + resolvedTheme, +}) => [ + { + key: "unique-codes", + name: "Unique codes", + description: + "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", + icon: , + config: , + }, + { + key: "passwords-login", + name: "Passwords", + description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to log in or sign up for Plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "GitHub", + description: "Allow members to log in or sign up for Plane with their GitHub accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + { + key: "gitlab", + name: "GitLab", + description: "Allow members to log in or sign up to plane with their GitLab accounts.", + icon: GitLab Logo, + config: , + }, + { + key: "oidc", + name: "OIDC", + description: "Authenticate your users via the OpenID Connect protocol.", + icon: OIDC Logo, + config: , + unavailable: true, + }, + { + key: "saml", + name: "SAML", + description: "Authenticate your users via the Security Assertion Markup Language protocol.", + icon: SAML Logo, + config: , + unavailable: true, + }, +]; + +export const AuthenticationModes: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( + <> + {getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => ( + + ))} + + ); +}); diff --git a/admin/ce/components/authentication/index.ts b/apps/admin/ce/components/authentication/index.ts similarity index 100% rename from admin/ce/components/authentication/index.ts rename to apps/admin/ce/components/authentication/index.ts diff --git a/admin/ce/components/common/index.ts b/apps/admin/ce/components/common/index.ts similarity index 100% rename from admin/ce/components/common/index.ts rename to apps/admin/ce/components/common/index.ts diff --git a/admin/ce/components/common/upgrade-button.tsx b/apps/admin/ce/components/common/upgrade-button.tsx similarity index 100% rename from admin/ce/components/common/upgrade-button.tsx rename to apps/admin/ce/components/common/upgrade-button.tsx diff --git a/admin/ce/store/root.store.ts b/apps/admin/ce/store/root.store.ts similarity index 100% rename from admin/ce/store/root.store.ts rename to apps/admin/ce/store/root.store.ts diff --git a/admin/core/components/authentication/authentication-method-card.tsx b/apps/admin/core/components/authentication/authentication-method-card.tsx similarity index 100% rename from admin/core/components/authentication/authentication-method-card.tsx rename to apps/admin/core/components/authentication/authentication-method-card.tsx diff --git a/admin/core/components/authentication/email-config-switch.tsx b/apps/admin/core/components/authentication/email-config-switch.tsx similarity index 100% rename from admin/core/components/authentication/email-config-switch.tsx rename to apps/admin/core/components/authentication/email-config-switch.tsx diff --git a/admin/core/components/authentication/github-config.tsx b/apps/admin/core/components/authentication/github-config.tsx similarity index 100% rename from admin/core/components/authentication/github-config.tsx rename to apps/admin/core/components/authentication/github-config.tsx diff --git a/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/core/components/authentication/gitlab-config.tsx similarity index 100% rename from admin/core/components/authentication/gitlab-config.tsx rename to apps/admin/core/components/authentication/gitlab-config.tsx diff --git a/admin/core/components/authentication/google-config.tsx b/apps/admin/core/components/authentication/google-config.tsx similarity index 100% rename from admin/core/components/authentication/google-config.tsx rename to apps/admin/core/components/authentication/google-config.tsx diff --git a/admin/core/components/authentication/password-config-switch.tsx b/apps/admin/core/components/authentication/password-config-switch.tsx similarity index 100% rename from admin/core/components/authentication/password-config-switch.tsx rename to apps/admin/core/components/authentication/password-config-switch.tsx diff --git a/admin/core/components/common/banner.tsx b/apps/admin/core/components/common/banner.tsx similarity index 100% rename from admin/core/components/common/banner.tsx rename to apps/admin/core/components/common/banner.tsx diff --git a/admin/core/components/common/breadcrumb-link.tsx b/apps/admin/core/components/common/breadcrumb-link.tsx similarity index 100% rename from admin/core/components/common/breadcrumb-link.tsx rename to apps/admin/core/components/common/breadcrumb-link.tsx diff --git a/admin/core/components/common/code-block.tsx b/apps/admin/core/components/common/code-block.tsx similarity index 100% rename from admin/core/components/common/code-block.tsx rename to apps/admin/core/components/common/code-block.tsx diff --git a/admin/core/components/common/confirm-discard-modal.tsx b/apps/admin/core/components/common/confirm-discard-modal.tsx similarity index 100% rename from admin/core/components/common/confirm-discard-modal.tsx rename to apps/admin/core/components/common/confirm-discard-modal.tsx diff --git a/admin/core/components/common/controller-input.tsx b/apps/admin/core/components/common/controller-input.tsx similarity index 100% rename from admin/core/components/common/controller-input.tsx rename to apps/admin/core/components/common/controller-input.tsx diff --git a/admin/core/components/common/copy-field.tsx b/apps/admin/core/components/common/copy-field.tsx similarity index 100% rename from admin/core/components/common/copy-field.tsx rename to apps/admin/core/components/common/copy-field.tsx diff --git a/admin/core/components/common/empty-state.tsx b/apps/admin/core/components/common/empty-state.tsx similarity index 100% rename from admin/core/components/common/empty-state.tsx rename to apps/admin/core/components/common/empty-state.tsx diff --git a/admin/core/components/common/logo-spinner.tsx b/apps/admin/core/components/common/logo-spinner.tsx similarity index 100% rename from admin/core/components/common/logo-spinner.tsx rename to apps/admin/core/components/common/logo-spinner.tsx diff --git a/admin/core/components/common/page-header.tsx b/apps/admin/core/components/common/page-header.tsx similarity index 100% rename from admin/core/components/common/page-header.tsx rename to apps/admin/core/components/common/page-header.tsx diff --git a/admin/core/components/common/password-strength-meter.tsx b/apps/admin/core/components/common/password-strength-meter.tsx similarity index 100% rename from admin/core/components/common/password-strength-meter.tsx rename to apps/admin/core/components/common/password-strength-meter.tsx diff --git a/admin/core/components/instance/instance-failure-view.tsx b/apps/admin/core/components/instance/failure.tsx similarity index 100% rename from admin/core/components/instance/instance-failure-view.tsx rename to apps/admin/core/components/instance/failure.tsx diff --git a/admin/core/components/instance/instance-not-ready.tsx b/apps/admin/core/components/instance/instance-not-ready.tsx similarity index 100% rename from admin/core/components/instance/instance-not-ready.tsx rename to apps/admin/core/components/instance/instance-not-ready.tsx diff --git a/apps/admin/core/components/instance/loading.tsx b/apps/admin/core/components/instance/loading.tsx new file mode 100644 index 00000000000..a21319d9ee2 --- /dev/null +++ b/apps/admin/core/components/instance/loading.tsx @@ -0,0 +1,21 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +// assets +import LogoSpinnerDark from "@/public/images/logo-spinner-dark.gif"; +import LogoSpinnerLight from "@/public/images/logo-spinner-light.gif"; + +export const InstanceLoading = () => { + const { resolvedTheme } = useTheme(); + const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight; + + return ( +
+
+
+ logo +

Fetching instance details...

+
+
+
+ ); +}; diff --git a/admin/core/components/instance/setup-form.tsx b/apps/admin/core/components/instance/setup-form.tsx similarity index 99% rename from admin/core/components/instance/setup-form.tsx rename to apps/admin/core/components/instance/setup-form.tsx index fcc5c6c83df..4e771e91be8 100644 --- a/admin/core/components/instance/setup-form.tsx +++ b/apps/admin/core/components/instance/setup-form.tsx @@ -10,7 +10,8 @@ import { AuthService } from "@plane/services"; import { Button, Checkbox, Input, Spinner } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; // components -import { Banner, PasswordStrengthMeter } from "@/components/common"; +import { Banner } from "@/components/common/banner"; +import { PasswordStrengthMeter } from "@/components/common/password-strength-meter"; // service initialization const authService = new AuthService(); diff --git a/admin/core/components/new-user-popup.tsx b/apps/admin/core/components/new-user-popup.tsx similarity index 100% rename from admin/core/components/new-user-popup.tsx rename to apps/admin/core/components/new-user-popup.tsx diff --git a/admin/core/components/workspace/list-item.tsx b/apps/admin/core/components/workspace/list-item.tsx similarity index 100% rename from admin/core/components/workspace/list-item.tsx rename to apps/admin/core/components/workspace/list-item.tsx diff --git a/admin/core/hooks/store/index.ts b/apps/admin/core/hooks/store/index.ts similarity index 100% rename from admin/core/hooks/store/index.ts rename to apps/admin/core/hooks/store/index.ts diff --git a/admin/core/hooks/store/use-instance.tsx b/apps/admin/core/hooks/store/use-instance.tsx similarity index 84% rename from admin/core/hooks/store/use-instance.tsx rename to apps/admin/core/hooks/store/use-instance.tsx index cf2edc39fa4..67ac3bca889 100644 --- a/admin/core/hooks/store/use-instance.tsx +++ b/apps/admin/core/hooks/store/use-instance.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-provider"; +import { StoreContext } from "@/app/(all)/store.provider"; import { IInstanceStore } from "@/store/instance.store"; export const useInstance = (): IInstanceStore => { diff --git a/admin/core/hooks/store/use-theme.tsx b/apps/admin/core/hooks/store/use-theme.tsx similarity index 83% rename from admin/core/hooks/store/use-theme.tsx rename to apps/admin/core/hooks/store/use-theme.tsx index bad89cfeeb5..0f07149b1db 100644 --- a/admin/core/hooks/store/use-theme.tsx +++ b/apps/admin/core/hooks/store/use-theme.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-provider"; +import { StoreContext } from "@/app/(all)/store.provider"; import { IThemeStore } from "@/store/theme.store"; export const useTheme = (): IThemeStore => { diff --git a/admin/core/hooks/store/use-user.tsx b/apps/admin/core/hooks/store/use-user.tsx similarity index 83% rename from admin/core/hooks/store/use-user.tsx rename to apps/admin/core/hooks/store/use-user.tsx index 8230031447a..eaf02862e26 100644 --- a/admin/core/hooks/store/use-user.tsx +++ b/apps/admin/core/hooks/store/use-user.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-provider"; +import { StoreContext } from "@/app/(all)/store.provider"; import { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { diff --git a/admin/core/hooks/store/use-workspace.tsx b/apps/admin/core/hooks/store/use-workspace.tsx similarity index 84% rename from admin/core/hooks/store/use-workspace.tsx rename to apps/admin/core/hooks/store/use-workspace.tsx index e3bde92d530..2203ec948ca 100644 --- a/admin/core/hooks/store/use-workspace.tsx +++ b/apps/admin/core/hooks/store/use-workspace.tsx @@ -1,6 +1,6 @@ import { useContext } from "react"; // store -import { StoreContext } from "@/lib/store-provider"; +import { StoreContext } from "@/app/(all)/store.provider"; import { IWorkspaceStore } from "@/store/workspace.store"; export const useWorkspace = (): IWorkspaceStore => { diff --git a/admin/core/store/instance.store.ts b/apps/admin/core/store/instance.store.ts similarity index 87% rename from admin/core/store/instance.store.ts rename to apps/admin/core/store/instance.store.ts index 33954fe73a9..1179f04d6fd 100644 --- a/admin/core/store/instance.store.ts +++ b/apps/admin/core/store/instance.store.ts @@ -32,6 +32,7 @@ export interface IInstanceStore { fetchInstanceAdmins: () => Promise; fetchInstanceConfigurations: () => Promise; updateInstanceConfigurations: (data: Partial) => Promise; + disableEmail: () => Promise; } export class InstanceStore implements IInstanceStore { @@ -100,7 +101,7 @@ export class InstanceStore implements IInstanceStore { if (this.instance === undefined && !instanceInfo?.instance?.workspaces_exist) this.store.theme.toggleNewUserPopup(); runInAction(() => { - console.log("instanceInfo: ", instanceInfo); + // console.log("instanceInfo: ", instanceInfo); this.isLoading = false; this.instance = instanceInfo.instance; this.config = instanceInfo.config; @@ -187,4 +188,30 @@ export class InstanceStore implements IInstanceStore { throw error; } }; + + disableEmail = async () => { + const instanceConfigurations = this.instanceConfigurations; + try { + runInAction(() => { + this.instanceConfigurations = this.instanceConfigurations?.map((config) => { + if ( + [ + "EMAIL_HOST", + "EMAIL_PORT", + "EMAIL_HOST_USER", + "EMAIL_HOST_PASSWORD", + "EMAIL_FROM", + "ENABLE_SMTP", + ].includes(config.key) + ) + return { ...config, value: "" }; + return config; + }); + }); + await this.instanceService.disableEmail(); + } catch (error) { + console.error("Error disabling the email"); + this.instanceConfigurations = instanceConfigurations; + } + }; } diff --git a/admin/core/store/root.store.ts b/apps/admin/core/store/root.store.ts similarity index 100% rename from admin/core/store/root.store.ts rename to apps/admin/core/store/root.store.ts diff --git a/admin/core/store/theme.store.ts b/apps/admin/core/store/theme.store.ts similarity index 100% rename from admin/core/store/theme.store.ts rename to apps/admin/core/store/theme.store.ts diff --git a/admin/core/store/user.store.ts b/apps/admin/core/store/user.store.ts similarity index 100% rename from admin/core/store/user.store.ts rename to apps/admin/core/store/user.store.ts diff --git a/admin/core/store/workspace.store.ts b/apps/admin/core/store/workspace.store.ts similarity index 100% rename from admin/core/store/workspace.store.ts rename to apps/admin/core/store/workspace.store.ts diff --git a/admin/ee/components/authentication/authentication-modes.tsx b/apps/admin/ee/components/authentication/authentication-modes.tsx similarity index 100% rename from admin/ee/components/authentication/authentication-modes.tsx rename to apps/admin/ee/components/authentication/authentication-modes.tsx diff --git a/admin/ee/components/authentication/index.ts b/apps/admin/ee/components/authentication/index.ts similarity index 100% rename from admin/ee/components/authentication/index.ts rename to apps/admin/ee/components/authentication/index.ts diff --git a/admin/ee/components/common/index.ts b/apps/admin/ee/components/common/index.ts similarity index 100% rename from admin/ee/components/common/index.ts rename to apps/admin/ee/components/common/index.ts diff --git a/admin/ee/store/root.store.ts b/apps/admin/ee/store/root.store.ts similarity index 100% rename from admin/ee/store/root.store.ts rename to apps/admin/ee/store/root.store.ts diff --git a/admin/next-env.d.ts b/apps/admin/next-env.d.ts similarity index 100% rename from admin/next-env.d.ts rename to apps/admin/next-env.d.ts diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js new file mode 100644 index 00000000000..c848e0b9255 --- /dev/null +++ b/apps/admin/next.config.js @@ -0,0 +1,29 @@ +/** @type {import('next').NextConfig} */ + +const nextConfig = { + trailingSlash: true, + reactStrictMode: false, + swcMinify: true, + output: "standalone", + images: { + unoptimized: true, + }, + basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "", + experimental: { + optimizePackageImports: [ + "@plane/constants", + "@plane/editor", + "@plane/hooks", + "@plane/i18n", + "@plane/logger", + "@plane/propel", + "@plane/services", + "@plane/shared-state", + "@plane/types", + "@plane/ui", + "@plane/utils", + ], + }, +}; + +module.exports = nextConfig; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 00000000000..556c29a5854 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,57 @@ +{ + "name": "admin", + "description": "Admin UI for Plane", + "version": "0.28.0", + "license": "AGPL-3.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "preview": "next build && next start", + "start": "next start", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", + "check:lint": "eslint . --max-warnings 0", + "check:types": "tsc --noEmit", + "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "fix:lint": "eslint . --fix", + "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"" + }, + "dependencies": { + "@headlessui/react": "^1.7.19", + "@plane/constants": "*", + "@plane/hooks": "*", + "@plane/propel": "*", + "@plane/services": "*", + "@plane/types": "*", + "@plane/ui": "*", + "@plane/utils": "*", + "@tailwindcss/typography": "^0.5.9", + "@types/lodash": "^4.17.0", + "autoprefixer": "10.4.14", + "axios": "1.11.0", + "lodash": "^4.17.21", + "lucide-react": "^0.469.0", + "mobx": "^6.12.0", + "mobx-react": "^9.1.1", + "next": "14.2.30", + "next-themes": "^0.2.1", + "postcss": "^8.4.49", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "7.51.5", + "swr": "^2.2.4", + "uuid": "^9.0.1", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", + "@plane/typescript-config": "*", + "@types/node": "18.16.1", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.2.18", + "@types/uuid": "^9.0.8", + "@types/zxcvbn": "^4.4.4", + "typescript": "5.8.3" + } +} diff --git a/admin/postcss.config.js b/apps/admin/postcss.config.js similarity index 100% rename from admin/postcss.config.js rename to apps/admin/postcss.config.js diff --git a/admin/public/auth/background-pattern-dark.svg b/apps/admin/public/auth/background-pattern-dark.svg similarity index 100% rename from admin/public/auth/background-pattern-dark.svg rename to apps/admin/public/auth/background-pattern-dark.svg diff --git a/admin/public/auth/background-pattern.svg b/apps/admin/public/auth/background-pattern.svg similarity index 100% rename from admin/public/auth/background-pattern.svg rename to apps/admin/public/auth/background-pattern.svg diff --git a/admin/public/favicon/android-chrome-192x192.png b/apps/admin/public/favicon/android-chrome-192x192.png similarity index 100% rename from admin/public/favicon/android-chrome-192x192.png rename to apps/admin/public/favicon/android-chrome-192x192.png diff --git a/admin/public/favicon/android-chrome-512x512.png b/apps/admin/public/favicon/android-chrome-512x512.png similarity index 100% rename from admin/public/favicon/android-chrome-512x512.png rename to apps/admin/public/favicon/android-chrome-512x512.png diff --git a/admin/public/favicon/apple-touch-icon.png b/apps/admin/public/favicon/apple-touch-icon.png similarity index 100% rename from admin/public/favicon/apple-touch-icon.png rename to apps/admin/public/favicon/apple-touch-icon.png diff --git a/admin/public/favicon/favicon-16x16.png b/apps/admin/public/favicon/favicon-16x16.png similarity index 100% rename from admin/public/favicon/favicon-16x16.png rename to apps/admin/public/favicon/favicon-16x16.png diff --git a/admin/public/favicon/favicon-32x32.png b/apps/admin/public/favicon/favicon-32x32.png similarity index 100% rename from admin/public/favicon/favicon-32x32.png rename to apps/admin/public/favicon/favicon-32x32.png diff --git a/admin/public/favicon/favicon.ico b/apps/admin/public/favicon/favicon.ico similarity index 100% rename from admin/public/favicon/favicon.ico rename to apps/admin/public/favicon/favicon.ico diff --git a/admin/public/favicon/site.webmanifest b/apps/admin/public/favicon/site.webmanifest similarity index 100% rename from admin/public/favicon/site.webmanifest rename to apps/admin/public/favicon/site.webmanifest diff --git a/admin/public/images/logo-spinner-dark.gif b/apps/admin/public/images/logo-spinner-dark.gif similarity index 100% rename from admin/public/images/logo-spinner-dark.gif rename to apps/admin/public/images/logo-spinner-dark.gif diff --git a/admin/public/images/logo-spinner-light.gif b/apps/admin/public/images/logo-spinner-light.gif similarity index 100% rename from admin/public/images/logo-spinner-light.gif rename to apps/admin/public/images/logo-spinner-light.gif diff --git a/admin/public/images/plane-takeoff.png b/apps/admin/public/images/plane-takeoff.png similarity index 100% rename from admin/public/images/plane-takeoff.png rename to apps/admin/public/images/plane-takeoff.png diff --git a/admin/public/instance/instance-failure-dark.svg b/apps/admin/public/instance/instance-failure-dark.svg similarity index 100% rename from admin/public/instance/instance-failure-dark.svg rename to apps/admin/public/instance/instance-failure-dark.svg diff --git a/admin/public/instance/instance-failure.svg b/apps/admin/public/instance/instance-failure.svg similarity index 100% rename from admin/public/instance/instance-failure.svg rename to apps/admin/public/instance/instance-failure.svg diff --git a/admin/public/instance/plane-takeoff.png b/apps/admin/public/instance/plane-takeoff.png similarity index 100% rename from admin/public/instance/plane-takeoff.png rename to apps/admin/public/instance/plane-takeoff.png diff --git a/admin/public/logos/github-black.png b/apps/admin/public/logos/github-black.png similarity index 100% rename from admin/public/logos/github-black.png rename to apps/admin/public/logos/github-black.png diff --git a/admin/public/logos/github-white.png b/apps/admin/public/logos/github-white.png similarity index 100% rename from admin/public/logos/github-white.png rename to apps/admin/public/logos/github-white.png diff --git a/admin/public/logos/gitlab-logo.svg b/apps/admin/public/logos/gitlab-logo.svg similarity index 100% rename from admin/public/logos/gitlab-logo.svg rename to apps/admin/public/logos/gitlab-logo.svg diff --git a/admin/public/logos/google-logo.svg b/apps/admin/public/logos/google-logo.svg similarity index 100% rename from admin/public/logos/google-logo.svg rename to apps/admin/public/logos/google-logo.svg diff --git a/admin/public/logos/oidc-logo.svg b/apps/admin/public/logos/oidc-logo.svg similarity index 100% rename from admin/public/logos/oidc-logo.svg rename to apps/admin/public/logos/oidc-logo.svg diff --git a/admin/public/logos/saml-logo.svg b/apps/admin/public/logos/saml-logo.svg similarity index 100% rename from admin/public/logos/saml-logo.svg rename to apps/admin/public/logos/saml-logo.svg diff --git a/admin/public/logos/takeoff-icon-dark.svg b/apps/admin/public/logos/takeoff-icon-dark.svg similarity index 100% rename from admin/public/logos/takeoff-icon-dark.svg rename to apps/admin/public/logos/takeoff-icon-dark.svg diff --git a/admin/public/logos/takeoff-icon-light.svg b/apps/admin/public/logos/takeoff-icon-light.svg similarity index 100% rename from admin/public/logos/takeoff-icon-light.svg rename to apps/admin/public/logos/takeoff-icon-light.svg diff --git a/admin/public/plane-logos/black-horizontal-with-blue-logo.png b/apps/admin/public/plane-logos/black-horizontal-with-blue-logo.png similarity index 100% rename from admin/public/plane-logos/black-horizontal-with-blue-logo.png rename to apps/admin/public/plane-logos/black-horizontal-with-blue-logo.png diff --git a/admin/public/plane-logos/blue-without-text.png b/apps/admin/public/plane-logos/blue-without-text.png similarity index 100% rename from admin/public/plane-logos/blue-without-text.png rename to apps/admin/public/plane-logos/blue-without-text.png diff --git a/admin/public/plane-logos/white-horizontal-with-blue-logo.png b/apps/admin/public/plane-logos/white-horizontal-with-blue-logo.png similarity index 100% rename from admin/public/plane-logos/white-horizontal-with-blue-logo.png rename to apps/admin/public/plane-logos/white-horizontal-with-blue-logo.png diff --git a/admin/public/site.webmanifest.json b/apps/admin/public/site.webmanifest.json similarity index 100% rename from admin/public/site.webmanifest.json rename to apps/admin/public/site.webmanifest.json diff --git a/admin/styles/globals.css b/apps/admin/styles/globals.css similarity index 100% rename from admin/styles/globals.css rename to apps/admin/styles/globals.css diff --git a/admin/tailwind.config.js b/apps/admin/tailwind.config.js similarity index 100% rename from admin/tailwind.config.js rename to apps/admin/tailwind.config.js diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 00000000000..d85abf2cc9a --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@plane/typescript-config/nextjs.json", + "compilerOptions": { + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "@/app/*": ["app/*"], + "@/*": ["core/*"], + "@/public/*": ["public/*"], + "@/plane-admin/*": ["ce/*"], + "@/styles/*": ["styles/*"] + }, + "strictNullChecks": true + }, + "include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apiserver/.coveragerc b/apps/api/.coveragerc similarity index 100% rename from apiserver/.coveragerc rename to apps/api/.coveragerc diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 00000000000..f158e3d7cc9 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,72 @@ +# Backend +# Debug value for api server use it as 0 for production use +DEBUG=0 +CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3100" + +# Database Settings +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="plane" +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" +REDIS_URL="redis://${REDIS_HOST}:6379/" + +# RabbitMQ Settings +RABBITMQ_HOST="plane-mq" +RABBITMQ_PORT="5672" +RABBITMQ_USER="plane" +RABBITMQ_PASSWORD="plane" +RABBITMQ_VHOST="plane" + +# AWS Settings +AWS_REGION="" +AWS_ACCESS_KEY_ID="access-key" +AWS_SECRET_ACCESS_KEY="secret-key" +AWS_S3_ENDPOINT_URL="http://localhost:9000" +# Changing this requires change in the proxy config for uploads if using minio setup +AWS_S3_BUCKET_NAME="uploads" +# Maximum file upload limit +FILE_SIZE_LIMIT=5242880 + +# Settings related to Docker +DOCKERIZED=1 # deprecated + +# set to 1 If using the pre-configured minio setup +USE_MINIO=0 + + + +# Email redirections and minio domain settings +WEB_URL="http://localhost:8000" + +# Gunicorn Workers +GUNICORN_WORKERS=2 + +# Base URLs +ADMIN_BASE_URL="http://localhost:3001" +ADMIN_BASE_PATH="/god-mode" + +SPACE_BASE_URL="http://localhost:3002" +SPACE_BASE_PATH="/spaces" + +APP_BASE_URL="http://localhost:3000" +APP_BASE_PATH="" + +LIVE_BASE_URL="http://localhost:3100" +LIVE_BASE_PATH="/live" + +LIVE_SERVER_SECRET_KEY="secret-key" + +# Hard delete files after days +HARD_DELETE_AFTER_DAYS=60 + +# Force HTTPS for handling SSL Termination +MINIO_ENDPOINT_SSL=0 + +# API key rate limit +API_KEY_RATE_LIMIT="60/minute" diff --git a/apps/api/Dockerfile.api b/apps/api/Dockerfile.api new file mode 100644 index 00000000000..132514811c2 --- /dev/null +++ b/apps/api/Dockerfile.api @@ -0,0 +1,58 @@ +FROM python:3.12.10-alpine + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +ENV INSTANCE_CHANGELOG_URL=https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/ + +# Update system packages for security +RUN apk update && apk upgrade + +WORKDIR /code + +RUN apk add --no-cache --upgrade \ + "libpq" \ + "libxslt" \ + "xmlsec" \ + "ca-certificates" \ + "openssl" + +COPY requirements.txt ./ +COPY requirements ./requirements +RUN apk add --no-cache libffi-dev +RUN apk add --no-cache --virtual .build-deps \ + "bash~=5.2" \ + "g++" \ + "gcc" \ + "cargo" \ + "git" \ + "make" \ + "postgresql-dev" \ + "libc-dev" \ + "linux-headers" \ + && \ + pip install -r requirements.txt --compile --no-cache-dir \ + && \ + apk del .build-deps \ + && \ + rm -rf /var/cache/apk/* + + +# Add in Django deps and generate Django's static files +COPY manage.py manage.py +COPY plane plane/ +COPY templates templates/ +COPY package.json package.json + +RUN apk --no-cache add "bash~=5.2" +COPY ./bin ./bin/ + +RUN mkdir -p /code/plane/logs +RUN chmod +x ./bin/* +RUN chmod -R 777 /code + +# Expose container port and run entry point script +EXPOSE 8000 + +CMD ["./bin/docker-entrypoint-api.sh"] \ No newline at end of file diff --git a/apiserver/Dockerfile.dev b/apps/api/Dockerfile.dev similarity index 100% rename from apiserver/Dockerfile.dev rename to apps/api/Dockerfile.dev diff --git a/apiserver/bin/docker-entrypoint-api-local.sh b/apps/api/bin/docker-entrypoint-api-local.sh similarity index 100% rename from apiserver/bin/docker-entrypoint-api-local.sh rename to apps/api/bin/docker-entrypoint-api-local.sh diff --git a/apiserver/bin/docker-entrypoint-api.sh b/apps/api/bin/docker-entrypoint-api.sh similarity index 100% rename from apiserver/bin/docker-entrypoint-api.sh rename to apps/api/bin/docker-entrypoint-api.sh diff --git a/apiserver/bin/docker-entrypoint-beat.sh b/apps/api/bin/docker-entrypoint-beat.sh similarity index 100% rename from apiserver/bin/docker-entrypoint-beat.sh rename to apps/api/bin/docker-entrypoint-beat.sh diff --git a/apiserver/bin/docker-entrypoint-migrator.sh b/apps/api/bin/docker-entrypoint-migrator.sh similarity index 100% rename from apiserver/bin/docker-entrypoint-migrator.sh rename to apps/api/bin/docker-entrypoint-migrator.sh diff --git a/apiserver/bin/docker-entrypoint-worker.sh b/apps/api/bin/docker-entrypoint-worker.sh similarity index 100% rename from apiserver/bin/docker-entrypoint-worker.sh rename to apps/api/bin/docker-entrypoint-worker.sh diff --git a/apiserver/manage.py b/apps/api/manage.py similarity index 100% rename from apiserver/manage.py rename to apps/api/manage.py diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000000..6b374e61164 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,7 @@ +{ + "name": "plane-api", + "version": "0.28.0", + "license": "AGPL-3.0", + "private": true, + "description": "API server powering Plane's backend" +} diff --git a/apiserver/plane/__init__.py b/apps/api/plane/__init__.py similarity index 100% rename from apiserver/plane/__init__.py rename to apps/api/plane/__init__.py diff --git a/apiserver/plane/analytics/__init__.py b/apps/api/plane/analytics/__init__.py similarity index 100% rename from apiserver/plane/analytics/__init__.py rename to apps/api/plane/analytics/__init__.py diff --git a/apiserver/plane/analytics/apps.py b/apps/api/plane/analytics/apps.py similarity index 100% rename from apiserver/plane/analytics/apps.py rename to apps/api/plane/analytics/apps.py diff --git a/apiserver/plane/api/__init__.py b/apps/api/plane/api/__init__.py similarity index 100% rename from apiserver/plane/api/__init__.py rename to apps/api/plane/api/__init__.py diff --git a/apiserver/plane/api/apps.py b/apps/api/plane/api/apps.py similarity index 100% rename from apiserver/plane/api/apps.py rename to apps/api/plane/api/apps.py diff --git a/apiserver/plane/api/middleware/__init__.py b/apps/api/plane/api/middleware/__init__.py similarity index 100% rename from apiserver/plane/api/middleware/__init__.py rename to apps/api/plane/api/middleware/__init__.py diff --git a/apiserver/plane/api/middleware/api_authentication.py b/apps/api/plane/api/middleware/api_authentication.py similarity index 100% rename from apiserver/plane/api/middleware/api_authentication.py rename to apps/api/plane/api/middleware/api_authentication.py diff --git a/apiserver/plane/api/rate_limit.py b/apps/api/plane/api/rate_limit.py similarity index 100% rename from apiserver/plane/api/rate_limit.py rename to apps/api/plane/api/rate_limit.py diff --git a/apiserver/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py similarity index 100% rename from apiserver/plane/api/serializers/__init__.py rename to apps/api/plane/api/serializers/__init__.py diff --git a/apiserver/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py similarity index 100% rename from apiserver/plane/api/serializers/base.py rename to apps/api/plane/api/serializers/base.py diff --git a/apiserver/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py similarity index 100% rename from apiserver/plane/api/serializers/cycle.py rename to apps/api/plane/api/serializers/cycle.py diff --git a/apiserver/plane/api/serializers/estimate.py b/apps/api/plane/api/serializers/estimate.py similarity index 100% rename from apiserver/plane/api/serializers/estimate.py rename to apps/api/plane/api/serializers/estimate.py diff --git a/apiserver/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py similarity index 100% rename from apiserver/plane/api/serializers/intake.py rename to apps/api/plane/api/serializers/intake.py diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py new file mode 100644 index 00000000000..20f967e3be5 --- /dev/null +++ b/apps/api/plane/api/serializers/issue.py @@ -0,0 +1,522 @@ +# Django imports +from django.utils import timezone +from lxml import html +from django.db import IntegrityError + +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.db.models import ( + Issue, + IssueType, + IssueActivity, + IssueAssignee, + FileAsset, + IssueComment, + IssueLabel, + IssueLink, + Label, + ProjectMember, + State, + User, + EstimatePoint, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) + +from .base import BaseSerializer +from .cycle import CycleLiteSerializer, CycleSerializer +from .module import ModuleLiteSerializer, ModuleSerializer +from .state import StateLiteSerializer +from .user import UserLiteSerializer + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator + + +class IssueSerializer(BaseSerializer): + assignees = serializers.ListField( + child=serializers.PrimaryKeyRelatedField( + queryset=User.objects.values_list("id", flat=True) + ), + write_only=True, + required=False, + ) + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField( + queryset=Label.objects.values_list("id", flat=True) + ), + write_only=True, + required=False, + ) + type_id = serializers.PrimaryKeyRelatedField( + source="type", queryset=IssueType.objects.all(), required=False, allow_null=True + ) + + class Meta: + model = Issue + read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"] + exclude = ["description", "description_stripped"] + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + try: + if data.get("description_html", None) is not None: + parsed = html.fromstring(data["description_html"]) + parsed_str = html.tostring(parsed, encoding="unicode") + data["description_html"] = parsed_str + + except Exception: + raise serializers.ValidationError("Invalid HTML passed") + + # Validate description content for security + if data.get("description"): + is_valid, error_msg = validate_json_content(data["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if data.get("description_html"): + is_valid, error_msg = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + if data.get("description_binary"): + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": error_msg}) + + # Validate assignees are from project + if data.get("assignees", []): + data["assignees"] = ProjectMember.objects.filter( + project_id=self.context.get("project_id"), + is_active=True, + role__gte=15, + member_id__in=data["assignees"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if data.get("labels", []): + data["labels"] = Label.objects.filter( + project_id=self.context.get("project_id"), id__in=data["labels"] + ).values_list("id", flat=True) + + # Check state is from the project only else raise validation error + if ( + data.get("state") + and not State.objects.filter( + project_id=self.context.get("project_id"), pk=data.get("state").id + ).exists() + ): + raise serializers.ValidationError( + "State is not valid please pass a valid state_id" + ) + + # Check parent issue is from workspace as it can be cross workspace + if ( + data.get("parent") + and not Issue.objects.filter( + workspace_id=self.context.get("workspace_id"), + project_id=self.context.get("project_id"), + pk=data.get("parent").id, + ).exists() + ): + raise serializers.ValidationError( + "Parent is not valid issue_id please pass a valid issue_id" + ) + + if ( + data.get("estimate_point") + and not EstimatePoint.objects.filter( + workspace_id=self.context.get("workspace_id"), + project_id=self.context.get("project_id"), + pk=data.get("estimate_point").id, + ).exists() + ): + raise serializers.ValidationError( + "Estimate point is not valid please pass a valid estimate_point_id" + ) + + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue_type = validated_data.pop("type", None) + + if not issue_type: + # Get default issue type + issue_type = IssueType.objects.filter( + project_issue_types__project_id=project_id, is_default=True + ).first() + issue_type = issue_type + + issue = Issue.objects.create( + **validated_data, project_id=project_id, type=issue_type + ) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + except IntegrityError: + pass + else: + try: + # Then assign it to default assignee, if it is a valid assignee + if ( + default_assignee_id is not None + and ProjectMember.objects.filter( + member_id=default_assignee_id, + project_id=project_id, + role__gte=15, + is_active=True, + ).exists() + ): + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + except IntegrityError: + pass + + if labels is not None and len(labels): + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ) + except IntegrityError: + pass + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + def to_representation(self, instance): + data = super().to_representation(instance) + if "assignees" in self.fields: + if "assignees" in self.expand: + from .user import UserLiteSerializer + + data["assignees"] = UserLiteSerializer( + User.objects.filter( + pk__in=IssueAssignee.objects.filter(issue=instance).values_list( + "assignee_id", flat=True + ) + ), + many=True, + ).data + else: + data["assignees"] = [ + str(assignee) + for assignee in IssueAssignee.objects.filter( + issue=instance + ).values_list("assignee_id", flat=True) + ] + if "labels" in self.fields: + if "labels" in self.expand: + data["labels"] = LabelSerializer( + Label.objects.filter( + pk__in=IssueLabel.objects.filter(issue=instance).values_list( + "label_id", flat=True + ) + ), + many=True, + ).data + else: + data["labels"] = [ + str(label) + for label in IssueLabel.objects.filter(issue=instance).values_list( + "label_id", flat=True + ) + ] + + return data + + +class IssueLiteSerializer(BaseSerializer): + class Meta: + model = Issue + fields = ["id", "sequence_id", "project_id"] + read_only_fields = fields + + +class LabelSerializer(BaseSerializer): + class Meta: + model = Label + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + +class IssueLinkSerializer(BaseSerializer): + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def validate_url(self, value): + # Check URL format + validate_url = URLValidator() + try: + validate_url(value) + except ValidationError: + raise serializers.ValidationError("Invalid URL format.") + + # Check URL scheme + if not value.startswith(("http://", "https://")): + raise serializers.ValidationError("Invalid URL scheme.") + + return value + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return IssueLink.objects.create(**validated_data) + + def update(self, instance, validated_data): + if ( + IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=instance.issue_id + ) + .exclude(pk=instance.id) + .exists() + ): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + + return super().update(instance, validated_data) + + +class IssueAttachmentSerializer(BaseSerializer): + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "updated_by", + "updated_at", + ] + + +class IssueCommentSerializer(BaseSerializer): + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + read_only_fields = [ + "id", + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + exclude = ["comment_stripped", "comment_json"] + + def validate(self, data): + try: + if data.get("comment_html", None) is not None: + parsed = html.fromstring(data["comment_html"]) + parsed_str = html.tostring(parsed, encoding="unicode") + data["comment_html"] = parsed_str + + except Exception: + raise serializers.ValidationError("Invalid HTML passed") + return data + + +class IssueActivitySerializer(BaseSerializer): + class Meta: + model = IssueActivity + exclude = ["created_by", "updated_by"] + + +class CycleIssueSerializer(BaseSerializer): + cycle = CycleSerializer(read_only=True) + + class Meta: + fields = ["cycle"] + + +class ModuleIssueSerializer(BaseSerializer): + module = ModuleSerializer(read_only=True) + + class Meta: + fields = ["module"] + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = ["id", "name", "color"] + + +class IssueExpandSerializer(BaseSerializer): + cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) + module = ModuleLiteSerializer(source="issue_module.module", read_only=True) + + labels = serializers.SerializerMethodField() + assignees = serializers.SerializerMethodField() + state = StateLiteSerializer(read_only=True) + + + def get_labels(self, obj): + expand = self.context.get("expand", []) + if "labels" in expand: + # Use prefetched data + return LabelLiteSerializer( + [il.label for il in obj.label_issue.all()], many=True + ).data + return [il.label_id for il in obj.label_issue.all()] + + def get_assignees(self, obj): + expand = self.context.get("expand", []) + if "assignees" in expand: + return UserLiteSerializer( + [ia.assignee for ia in obj.issue_assignee.all()], many=True + ).data + return [ia.assignee_id for ia in obj.issue_assignee.all()] + + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] diff --git a/apiserver/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py similarity index 100% rename from apiserver/plane/api/serializers/module.py rename to apps/api/plane/api/serializers/module.py diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py new file mode 100644 index 00000000000..10ae7f4de32 --- /dev/null +++ b/apps/api/plane/api/serializers/project.py @@ -0,0 +1,126 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.db.models import Project, ProjectIdentifier, WorkspaceMember +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) + +from .base import BaseSerializer + + +class ProjectSerializer(BaseSerializer): + total_members = serializers.IntegerField(read_only=True) + total_cycles = serializers.IntegerField(read_only=True) + total_modules = serializers.IntegerField(read_only=True) + is_member = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + is_deployed = serializers.BooleanField(read_only=True) + cover_image_url = serializers.CharField(read_only=True) + + class Meta: + model = Project + fields = "__all__" + read_only_fields = [ + "id", + "emoji", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + "deleted_at", + "cover_image_url", + ] + + def validate(self, data): + # Check project lead should be a member of the workspace + if ( + data.get("project_lead", None) is not None + and not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("project_lead"), + ).exists() + ): + raise serializers.ValidationError( + "Project lead should be a user in the workspace" + ) + + # Check default assignee should be a member of the workspace + if ( + data.get("default_assignee", None) is not None + and not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("default_assignee"), + ).exists() + ): + raise serializers.ValidationError( + "Default assignee should be a user in the workspace" + ) + + # Validate description content for security + if "description" in data and data["description"]: + # For Project, description might be text field, not JSON + if isinstance(data["description"], dict): + is_valid, error_msg = validate_json_content(data["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if "description_text" in data and data["description_text"]: + is_valid, error_msg = validate_json_content(data["description_text"]) + if not is_valid: + raise serializers.ValidationError({"description_text": error_msg}) + + if "description_html" in data and data["description_html"]: + if isinstance(data["description_html"], dict): + is_valid, error_msg = validate_json_content(data["description_html"]) + else: + is_valid, error_msg = validate_html_content( + str(data["description_html"]) + ) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + return data + + def create(self, validated_data): + identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": + raise serializers.ValidationError(detail="Project Identifier is required") + + if ProjectIdentifier.objects.filter( + name=identifier, workspace_id=self.context["workspace_id"] + ).exists(): + raise serializers.ValidationError(detail="Project Identifier is taken") + + project = Project.objects.create( + **validated_data, workspace_id=self.context["workspace_id"] + ) + _ = ProjectIdentifier.objects.create( + name=project.identifier, + project=project, + workspace_id=self.context["workspace_id"], + ) + return project + + +class ProjectLiteSerializer(BaseSerializer): + cover_image_url = serializers.CharField(read_only=True) + + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "icon_prop", + "emoji", + "description", + "cover_image_url", + ] + read_only_fields = fields diff --git a/apiserver/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py similarity index 100% rename from apiserver/plane/api/serializers/state.py rename to apps/api/plane/api/serializers/state.py diff --git a/apiserver/plane/api/serializers/user.py b/apps/api/plane/api/serializers/user.py similarity index 100% rename from apiserver/plane/api/serializers/user.py rename to apps/api/plane/api/serializers/user.py diff --git a/apiserver/plane/api/serializers/workspace.py b/apps/api/plane/api/serializers/workspace.py similarity index 100% rename from apiserver/plane/api/serializers/workspace.py rename to apps/api/plane/api/serializers/workspace.py diff --git a/apiserver/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py similarity index 100% rename from apiserver/plane/api/urls/__init__.py rename to apps/api/plane/api/urls/__init__.py diff --git a/apiserver/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py similarity index 100% rename from apiserver/plane/api/urls/cycle.py rename to apps/api/plane/api/urls/cycle.py diff --git a/apiserver/plane/api/urls/intake.py b/apps/api/plane/api/urls/intake.py similarity index 100% rename from apiserver/plane/api/urls/intake.py rename to apps/api/plane/api/urls/intake.py diff --git a/apiserver/plane/api/urls/issue.py b/apps/api/plane/api/urls/issue.py similarity index 100% rename from apiserver/plane/api/urls/issue.py rename to apps/api/plane/api/urls/issue.py diff --git a/apiserver/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py similarity index 100% rename from apiserver/plane/api/urls/member.py rename to apps/api/plane/api/urls/member.py diff --git a/apiserver/plane/api/urls/module.py b/apps/api/plane/api/urls/module.py similarity index 100% rename from apiserver/plane/api/urls/module.py rename to apps/api/plane/api/urls/module.py diff --git a/apiserver/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py similarity index 100% rename from apiserver/plane/api/urls/project.py rename to apps/api/plane/api/urls/project.py diff --git a/apiserver/plane/api/urls/state.py b/apps/api/plane/api/urls/state.py similarity index 100% rename from apiserver/plane/api/urls/state.py rename to apps/api/plane/api/urls/state.py diff --git a/apiserver/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py similarity index 100% rename from apiserver/plane/api/views/__init__.py rename to apps/api/plane/api/views/__init__.py diff --git a/apiserver/plane/api/views/base.py b/apps/api/plane/api/views/base.py similarity index 100% rename from apiserver/plane/api/views/base.py rename to apps/api/plane/api/views/base.py diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py new file mode 100644 index 00000000000..457671b93a1 --- /dev/null +++ b/apps/api/plane/api/views/cycle.py @@ -0,0 +1,1205 @@ +# Python imports +import json + +# Django imports +from django.core import serializers +from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Count, + F, + Func, + OuterRef, + Q, + Sum, + FloatField, + Case, + When, + Value, +) +from django.db.models.functions import Cast, Concat +from django.db import models + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.api.serializers import CycleIssueSerializer, CycleSerializer, IssueSerializer +from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Cycle, + CycleIssue, + Issue, + Project, + FileAsset, + IssueLink, + ProjectMember, + UserFavorite, +) +from plane.utils.analytics_plot import burndown_plot +from plane.utils.host import base_host +from .base import BaseAPIView +from plane.bgtasks.webhook_task import model_activity + + +class CycleAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to cycle. + + """ + + serializer_class = CycleSerializer + model = Cycle + webhook_event = "cycle" + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id, pk=None): + project = Project.objects.get(workspace__slug=slug, pk=project_id) + if pk: + queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) + data = CycleSerializer( + queryset, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data + return Response(data, status=status.HTTP_200_OK) + queryset = self.get_queryset().filter(archived_at__isnull=True) + cycle_view = request.GET.get("cycle_view", "all") + + # Current Cycle + if cycle_view == "current": + queryset = queryset.filter( + start_date__lte=timezone.now(), end_date__gte=timezone.now() + ) + data = CycleSerializer( + queryset, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data + return Response(data, status=status.HTTP_200_OK) + + # Upcoming Cycles + if cycle_view == "upcoming": + queryset = queryset.filter(start_date__gt=timezone.now()) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + # Completed Cycles + if cycle_view == "completed": + queryset = queryset.filter(end_date__lt=timezone.now()) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + # Draft Cycles + if cycle_view == "draft": + queryset = queryset.filter(end_date=None, start_date=None) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + # Incomplete Cycles + if cycle_view == "incomplete": + queryset = queryset.filter( + Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True) + ) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + return self.paginate( + request=request, + queryset=(queryset), + on_results=lambda cycles: CycleSerializer( + cycles, + many=True, + fields=self.fields, + expand=self.expand, + context={"project": project}, + ).data, + ) + + def post(self, request, slug, project_id): + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None + ): + serializer = CycleSerializer(data=request.data) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + cycle = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id, owned_by=request.user) + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + { + "error": "Both start date and end date are either required or are to be null" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + def patch(self, request, slug, project_id, pk): + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + + current_instance = json.dumps( + CycleSerializer(cycle).data, cls=DjangoJSONEncoder + ) + + if cycle.archived_at: + return Response( + {"error": "Archived cycle cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + request_data = request.data + + if cycle.end_date is not None and cycle.end_date < timezone.now(): + if "sort_order" in request_data: + # Can only change sort order + request_data = { + "sort_order": request_data.get("sort_order", cycle.sort_order) + } + else: + return Response( + { + "error": "The Cycle has already been completed so it cannot be edited" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = CycleSerializer(cycle, data=request.data, partial=True) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (cycle.external_id != request.data.get("external_id")) + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", cycle.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + + # Send the model activity + model_activity.delay( + model_name="cycle", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, pk): + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + if cycle.owned_by_id != request.user.id and ( + not ProjectMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or creator can delete the cycle"}, + status=status.HTTP_403_FORBIDDEN, + ) + + cycle_issues = list( + CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( + "issue", flat=True + ) + ) + + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(pk), + "cycle_name": str(cycle.name), + "issues": [str(issue_id) for issue_id in cycle_issues], + } + ), + actor_id=str(request.user.id), + issue_id=None, + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # Delete the cycle + cycle.delete() + # Delete the user favorite cycle + UserFavorite.objects.filter( + entity_type="cycle", entity_identifier=pk, project_id=project_id + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(archived_at__isnull=False) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id): + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda cycles: CycleSerializer( + cycles, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + def post(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + if cycle.end_date >= timezone.now(): + return Response( + {"error": "Only completed cycles can be archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + cycle.archived_at = timezone.now() + cycle.save() + UserFavorite.objects.filter( + entity_type="cycle", + entity_identifier=cycle_id, + project_id=project_id, + workspace__slug=slug, + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id, cycle_id): + cycle = Cycle.objects.get( + pk=cycle_id, project_id=project_id, workspace__slug=slug + ) + cycle.archived_at = None + cycle.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CycleIssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, + and `destroy` actions related to cycle issues. + + """ + + serializer_class = CycleIssueSerializer + model = CycleIssue + webhook_event = "cycle_issue" + bulk = True + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + CycleIssue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(cycle_id=self.kwargs.get("cycle_id")) + .select_related("project") + .select_related("workspace") + .select_related("cycle") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id, cycle_id, issue_id=None): + # Get + if issue_id: + cycle_issue = CycleIssue.objects.get( + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + issue_id=issue_id, + ) + serializer = CycleIssueSerializer( + cycle_issue, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # List + order_by = request.GET.get("order_by", "created_at") + issues = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate(bridge_id=F("issue_cycle__id")) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(order_by) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: IssueSerializer( + issues, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + def post(self, request, slug, project_id, cycle_id): + issues = request.data.get("issues", []) + + if not issues: + return Response( + {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST + ) + + cycle = Cycle.objects.get( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + + # Get all CycleIssues already created + cycle_issues = list( + CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) + ) + + existing_issues = [ + str(cycle_issue.issue_id) + for cycle_issue in cycle_issues + if str(cycle_issue.issue_id) in issues + ] + new_issues = list(set(issues) - set(existing_issues)) + + # New issues to create + created_records = CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace_id=cycle.workspace_id, + cycle_id=cycle_id, + issue_id=issue, + ) + for issue in new_issues + ], + ignore_conflicts=True, + batch_size=10, + ) + + # Updated Issues + updated_records = [] + update_cycle_issue_activity = [] + # Iterate over each cycle_issue in cycle_issues + for cycle_issue in cycle_issues: + old_cycle_id = cycle_issue.cycle_id + # Update the cycle_issue's cycle_id + cycle_issue.cycle_id = cycle_id + # Add the modified cycle_issue to the records_to_update list + updated_records.append(cycle_issue) + # Record the update activity + update_cycle_issue_activity.append( + { + "old_cycle_id": str(old_cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Update the cycle issues + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) + + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": issues}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize( + "json", created_records + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + # Return all Cycle Issues + return Response( + CycleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, cycle_id, issue_id): + cycle_issue = CycleIssue.objects.get( + issue_id=issue_id, + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + issue_id = cycle_issue.issue_id + cycle_issue.delete() + issue_activity.delay( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(self.kwargs.get("cycle_id")), + "issues": [str(issue_id)], + } + ), + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TransferCycleIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `create` actions for transferring the issues into a particular cycle. + + """ + + permission_classes = [ProjectEntityPermission] + + def post(self, request, slug, project_id, cycle_id): + new_cycle_id = request.data.get("new_cycle_id", False) + + if not new_cycle_id: + return Response( + {"error": "New Cycle Id is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + old_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + issue_cycle__issue__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + .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, + issue_cycle__deleted_at__isnull=True, + ), + ) + ) + ) + old_cycle = old_cycle.first() + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + if estimate_type: + assignee_estimate_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar", "avatar_url") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item.get("avatar", None), + "avatar_url": item.get("avatar_url", None), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + label_distribution_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum(Cast("estimate_point__value", FloatField())) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": (str(item["label_id"]) if item["label_id"] else None), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in label_distribution_data + ] + + # Get the assignee distribution + assignee_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + total_issues=Count( + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item.get("avatar", None), + "avatar_url": item.get("avatar_url", None), + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + # Get the label distribution + label_distribution = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + issue_cycle__deleted_at__isnull=True, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", filter=Q(archived_at__isnull=True, is_draft=False) + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + # Label distribution serilization + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": (str(item["label_id"]) if item["label_id"] else None), + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle, + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.total_issues, + "completed_issues": old_cycle.completed_issues, + "cancelled_issues": old_cycle.cancelled_issues, + "started_issues": old_cycle.started_issues, + "unstarted_issues": old_cycle.unstarted_issues, + "backlog_issues": old_cycle.backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), + } + current_cycle.save(update_fields=["progress_snapshot"]) + + if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): + return Response( + { + "error": "The cycle where the issues are transferred is already completed" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__state__group__in=["backlog", "unstarted", "started"], + ) + + updated_cycles = [] + update_cycle_issue_activity = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_id), + "new_cycle_id": str(new_cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + cycle_issues = CycleIssue.objects.bulk_update( + updated_cycles, ["cycle_id"], batch_size=100 + ) + + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": []}), + actor_id=str(self.request.user.id), + issue_id=None, + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": "[]", + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + return Response({"message": "Success"}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py similarity index 100% rename from apiserver/plane/api/views/intake.py rename to apps/api/plane/api/views/intake.py diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py new file mode 100644 index 00000000000..6a5016bec46 --- /dev/null +++ b/apps/api/plane/api/views/issue.py @@ -0,0 +1,1162 @@ +# Python imports +import json +import uuid + +# Django imports +from django.core.serializers.json import DjangoJSONEncoder +from django.http import HttpResponseRedirect +from django.db import IntegrityError +from django.db.models import ( + Case, + CharField, + Exists, + F, + Func, + Max, + OuterRef, + Q, + Value, + When, + Subquery, +) +from django.utils import timezone +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.api.serializers import ( + IssueAttachmentSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + IssueLinkSerializer, + IssueSerializer, + LabelSerializer, +) +from plane.app.permissions import ( + ProjectEntityPermission, + ProjectLitePermission, + ProjectMemberPermission, +) +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import ( + Issue, + IssueActivity, + FileAsset, + IssueComment, + IssueLink, + Label, + Project, + ProjectMember, + CycleIssue, + Workspace, +) +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata +from .base import BaseAPIView +from plane.utils.host import base_host +from plane.bgtasks.webhook_task import model_activity +from plane.bgtasks.work_item_link_task import crawl_work_item_link_title + + +class WorkspaceIssueAPIEndpoint(BaseAPIView): + """ + This viewset provides `retrieveByIssueId` on workspace level + + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + + @property + def project__identifier(self): + return self.kwargs.get("project__identifier", None) + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project__identifier=self.kwargs.get("project__identifier")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + def get(self, request, slug, project__identifier=None, issue__identifier=None): + if issue__identifier and project__identifier: + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get( + workspace__slug=slug, + project__identifier=project__identifier, + sequence_id=issue__identifier, + ) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + +class IssueAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to issue. + + """ + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + def get(self, request, slug, project_id, pk=None): + external_id = request.GET.get("external_id") + external_source = request.GET.get("external_source") + + if external_id and external_source: + issue = Issue.objects.get( + external_id=external_id, + external_source=external_source, + workspace__slug=slug, + project_id=project_id, + ) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + if pk: + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get(workspace__slug=slug, project_id=project_id, pk=pk) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = ( + self.get_queryset() + .annotate( + cycle_id=Subquery( + CycleIssue.objects.filter( + issue=OuterRef("id"), deleted_at__isnull=True + ).values("cycle_id")[:1] + ) + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order if order_by_param == "priority" else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" if order_by_param.startswith("-") else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + return self.paginate( + request=request, + queryset=(issue_queryset), + on_results=lambda issues: IssueSerializer( + issues, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id) + + serializer = IssueSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer.save() + # Refetch the issue + issue = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk=serializer.data["id"] + ).first() + issue.created_at = request.data.get("created_at", timezone.now()) + issue.created_by_id = request.data.get("created_by", request.user.id) + issue.save(update_fields=["created_at", "created_by"]) + + # Track the issue + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + + # Send the model activity + model_activity.delay( + model_name="issue", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def put(self, request, slug, project_id): + # Get the entities required for putting the issue, external_id and + # external_source are must to identify the issue here + project = Project.objects.get(pk=project_id) + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # If the external_id and source are present, we need to find the exact + # issue that needs to be updated with the provided external_id and + # external_source + if external_id and external_source: + try: + issue = Issue.objects.get( + project_id=project_id, + workspace__slug=slug, + external_id=external_id, + external_source=external_source, + ) + + # Get the current instance of the issue in order to track + # changes and dispatch the issue activity + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + + # Get the requested data, encode it as django object and pass it + # to serializer to validation + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + serializer = IssueSerializer( + issue, + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + }, + partial=True, + ) + if serializer.is_valid(): + # If the serializer is valid, save the issue and dispatch + # the update issue activity worker event. + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + # If the serializer is not valid, respond with 400 bad + # request + serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) + except Issue.DoesNotExist: + # If the issue does not exist, a new record needs to be created + # for the requested data. + # Serialize the data with the context of the project and + # workspace + serializer = IssueSerializer( + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, + ) + + # If the serializer is valid, save the issue and dispatch the + # issue activity worker event as created + if serializer.is_valid(): + serializer.save() + # Refetch the issue + issue = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + pk=serializer.data["id"], + ).first() + + # If any of the created_at or created_by is present, update + # the issue with the provided data, else return with the + # default states given. + issue.created_at = request.data.get("created_at", timezone.now()) + issue.created_by_id = request.data.get( + "created_by", request.user.id + ) + issue.save(update_fields=["created_at", "created_by"]) + + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), + actor_id=str(request.user.id), + issue_id=str(serializer.data.get("id", None)), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + {"error": "external_id and external_source are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def patch(self, request, slug, project_id, pk=None): + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + project = Project.objects.get(pk=project_id) + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + serializer = IssueSerializer( + issue, + data=request.data, + context={"project_id": project_id, "workspace_id": project.workspace_id}, + partial=True, + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (issue.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", issue.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer.save() + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, pk=None): + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) + if issue.created_by_id != request.user.id and ( + not ProjectMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or creator can delete the issue"}, + status=status.HTTP_403_FORBIDDEN, + ) + current_instance = json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ) + issue.delete() + issue_activity.delay( + type="issue.activity.deleted", + requested_data=json.dumps({"issue_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(pk), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class LabelAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to the labels. + + """ + + serializer_class = LabelSerializer + model = Label + permission_classes = [ProjectMemberPermission] + + def get_queryset(self): + return ( + Label.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("project") + .select_related("workspace") + .select_related("parent") + .distinct() + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + def post(self, request, slug, project_id): + try: + serializer = LabelSerializer(data=request.data) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() + return Response( + { + "error": "Label with the same name already exists in the project", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + def get(self, request, slug, project_id, pk=None): + if pk is None: + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda labels: LabelSerializer( + labels, many=True, fields=self.fields, expand=self.expand + ).data, + ) + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, pk=None): + label = self.get_queryset().get(pk=pk) + serializer = LabelSerializer(label, data=request.data, partial=True) + if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (label.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", label.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, pk=None): + label = self.get_queryset().get(pk=pk) + label.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueLinkAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to the links of the particular issue. + + """ + + permission_classes = [ProjectEntityPermission] + + model = IssueLink + serializer_class = IssueLinkSerializer + + def get_queryset(self): + return ( + IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id, issue_id, pk=None): + if pk is None: + issue_links = self.get_queryset() + serializer = IssueLinkSerializer( + issue_links, fields=self.fields, expand=self.expand + ) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, many=True, fields=self.fields, expand=self.expand + ).data, + ) + issue_link = self.get_queryset().get(pk=pk) + serializer = IssueLinkSerializer( + issue_link, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def post(self, request, slug, project_id, issue_id): + serializer = IssueLinkSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title.delay( + serializer.data.get("id"), serializer.data.get("url") + ) + + link = IssueLink.objects.get(pk=serializer.data["id"]) + link.created_by_id = request.data.get("created_by", request.user.id) + link.save(update_fields=["created_by"]) + issue_activity.delay( + type="link.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + actor_id=str(link.created_by_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder + ) + serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + crawl_work_item_link_title.delay( + serializer.data.get("id"), serializer.data.get("url") + ) + issue_activity.delay( + type="link.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_link = IssueLink.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + current_instance = json.dumps( + IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder + ) + issue_activity.delay( + type="link.activity.deleted", + requested_data=json.dumps({"link_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + issue_link.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueCommentAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, `retrieve`, + `update` and `destroy` actions related to comments of the particular issue. + + """ + + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + permission_classes = [ProjectLitePermission] + + def get_queryset(self): + return ( + IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("workspace", "project", "issue", "actor") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + issue_comment = self.get_queryset().get(pk=pk) + serializer = IssueCommentSerializer( + issue_comment, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_comment: IssueCommentSerializer( + issue_comment, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + def post(self, request, slug, project_id, issue_id): + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and request.data.get("external_source") + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue_comment = IssueComment.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, issue_id=issue_id, actor=request.user + ) + issue_comment = IssueComment.objects.get(pk=serializer.data.get("id")) + # Update the created_at and the created_by and save the comment + issue_comment.created_at = request.data.get("created_at", timezone.now()) + issue_comment.created_by_id = request.data.get( + "created_by", request.user.id + ) + issue_comment.save(update_fields=["created_at", "created_by"]) + + issue_activity.delay( + type="comment.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + actor_id=str(issue_comment.created_by_id), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + # Send the model activity + model_activity.delay( + model_name="issue_comment", + model_id=str(serializer.data["id"]), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = json.dumps( + IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder + ) + + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and (issue_comment.external_id != str(request.data.get("external_id"))) + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", issue_comment.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentSerializer( + issue_comment, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + issue_activity.delay( + type="comment.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + # Send the model activity + model_activity.delay( + model_name="issue_comment", + model_id=str(pk), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_comment = IssueComment.objects.get( + workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk + ) + current_instance = json.dumps( + IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder + ) + issue_comment.delete() + issue_activity.delay( + type="comment.activity.deleted", + requested_data=json.dumps({"comment_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueActivityAPIEndpoint(BaseAPIView): + permission_classes = [ProjectEntityPermission] + + def get(self, request, slug, project_id, issue_id, pk=None): + issue_activities = ( + IssueActivity.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id + ) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("actor", "workspace", "issue", "project") + ).order_by(request.GET.get("order_by", "created_at")) + + if pk: + issue_activities = issue_activities.get(pk=pk) + serializer = IssueActivitySerializer(issue_activities) + return Response(serializer.data, status=status.HTTP_200_OK) + + return self.paginate( + request=request, + queryset=(issue_activities), + on_results=lambda issue_activity: IssueActivitySerializer( + issue_activity, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + +class IssueAttachmentEndpoint(BaseAPIView): + serializer_class = IssueAttachmentSerializer + permission_classes = [ProjectEntityPermission] + model = FileAsset + + def post(self, request, slug, project_id, issue_id): + name = request.data.get("name") + type = request.data.get("type", False) + size = request.data.get("size") + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # Check if the request is valid + if not name or not size: + return Response( + {"error": "Invalid request.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + if not type or type not in settings.ATTACHMENT_MIME_TYPES: + return Response( + {"error": "Invalid file type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + if ( + request.data.get("external_id") + and request.data.get("external_source") + and FileAsset.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).exists() + ): + asset = FileAsset.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).first() + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(asset.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + external_id=external_id, + external_source=external_source, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "attachment": IssueAttachmentSerializer(asset).data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + issue_attachment.is_deleted = True + issue_attachment.deleted_at = timezone.now() + issue_attachment.save() + + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The asset is not uploaded.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + return HttpResponseRedirect(presigned_url) + + # Get all the attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + # Serialize the attachments + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachment) + + # Send this activity only if the attachment is not uploaded before + if not issue_attachment.is_uploaded: + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + + # Update the attachment + issue_attachment.is_uploaded = True + issue_attachment.created_by = request.user + + # Get the storage metadata + if not issue_attachment.storage_metadata: + get_asset_object_metadata.delay(str(issue_attachment.id)) + issue_attachment.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/api/views/member.py b/apps/api/plane/api/views/member.py similarity index 100% rename from apiserver/plane/api/views/member.py rename to apps/api/plane/api/views/member.py diff --git a/apiserver/plane/api/views/module.py b/apps/api/plane/api/views/module.py similarity index 100% rename from apiserver/plane/api/views/module.py rename to apps/api/plane/api/views/module.py diff --git a/apiserver/plane/api/views/project.py b/apps/api/plane/api/views/project.py similarity index 100% rename from apiserver/plane/api/views/project.py rename to apps/api/plane/api/views/project.py diff --git a/apiserver/plane/api/views/state.py b/apps/api/plane/api/views/state.py similarity index 100% rename from apiserver/plane/api/views/state.py rename to apps/api/plane/api/views/state.py diff --git a/apiserver/plane/app/__init__.py b/apps/api/plane/app/__init__.py similarity index 100% rename from apiserver/plane/app/__init__.py rename to apps/api/plane/app/__init__.py diff --git a/apiserver/plane/app/apps.py b/apps/api/plane/app/apps.py similarity index 100% rename from apiserver/plane/app/apps.py rename to apps/api/plane/app/apps.py diff --git a/apiserver/plane/app/middleware/__init__.py b/apps/api/plane/app/middleware/__init__.py similarity index 100% rename from apiserver/plane/app/middleware/__init__.py rename to apps/api/plane/app/middleware/__init__.py diff --git a/apiserver/plane/app/middleware/api_authentication.py b/apps/api/plane/app/middleware/api_authentication.py similarity index 100% rename from apiserver/plane/app/middleware/api_authentication.py rename to apps/api/plane/app/middleware/api_authentication.py diff --git a/apiserver/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py similarity index 100% rename from apiserver/plane/app/permissions/__init__.py rename to apps/api/plane/app/permissions/__init__.py diff --git a/apiserver/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py similarity index 100% rename from apiserver/plane/app/permissions/base.py rename to apps/api/plane/app/permissions/base.py diff --git a/apiserver/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py similarity index 100% rename from apiserver/plane/app/permissions/project.py rename to apps/api/plane/app/permissions/project.py diff --git a/apiserver/plane/app/permissions/workspace.py b/apps/api/plane/app/permissions/workspace.py similarity index 100% rename from apiserver/plane/app/permissions/workspace.py rename to apps/api/plane/app/permissions/workspace.py diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py new file mode 100644 index 00000000000..0116b206138 --- /dev/null +++ b/apps/api/plane/app/serializers/__init__.py @@ -0,0 +1,132 @@ +from .base import BaseSerializer +from .user import ( + UserSerializer, + UserLiteSerializer, + ChangePasswordSerializer, + ResetPasswordSerializer, + UserAdminLiteSerializer, + UserMeSerializer, + UserMeSettingsSerializer, + ProfileSerializer, + AccountSerializer, +) +from .workspace import ( + WorkSpaceSerializer, + WorkSpaceMemberSerializer, + WorkSpaceMemberInviteSerializer, + WorkspaceLiteSerializer, + WorkspaceThemeSerializer, + WorkspaceMemberAdminSerializer, + WorkspaceMemberMeSerializer, + WorkspaceUserPropertiesSerializer, + WorkspaceUserLinkSerializer, + WorkspaceRecentVisitSerializer, + WorkspaceHomePreferenceSerializer, + StickySerializer, +) +from .project import ( + ProjectSerializer, + ProjectListSerializer, + ProjectDetailSerializer, + ProjectMemberSerializer, + ProjectMemberInviteSerializer, + ProjectIdentifierSerializer, + ProjectLiteSerializer, + ProjectMemberLiteSerializer, + DeployBoardSerializer, + ProjectMemberAdminSerializer, + ProjectPublicMemberSerializer, + ProjectMemberRoleSerializer, +) +from .state import StateSerializer, StateLiteSerializer +from .view import IssueViewSerializer, ViewIssueListSerializer +from .cycle import ( + CycleSerializer, + CycleIssueSerializer, + CycleWriteSerializer, + CycleUserPropertiesSerializer, +) +from .asset import FileAssetSerializer +from .issue import ( + IssueCreateSerializer, + IssueActivitySerializer, + IssueCommentSerializer, + IssueUserPropertySerializer, + IssueAssigneeSerializer, + LabelSerializer, + IssueSerializer, + IssueFlatSerializer, + IssueStateSerializer, + IssueLinkSerializer, + IssueIntakeSerializer, + IssueLiteSerializer, + IssueAttachmentSerializer, + IssueSubscriberSerializer, + IssueReactionSerializer, + CommentReactionSerializer, + IssueVoteSerializer, + IssueRelationSerializer, + RelatedIssueSerializer, + IssuePublicSerializer, + IssueDetailSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, + IssueVersionDetailSerializer, + IssueDescriptionVersionDetailSerializer, + IssueListDetailSerializer, +) + +from .module import ( + ModuleDetailSerializer, + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, + ModuleLinkSerializer, + ModuleUserPropertiesSerializer, +) + +from .api import APITokenSerializer, APITokenReadSerializer + +from .importer import ImporterSerializer + +from .page import ( + PageSerializer, + PageLogSerializer, + SubPageSerializer, + PageDetailSerializer, + PageVersionSerializer, + PageBinaryUpdateSerializer, + PageVersionDetailSerializer, +) + +from .estimate import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, + WorkspaceEstimateSerializer, +) + +from .intake import ( + IntakeSerializer, + IntakeIssueSerializer, + IssueStateIntakeSerializer, + IntakeIssueLiteSerializer, + IntakeIssueDetailSerializer, +) + +from .analytic import AnalyticViewSerializer + +from .notification import NotificationSerializer, UserNotificationPreferenceSerializer + +from .exporter import ExporterHistorySerializer + +from .webhook import WebhookSerializer, WebhookLogSerializer + +from .favorite import UserFavoriteSerializer + +from .draft import ( + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) diff --git a/apiserver/plane/app/serializers/analytic.py b/apps/api/plane/app/serializers/analytic.py similarity index 100% rename from apiserver/plane/app/serializers/analytic.py rename to apps/api/plane/app/serializers/analytic.py diff --git a/apiserver/plane/app/serializers/api.py b/apps/api/plane/app/serializers/api.py similarity index 100% rename from apiserver/plane/app/serializers/api.py rename to apps/api/plane/app/serializers/api.py diff --git a/apiserver/plane/app/serializers/asset.py b/apps/api/plane/app/serializers/asset.py similarity index 100% rename from apiserver/plane/app/serializers/asset.py rename to apps/api/plane/app/serializers/asset.py diff --git a/apiserver/plane/app/serializers/base.py b/apps/api/plane/app/serializers/base.py similarity index 100% rename from apiserver/plane/app/serializers/base.py rename to apps/api/plane/app/serializers/base.py diff --git a/apps/api/plane/app/serializers/cycle.py b/apps/api/plane/app/serializers/cycle.py new file mode 100644 index 00000000000..2aa2ac7b7c9 --- /dev/null +++ b/apps/api/plane/app/serializers/cycle.py @@ -0,0 +1,105 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueStateSerializer +from plane.db.models import Cycle, CycleIssue, CycleUserProperties +from plane.utils.timezone_converter import convert_to_utc + + +class CycleWriteSerializer(BaseSerializer): + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + and data.get("start_date", None) > data.get("end_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed end date") + if ( + data.get("start_date", None) is not None + and data.get("end_date", None) is not None + ): + project_id = ( + self.initial_data.get("project_id", None) + or (self.instance and self.instance.project_id) + or self.context.get("project_id", None) + ) + data["start_date"] = convert_to_utc( + date=str(data.get("start_date").date()), + project_id=project_id, + is_start_date=True, + ) + data["end_date"] = convert_to_utc( + date=str(data.get("end_date", None).date()), + project_id=project_id, + ) + return data + + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = ["workspace", "project", "owned_by", "archived_at"] + + +class CycleSerializer(BaseSerializer): + # favorite + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + # state group wise distribution + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) + + class Meta: + model = Cycle + fields = [ + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + "logo_props", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "status", + ] + read_only_fields = fields + + +class CycleIssueSerializer(BaseSerializer): + issue_detail = IssueStateSerializer(read_only=True, source="issue") + sub_issues_count = serializers.IntegerField(read_only=True) + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = ["workspace", "project", "cycle"] + + +class CycleUserPropertiesSerializer(BaseSerializer): + class Meta: + model = CycleUserProperties + fields = "__all__" + read_only_fields = ["workspace", "project", "cycle", "user"] diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py new file mode 100644 index 00000000000..57600bff93d --- /dev/null +++ b/apps/api/plane/app/serializers/draft.py @@ -0,0 +1,349 @@ +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + User, + Issue, + Label, + State, + DraftIssue, + DraftIssueAssignee, + DraftIssueLabel, + DraftIssueCycle, + DraftIssueModule, + ProjectMember, + EstimatePoint, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) +from plane.app.permissions import ROLE + + +class DraftIssueCreateSerializer(BaseSerializer): + # ids + state_id = serializers.PrimaryKeyRelatedField( + source="state", queryset=State.objects.all(), required=False, allow_null=True + ) + parent_id = serializers.PrimaryKeyRelatedField( + source="parent", queryset=Issue.objects.all(), required=False, allow_null=True + ) + label_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = DraftIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + assignee_ids = self.initial_data.get("assignee_ids") + data["assignee_ids"] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get("label_ids") + data["label_ids"] = label_ids if label_ids else [] + return data + + def validate(self, attrs): + if ( + attrs.get("start_date", None) is not None + and attrs.get("target_date", None) is not None + and attrs.get("start_date", None) > attrs.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + # Validate description content for security + if "description" in attrs and attrs["description"]: + is_valid, error_msg = validate_json_content(attrs["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if "description_html" in attrs and attrs["description_html"]: + is_valid, error_msg = validate_html_content(attrs["description_html"]) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + if "description_binary" in attrs and attrs["description_binary"]: + is_valid, error_msg = validate_binary_data(attrs["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": error_msg}) + + # Validate assignees are from project + if attrs.get("assignee_ids", []): + attrs["assignee_ids"] = ProjectMember.objects.filter( + project_id=self.context["project_id"], + role__gte=ROLE.MEMBER.value, + is_active=True, + member_id__in=attrs["assignee_ids"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if attrs.get("label_ids"): + label_ids = [label.id for label in attrs["label_ids"]] + attrs["label_ids"] = list( + Label.objects.filter( + project_id=self.context.get("project_id"), id__in=label_ids + ).values_list("id", flat=True) + ) + + # # Check state is from the project only else raise validation error + if ( + attrs.get("state") + and not State.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("state").id, + ).exists() + ): + raise serializers.ValidationError( + "State is not valid please pass a valid state_id" + ) + + # # Check parent issue is from workspace as it can be cross workspace + if ( + attrs.get("parent") + and not Issue.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("parent").id, + ).exists() + ): + raise serializers.ValidationError( + "Parent is not valid issue_id please pass a valid issue_id" + ) + + if ( + attrs.get("estimate_point") + and not EstimatePoint.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("estimate_point").id, + ).exists() + ): + raise serializers.ValidationError( + "Estimate point is not valid please pass a valid estimate_point_id" + ) + + return attrs + + def create(self, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + modules = validated_data.pop("module_ids", None) + cycle_id = self.initial_data.get("cycle_id", None) + modules = self.initial_data.get("module_ids", None) + + workspace_id = self.context["workspace_id"] + project_id = self.context["project_id"] + + # Create Issue + issue = DraftIssue.objects.create( + **validated_data, workspace_id=workspace_id, project_id=project_id + ) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + DraftIssueAssignee.objects.bulk_create( + [ + DraftIssueAssignee( + assignee_id=assignee_id, + draft_issue=issue, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + + if labels is not None and len(labels): + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label_id=label_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ) + + if cycle_id is not None: + DraftIssueCycle.objects.create( + cycle_id=cycle_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if modules is not None and len(modules): + DraftIssueModule.objects.bulk_create( + [ + DraftIssueModule( + module_id=module_id, + draft_issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for module_id in modules + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + cycle_id = self.context.get("cycle_id", None) + modules = self.initial_data.get("module_ids", None) + + # Related models + workspace_id = instance.workspace_id + project_id = instance.project_id + + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + DraftIssueAssignee.objects.filter(draft_issue=instance).delete() + DraftIssueAssignee.objects.bulk_create( + [ + DraftIssueAssignee( + assignee_id=assignee_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + + if labels is not None: + DraftIssueLabel.objects.filter(draft_issue=instance).delete() + DraftIssueLabel.objects.bulk_create( + [ + DraftIssueLabel( + label=label, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + if cycle_id != "not_provided": + DraftIssueCycle.objects.filter(draft_issue=instance).delete() + if cycle_id: + DraftIssueCycle.objects.create( + cycle_id=cycle_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if modules is not None: + DraftIssueModule.objects.filter(draft_issue=instance).delete() + DraftIssueModule.objects.bulk_create( + [ + DraftIssueModule( + module_id=module_id, + draft_issue=instance, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for module_id in modules + ], + batch_size=10, + ) + + # Time updation occurs even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class DraftIssueSerializer(BaseSerializer): + # ids + cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) + module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + # Many to many + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = DraftIssue + fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + "type_id", + "description_html", + ] + read_only_fields = fields + + +class DraftIssueDetailSerializer(DraftIssueSerializer): + description_html = serializers.CharField() + + class Meta(DraftIssueSerializer.Meta): + fields = DraftIssueSerializer.Meta.fields + ["description_html"] + read_only_fields = fields diff --git a/apiserver/plane/app/serializers/estimate.py b/apps/api/plane/app/serializers/estimate.py similarity index 100% rename from apiserver/plane/app/serializers/estimate.py rename to apps/api/plane/app/serializers/estimate.py diff --git a/apiserver/plane/app/serializers/exporter.py b/apps/api/plane/app/serializers/exporter.py similarity index 100% rename from apiserver/plane/app/serializers/exporter.py rename to apps/api/plane/app/serializers/exporter.py diff --git a/apiserver/plane/app/serializers/favorite.py b/apps/api/plane/app/serializers/favorite.py similarity index 100% rename from apiserver/plane/app/serializers/favorite.py rename to apps/api/plane/app/serializers/favorite.py diff --git a/apiserver/plane/app/serializers/importer.py b/apps/api/plane/app/serializers/importer.py similarity index 100% rename from apiserver/plane/app/serializers/importer.py rename to apps/api/plane/app/serializers/importer.py diff --git a/apiserver/plane/app/serializers/intake.py b/apps/api/plane/app/serializers/intake.py similarity index 100% rename from apiserver/plane/app/serializers/intake.py rename to apps/api/plane/app/serializers/intake.py diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py new file mode 100644 index 00000000000..8973264313a --- /dev/null +++ b/apps/api/plane/app/serializers/issue.py @@ -0,0 +1,1008 @@ +# Django imports +from django.utils import timezone +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError +from django.db import IntegrityError + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from .user import UserLiteSerializer +from .state import StateLiteSerializer +from .project import ProjectLiteSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import ( + User, + Issue, + IssueActivity, + IssueComment, + IssueUserProperty, + IssueAssignee, + IssueSubscriber, + IssueLabel, + Label, + CycleIssue, + Cycle, + Module, + ModuleIssue, + IssueLink, + FileAsset, + IssueReaction, + CommentReaction, + IssueVote, + IssueRelation, + State, + IssueVersion, + IssueDescriptionVersion, + ProjectMember, + EstimatePoint, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "description_html", + "priority", + "start_date", + "target_date", + "sequence_id", + "sort_order", + "is_draft", + ] + + +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = ["id", "project_detail", "name", "sequence_id"] + read_only_fields = fields + + +##TODO: Find a better way to write this serializer +## Find a better approach to save manytomany? +class IssueCreateSerializer(BaseSerializer): + # ids + state_id = serializers.PrimaryKeyRelatedField( + source="state", queryset=State.objects.all(), required=False, allow_null=True + ) + parent_id = serializers.PrimaryKeyRelatedField( + source="parent", queryset=Issue.objects.all(), required=False, allow_null=True + ) + label_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + project_id = serializers.UUIDField(source="project.id", read_only=True) + workspace_id = serializers.UUIDField(source="workspace.id", read_only=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + assignee_ids = self.initial_data.get("assignee_ids") + data["assignee_ids"] = assignee_ids if assignee_ids else [] + label_ids = self.initial_data.get("label_ids") + data["label_ids"] = label_ids if label_ids else [] + return data + + def validate(self, attrs): + if ( + attrs.get("start_date", None) is not None + and attrs.get("target_date", None) is not None + and attrs.get("start_date", None) > attrs.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + # Validate description content for security + if "description" in attrs and attrs["description"]: + is_valid, error_msg = validate_json_content(attrs["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if "description_html" in attrs and attrs["description_html"]: + is_valid, error_msg = validate_html_content(attrs["description_html"]) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + if "description_binary" in attrs and attrs["description_binary"]: + is_valid, error_msg = validate_binary_data(attrs["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": error_msg}) + + # Validate assignees are from project + if attrs.get("assignee_ids", []): + attrs["assignee_ids"] = ProjectMember.objects.filter( + project_id=self.context["project_id"], + role__gte=15, + is_active=True, + member_id__in=attrs["assignee_ids"], + ).values_list("member_id", flat=True) + + # Validate labels are from project + if attrs.get("label_ids"): + label_ids = [label.id for label in attrs["label_ids"]] + attrs["label_ids"] = list( + Label.objects.filter( + project_id=self.context.get("project_id"), + id__in=label_ids, + ).values_list("id", flat=True) + ) + + # Check state is from the project only else raise validation error + if ( + attrs.get("state") + and not State.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("state").id, + ).exists() + ): + raise serializers.ValidationError( + "State is not valid please pass a valid state_id" + ) + + # Check parent issue is from workspace as it can be cross workspace + if ( + attrs.get("parent") + and not Issue.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("parent").id, + ).exists() + ): + raise serializers.ValidationError( + "Parent is not valid issue_id please pass a valid issue_id" + ) + + if ( + attrs.get("estimate_point") + and not EstimatePoint.objects.filter( + project_id=self.context.get("project_id"), + pk=attrs.get("estimate_point").id, + ).exists() + ): + raise serializers.ValidationError( + "Estimate point is not valid please pass a valid estimate_point_id" + ) + + return attrs + + def create(self, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + # Create Issue + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ) + except IntegrityError: + pass + else: + # Then assign it to default assignee, if it is a valid assignee + if ( + default_assignee_id is not None + and ProjectMember.objects.filter( + member_id=default_assignee_id, + project_id=project_id, + role__gte=15, + is_active=True, + ).exists() + ): + try: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + except IntegrityError: + pass + + if labels is not None and len(labels): + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ) + except IntegrityError: + pass + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignee_ids", None) + labels = validated_data.pop("label_ids", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + try: + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for assignee_id in assignees + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + try: + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label_id in labels + ], + batch_size=10, + ignore_conflicts=True, + ) + except IntegrityError: + pass + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class IssueActivitySerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + source_data = serializers.SerializerMethodField() + + def get_source_data(self, obj): + if ( + hasattr(obj, "issue") + and hasattr(obj.issue, "source_data") + and obj.issue.source_data + ): + return { + "source": obj.issue.source_data[0].source, + "source_email": obj.issue.source_data[0].source_email, + "extra": obj.issue.source_data[0].extra, + } + return None + + class Meta: + model = IssueActivity + fields = "__all__" + + +class IssueUserPropertySerializer(BaseSerializer): + class Meta: + model = IssueUserProperty + fields = "__all__" + read_only_fields = ["user", "workspace", "project"] + + +class LabelSerializer(BaseSerializer): + class Meta: + model = Label + fields = [ + "parent", + "name", + "color", + "id", + "project_id", + "workspace_id", + "sort_order", + ] + read_only_fields = ["workspace", "project"] + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = ["id", "name", "color"] + + +class IssueLabelSerializer(BaseSerializer): + class Meta: + model = IssueLabel + fields = "__all__" + read_only_fields = ["workspace", "project"] + + +class IssueRelationSerializer(BaseSerializer): + id = serializers.UUIDField(source="related_issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField( + source="related_issue.project_id", read_only=True + ) + sequence_id = serializers.IntegerField( + source="related_issue.sequence_id", read_only=True + ) + name = serializers.CharField(source="related_issue.name", read_only=True) + relation_type = serializers.CharField(read_only=True) + state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True) + priority = serializers.CharField(source="related_issue.priority", read_only=True) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = IssueRelation + fields = [ + "id", + "project_id", + "sequence_id", + "relation_type", + "name", + "state_id", + "priority", + "assignee_ids", + "created_by", + "created_at", + "updated_at", + "updated_by", + ] + read_only_fields = [ + "workspace", + "project", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + + +class RelatedIssueSerializer(BaseSerializer): + id = serializers.UUIDField(source="issue.id", read_only=True) + project_id = serializers.PrimaryKeyRelatedField( + source="issue.project_id", read_only=True + ) + sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True) + name = serializers.CharField(source="issue.name", read_only=True) + relation_type = serializers.CharField(read_only=True) + state_id = serializers.UUIDField(source="issue.state.id", read_only=True) + priority = serializers.CharField(source="issue.priority", read_only=True) + assignee_ids = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = IssueRelation + fields = [ + "id", + "project_id", + "sequence_id", + "relation_type", + "name", + "state_id", + "priority", + "assignee_ids", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + read_only_fields = [ + "workspace", + "project", + "created_by", + "created_at", + "updated_by", + "updated_at", + ] + + +class IssueAssigneeSerializer(BaseSerializer): + assignee_details = UserLiteSerializer(read_only=True, source="assignee") + + class Meta: + model = IssueAssignee + fields = "__all__" + + +class CycleBaseSerializer(BaseSerializer): + class Meta: + model = Cycle + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueCycleDetailSerializer(BaseSerializer): + cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class ModuleBaseSerializer(BaseSerializer): + class Meta: + model = Module + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueModuleDetailSerializer(BaseSerializer): + module_detail = ModuleBaseSerializer(read_only=True, source="module") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + def to_internal_value(self, data): + # Modify the URL before validation by appending http:// if missing + url = data.get("url", "") + if url and not url.startswith(("http://", "https://")): + data["url"] = "http://" + url + + return super().to_internal_value(data) + + def validate_url(self, value): + # Use Django's built-in URLValidator for validation + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise serializers.ValidationError({"error": "Invalid URL format."}) + + return value + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return IssueLink.objects.create(**validated_data) + + def update(self, instance, validated_data): + if ( + IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=instance.issue_id + ) + .exclude(pk=instance.id) + .exists() + ): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + + return super().update(instance, validated_data) + + +class IssueLinkLiteSerializer(BaseSerializer): + class Meta: + model = IssueLink + fields = [ + "id", + "issue_id", + "title", + "url", + "metadata", + "created_by_id", + "created_at", + ] + read_only_fields = fields + + +class IssueAttachmentSerializer(BaseSerializer): + asset_url = serializers.CharField(read_only=True) + + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + +class IssueAttachmentLiteSerializer(DynamicBaseSerializer): + class Meta: + model = FileAsset + fields = [ + "id", + "asset", + "attributes", + # "issue_id", + "created_by", + "updated_at", + "updated_by", + "asset_url", + ] + read_only_fields = fields + + +class IssueReactionSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "issue", "actor", "deleted_at"] + + +class IssueReactionLiteSerializer(DynamicBaseSerializer): + class Meta: + model = IssueReaction + fields = ["id", "actor", "issue", "reaction"] + + +class CommentReactionSerializer(BaseSerializer): + class Meta: + model = CommentReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"] + + +class IssueVoteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueVote + fields = ["issue", "vote", "workspace", "project", "actor", "actor_detail"] + read_only_fields = fields + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionSerializer(read_only=True, many=True) + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] + + +# Issue Serializer with state details +class IssueStateSerializer(DynamicBaseSerializer): + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Issue + fields = "__all__" + + +class IssueIntakeSerializer(DynamicBaseSerializer): + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "priority", + "sequence_id", + "project_id", + "created_at", + "label_ids", + "created_by", + ] + read_only_fields = fields + + +class IssueSerializer(DynamicBaseSerializer): + # ids + cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) + module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + # Many to many + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + # Count items + sub_issues_count = serializers.IntegerField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ] + read_only_fields = fields + + +class IssueListDetailSerializer(serializers.Serializer): + def __init__(self, *args, **kwargs): + # Extract expand parameter and store it as instance variable + self.expand = kwargs.pop("expand", []) or [] + # Extract fields parameter and store it as instance variable + self.fields = kwargs.pop("fields", []) or [] + super().__init__(*args, **kwargs) + + def get_module_ids(self, obj): + return [module.module_id for module in obj.issue_module.all()] + + def get_label_ids(self, obj): + return [label.label_id for label in obj.label_issue.all()] + + def get_assignee_ids(self, obj): + return [assignee.assignee_id for assignee in obj.issue_assignee.all()] + + def to_representation(self, instance): + data = { + # Basic fields + "id": instance.id, + "name": instance.name, + "state_id": instance.state_id, + "sort_order": instance.sort_order, + "completed_at": instance.completed_at, + "estimate_point": instance.estimate_point_id, + "priority": instance.priority, + "start_date": instance.start_date, + "target_date": instance.target_date, + "sequence_id": instance.sequence_id, + "project_id": instance.project_id, + "parent_id": instance.parent_id, + "created_at": instance.created_at, + "updated_at": instance.updated_at, + "created_by": instance.created_by_id, + "updated_by": instance.updated_by_id, + "is_draft": instance.is_draft, + "archived_at": instance.archived_at, + # Computed fields + "cycle_id": instance.cycle_id, + "module_ids": self.get_module_ids(instance), + "label_ids": self.get_label_ids(instance), + "assignee_ids": self.get_assignee_ids(instance), + "sub_issues_count": instance.sub_issues_count, + "attachment_count": instance.attachment_count, + "link_count": instance.link_count, + } + + # Handle expanded fields only when requested - using direct field access + if self.expand: + if "issue_relation" in self.expand: + relations = [] + for relation in instance.issue_relation.all(): + related_issue = relation.related_issue + # If the related issue is deleted, skip it + if not related_issue: + continue + # Add the related issue to the relations list + relations.append( + { + "id": related_issue.id, + "project_id": related_issue.project_id, + "sequence_id": related_issue.sequence_id, + "name": related_issue.name, + "relation_type": relation.relation_type, + "state_id": related_issue.state_id, + "priority": related_issue.priority, + "created_by": related_issue.created_by_id, + "created_at": related_issue.created_at, + "updated_at": related_issue.updated_at, + "updated_by": related_issue.updated_by_id, + } + ) + data["issue_relation"] = relations + + if "issue_related" in self.expand: + related = [] + for relation in instance.issue_related.all(): + issue = relation.issue + # If the related issue is deleted, skip it + if not issue: + continue + # Add the related issue to the related list + related.append( + { + "id": issue.id, + "project_id": issue.project_id, + "sequence_id": issue.sequence_id, + "name": issue.name, + "relation_type": relation.relation_type, + "state_id": issue.state_id, + "priority": issue.priority, + "created_by": issue.created_by_id, + "created_at": issue.created_at, + "updated_at": issue.updated_at, + "updated_by": issue.updated_by_id, + } + ) + data["issue_related"] = related + + return data + + +class IssueLiteSerializer(DynamicBaseSerializer): + class Meta: + model = Issue + fields = ["id", "sequence_id", "project_id"] + read_only_fields = fields + + +class IssueDetailSerializer(IssueSerializer): + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta(IssueSerializer.Meta): + fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"] + read_only_fields = fields + + +class IssuePublicSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateLiteSerializer(read_only=True, source="state") + reactions = IssueReactionSerializer( + read_only=True, many=True, source="issue_reactions" + ) + votes = IssueVoteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description_html", + "sequence_id", + "state", + "state_detail", + "project", + "project_detail", + "workspace", + "priority", + "target_date", + "reactions", + "votes", + ] + read_only_fields = fields + + +class IssueSubscriberSerializer(BaseSerializer): + class Meta: + model = IssueSubscriber + fields = "__all__" + read_only_fields = ["workspace", "project", "issue"] + + +class IssueVersionDetailSerializer(BaseSerializer): + class Meta: + model = IssueVersion + fields = [ + "id", + "workspace", + "project", + "issue", + "parent", + "state", + "estimate_point", + "name", + "priority", + "start_date", + "target_date", + "assignees", + "sequence_id", + "labels", + "sort_order", + "completed_at", + "archived_at", + "is_draft", + "external_source", + "external_id", + "type", + "cycle", + "modules", + "meta", + "name", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "issue"] + + +class IssueDescriptionVersionDetailSerializer(BaseSerializer): + class Meta: + model = IssueDescriptionVersion + fields = [ + "id", + "workspace", + "project", + "issue", + "description_binary", + "description_html", + "description_stripped", + "description_json", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "issue"] diff --git a/apiserver/plane/app/serializers/module.py b/apps/api/plane/app/serializers/module.py similarity index 100% rename from apiserver/plane/app/serializers/module.py rename to apps/api/plane/app/serializers/module.py diff --git a/apiserver/plane/app/serializers/notification.py b/apps/api/plane/app/serializers/notification.py similarity index 100% rename from apiserver/plane/app/serializers/notification.py rename to apps/api/plane/app/serializers/notification.py diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py new file mode 100644 index 00000000000..78762e4b4e1 --- /dev/null +++ b/apps/api/plane/app/serializers/page.py @@ -0,0 +1,262 @@ +# Third party imports +from rest_framework import serializers +import base64 + +# Module imports +from .base import BaseSerializer +from plane.utils.content_validator import ( + validate_binary_data, + validate_html_content, + validate_json_content, +) +from plane.db.models import ( + Page, + PageLog, + PageLabel, + Label, + ProjectPage, + Project, + PageVersion, +) + + +class PageSerializer(BaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + # Many to many + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + project_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = Page + fields = [ + "id", + "name", + "owned_by", + "access", + "color", + "labels", + "parent", + "is_favorite", + "is_locked", + "archived_at", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + "view_props", + "logo_props", + "label_ids", + "project_ids", + ] + read_only_fields = ["workspace", "owned_by"] + + def create(self, validated_data): + labels = validated_data.pop("labels", None) + project_id = self.context["project_id"] + owned_by_id = self.context["owned_by_id"] + description = self.context["description"] + description_binary = self.context["description_binary"] + description_html = self.context["description_html"] + + # Get the workspace id from the project + project = Project.objects.get(pk=project_id) + + # Create the page + page = Page.objects.create( + **validated_data, + description=description, + description_binary=description_binary, + description_html=description_html, + owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) + + # Create the project page + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + # Create page labels + if labels is not None: + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=page, + workspace_id=page.workspace_id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return page + + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + if labels is not None: + PageLabel.objects.filter(page=instance).delete() + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=instance, + workspace_id=instance.workspace_id, + created_by_id=instance.created_by_id, + updated_by_id=instance.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class PageDetailSerializer(PageSerializer): + description_html = serializers.CharField() + + class Meta(PageSerializer.Meta): + fields = PageSerializer.Meta.fields + ["description_html"] + + +class SubPageSerializer(BaseSerializer): + entity_details = serializers.SerializerMethodField() + + class Meta: + model = PageLog + fields = "__all__" + read_only_fields = ["workspace", "page"] + + def get_entity_details(self, obj): + entity_name = obj.entity_name + if entity_name == "forward_link" or entity_name == "back_link": + try: + page = Page.objects.get(pk=obj.entity_identifier) + return PageSerializer(page).data + except Page.DoesNotExist: + return None + return None + + +class PageLogSerializer(BaseSerializer): + class Meta: + model = PageLog + fields = "__all__" + read_only_fields = ["workspace", "page"] + + +class PageVersionSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "page"] + + +class PageVersionDetailSerializer(BaseSerializer): + class Meta: + model = PageVersion + fields = [ + "id", + "workspace", + "page", + "last_saved_at", + "description_binary", + "description_html", + "description_json", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "page"] + + +class PageBinaryUpdateSerializer(serializers.Serializer): + """Serializer for updating page binary description with validation""" + + description_binary = serializers.CharField(required=False, allow_blank=True) + description_html = serializers.CharField(required=False, allow_blank=True) + description = serializers.JSONField(required=False, allow_null=True) + + def validate_description_binary(self, value): + """Validate the base64-encoded binary data""" + if not value: + return value + + try: + # Decode the base64 data + binary_data = base64.b64decode(value) + + # Validate the binary data + is_valid, error_message = validate_binary_data(binary_data) + if not is_valid: + raise serializers.ValidationError( + f"Invalid binary data: {error_message}" + ) + + return binary_data + except Exception as e: + if isinstance(e, serializers.ValidationError): + raise + raise serializers.ValidationError("Failed to decode base64 data") + + def validate_description_html(self, value): + """Validate the HTML content""" + if not value: + return value + + # Use the validation function from utils + is_valid, error_message = validate_html_content(value) + if not is_valid: + raise serializers.ValidationError(error_message) + + return value + + def validate_description(self, value): + """Validate the JSON description""" + if not value: + return value + + # Use the validation function from utils + is_valid, error_message = validate_json_content(value) + if not is_valid: + raise serializers.ValidationError(error_message) + + return value + + def update(self, instance, validated_data): + """Update the page instance with validated data""" + if "description_binary" in validated_data: + instance.description_binary = validated_data.get("description_binary") + + if "description_html" in validated_data: + instance.description_html = validated_data.get("description_html") + + if "description" in validated_data: + instance.description = validated_data.get("description") + + instance.save() + return instance diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py new file mode 100644 index 00000000000..dfa541d9f17 --- /dev/null +++ b/apps/api/plane/app/serializers/project.py @@ -0,0 +1,226 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer, DynamicBaseSerializer +from plane.app.serializers.workspace import WorkspaceLiteSerializer +from plane.app.serializers.user import UserLiteSerializer, UserAdminLiteSerializer +from plane.db.models import ( + Project, + ProjectMember, + ProjectMemberInvite, + ProjectIdentifier, + DeployBoard, + ProjectPublicMember, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) + + +class ProjectSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + inbox_view = serializers.BooleanField(read_only=True, source="intake_view") + + class Meta: + model = Project + fields = "__all__" + read_only_fields = ["workspace", "deleted_at"] + + def validate_name(self, name): + project_id = self.instance.id if self.instance else None + workspace_id = self.context["workspace_id"] + + project = Project.objects.filter(name=name, workspace_id=workspace_id) + + if project_id: + project = project.exclude(id=project_id) + + if project.exists(): + raise serializers.ValidationError( + detail="PROJECT_NAME_ALREADY_EXIST", + ) + + return name + + def validate_identifier(self, identifier): + project_id = self.instance.id if self.instance else None + workspace_id = self.context["workspace_id"] + + project = Project.objects.filter( + identifier=identifier, workspace_id=workspace_id + ) + + if project_id: + project = project.exclude(id=project_id) + + if project.exists(): + raise serializers.ValidationError( + detail="PROJECT_IDENTIFIER_ALREADY_EXIST", + ) + + return identifier + + def validate(self, data): + # Validate description content for security + if "description" in data and data["description"]: + # For Project, description might be text field, not JSON + if isinstance(data["description"], dict): + is_valid, error_msg = validate_json_content(data["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if "description_text" in data and data["description_text"]: + is_valid, error_msg = validate_json_content(data["description_text"]) + if not is_valid: + raise serializers.ValidationError({"description_text": error_msg}) + + if "description_html" in data and data["description_html"]: + if isinstance(data["description_html"], dict): + is_valid, error_msg = validate_json_content(data["description_html"]) + else: + is_valid, error_msg = validate_html_content( + str(data["description_html"]) + ) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + return data + + def create(self, validated_data): + workspace_id = self.context["workspace_id"] + + project = Project.objects.create(**validated_data, workspace_id=workspace_id) + + ProjectIdentifier.objects.create( + name=project.identifier, project=project, workspace_id=workspace_id + ) + + return project + + +class ProjectLiteSerializer(BaseSerializer): + class Meta: + model = Project + fields = [ + "id", + "identifier", + "name", + "cover_image", + "cover_image_url", + "logo_props", + "description", + ] + read_only_fields = fields + + +class ProjectListSerializer(DynamicBaseSerializer): + is_favorite = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + anchor = serializers.CharField(read_only=True) + members = serializers.SerializerMethodField() + cover_image_url = serializers.CharField(read_only=True) + inbox_view = serializers.BooleanField(read_only=True, source="intake_view") + + def get_members(self, obj): + project_members = getattr(obj, "members_list", None) + if project_members is not None: + # Filter members by the project ID + return [ + member.member_id + for member in project_members + if member.is_active and not member.member.is_bot + ] + return [] + + class Meta: + model = Project + fields = "__all__" + + +class ProjectDetailSerializer(BaseSerializer): + # workspace = WorkSpaceSerializer(read_only=True) + default_assignee = UserLiteSerializer(read_only=True) + project_lead = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) + member_role = serializers.IntegerField(read_only=True) + anchor = serializers.CharField(read_only=True) + + class Meta: + model = Project + fields = "__all__" + + +class ProjectMemberSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + member = UserLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberAdminSerializer(BaseSerializer): + workspace = WorkspaceLiteSerializer(read_only=True) + project = ProjectLiteSerializer(read_only=True) + member = UserAdminLiteSerializer(read_only=True) + + class Meta: + model = ProjectMember + fields = "__all__" + + +class ProjectMemberRoleSerializer(DynamicBaseSerializer): + original_role = serializers.IntegerField(source="role", read_only=True) + + class Meta: + model = ProjectMember + fields = ("id", "role", "member", "project", "original_role", "created_at") + read_only_fields = ["original_role", "created_at"] + + +class ProjectMemberInviteSerializer(BaseSerializer): + project = ProjectLiteSerializer(read_only=True) + workspace = WorkspaceLiteSerializer(read_only=True) + + class Meta: + model = ProjectMemberInvite + fields = "__all__" + + +class ProjectIdentifierSerializer(BaseSerializer): + class Meta: + model = ProjectIdentifier + fields = "__all__" + + +class ProjectMemberLiteSerializer(BaseSerializer): + member = UserLiteSerializer(read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + + class Meta: + model = ProjectMember + fields = ["member", "id", "is_subscribed"] + read_only_fields = fields + + +class DeployBoardSerializer(BaseSerializer): + project_details = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + class Meta: + model = DeployBoard + fields = "__all__" + read_only_fields = ["workspace", "project", "anchor"] + + +class ProjectPublicMemberSerializer(BaseSerializer): + class Meta: + model = ProjectPublicMember + fields = "__all__" + read_only_fields = ["workspace", "project", "member"] diff --git a/apiserver/plane/app/serializers/state.py b/apps/api/plane/app/serializers/state.py similarity index 100% rename from apiserver/plane/app/serializers/state.py rename to apps/api/plane/app/serializers/state.py diff --git a/apps/api/plane/app/serializers/user.py b/apps/api/plane/app/serializers/user.py new file mode 100644 index 00000000000..7b545356884 --- /dev/null +++ b/apps/api/plane/app/serializers/user.py @@ -0,0 +1,229 @@ +# Third party imports +from rest_framework import serializers + +# Module import +from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite +from plane.utils.url import contains_url + +from .base import BaseSerializer + + +class UserSerializer(BaseSerializer): + def validate_first_name(self, value): + if contains_url(value): + raise serializers.ValidationError("First name cannot contain a URL.") + return value + + def validate_last_name(self, value): + if contains_url(value): + raise serializers.ValidationError("Last name cannot contain a URL.") + return value + + class Meta: + model = User + # Exclude password field from the serializer + fields = [field.name for field in User._meta.fields if field.name != "password"] + # Make all system fields and email read only + read_only_fields = [ + "id", + "username", + "mobile_number", + "email", + "token", + "created_at", + "updated_at", + "is_superuser", + "is_staff", + "is_managed", + "last_active", + "last_login_time", + "last_logout_time", + "last_login_ip", + "last_logout_ip", + "last_login_uagent", + "last_location", + "last_login_medium", + "created_location", + "is_bot", + "is_password_autoset", + "is_email_verified", + "is_active", + "token_updated_at", + ] + + # If the user has already filled first name or last name then he is onboarded + def get_is_onboarded(self, obj): + return bool(obj.first_name) or bool(obj.last_name) + + +class UserMeSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "avatar", + "cover_image", + "avatar_url", + "cover_image_url", + "date_joined", + "display_name", + "email", + "first_name", + "last_name", + "is_active", + "is_bot", + "is_email_verified", + "user_timezone", + "username", + "is_password_autoset", + "is_email_verified", + "last_login_medium", + ] + read_only_fields = fields + + +class UserMeSettingsSerializer(BaseSerializer): + workspace = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["id", "email", "workspace"] + read_only_fields = fields + + def get_workspace(self, obj): + workspace_invites = WorkspaceMemberInvite.objects.filter( + email=obj.email + ).count() + + # profile + profile = Profile.objects.get(user=obj) + if ( + profile.last_workspace_id is not None + and Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member=obj.id, + workspace_member__is_active=True, + ).exists() + ): + workspace = Workspace.objects.filter( + pk=profile.last_workspace_id, + workspace_member__member=obj.id, + workspace_member__is_active=True, + ).first() + logo_asset_url = ( + workspace.logo_asset.asset_url + if workspace.logo_asset is not None + else "" + ) + return { + "last_workspace_id": profile.last_workspace_id, + "last_workspace_slug": ( + workspace.slug if workspace is not None else "" + ), + "last_workspace_name": ( + workspace.name if workspace is not None else "" + ), + "last_workspace_logo": (logo_asset_url), + "fallback_workspace_id": profile.last_workspace_id, + "fallback_workspace_slug": ( + workspace.slug if workspace is not None else "" + ), + "invites": workspace_invites, + } + else: + fallback_workspace = ( + Workspace.objects.filter( + workspace_member__member_id=obj.id, workspace_member__is_active=True + ) + .order_by("created_at") + .first() + ) + return { + "last_workspace_id": None, + "last_workspace_slug": None, + "fallback_workspace_id": ( + fallback_workspace.id if fallback_workspace is not None else None + ), + "fallback_workspace_slug": ( + fallback_workspace.slug if fallback_workspace is not None else None + ), + "invites": workspace_invites, + } + + +class UserLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "avatar_url", + "is_bot", + "display_name", + ] + read_only_fields = ["id", "is_bot"] + + +class UserAdminLiteSerializer(BaseSerializer): + class Meta: + model = User + fields = [ + "id", + "first_name", + "last_name", + "avatar", + "avatar_url", + "is_bot", + "display_name", + "email", + "last_login_medium", + ] + read_only_fields = ["id", "is_bot"] + + +class ChangePasswordSerializer(serializers.Serializer): + model = User + + """ + Serializer for password change endpoint. + """ + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + confirm_password = serializers.CharField(required=True, min_length=8) + + def validate(self, data): + if data.get("old_password") == data.get("new_password"): + raise serializers.ValidationError( + {"error": "New password cannot be same as old password."} + ) + + if data.get("new_password") != data.get("confirm_password"): + raise serializers.ValidationError( + {"error": "Confirm password should be same as the new password."} + ) + + return data + + +class ResetPasswordSerializer(serializers.Serializer): + """ + Serializer for password change endpoint. + """ + + new_password = serializers.CharField(required=True, min_length=8) + + +class ProfileSerializer(BaseSerializer): + class Meta: + model = Profile + fields = "__all__" + read_only_fields = ["user"] + + +class AccountSerializer(BaseSerializer): + class Meta: + model = Account + fields = "__all__" + read_only_fields = ["user"] diff --git a/apiserver/plane/app/serializers/view.py b/apps/api/plane/app/serializers/view.py similarity index 99% rename from apiserver/plane/app/serializers/view.py rename to apps/api/plane/app/serializers/view.py index 94ff68de39c..bf7ff9727c6 100644 --- a/apiserver/plane/app/serializers/view.py +++ b/apps/api/plane/app/serializers/view.py @@ -8,7 +8,6 @@ class ViewIssueListSerializer(serializers.Serializer): - def get_assignee_ids(self, instance): return [assignee.assignee_id for assignee in instance.issue_assignee.all()] diff --git a/apiserver/plane/app/serializers/webhook.py b/apps/api/plane/app/serializers/webhook.py similarity index 100% rename from apiserver/plane/app/serializers/webhook.py rename to apps/api/plane/app/serializers/webhook.py diff --git a/apiserver/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py similarity index 90% rename from apiserver/plane/app/serializers/workspace.py rename to apps/api/plane/app/serializers/workspace.py index b4e75a5065a..ec4c4bf63e0 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -24,6 +24,11 @@ ) from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS from plane.utils.url import contains_url +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) # Django imports from django.core.validators import URLValidator @@ -76,7 +81,6 @@ class Meta: class WorkSpaceMemberSerializer(DynamicBaseSerializer): member = UserLiteSerializer(read_only=True) - workspace = WorkspaceLiteSerializer(read_only=True) class Meta: model = WorkspaceMember @@ -93,7 +97,6 @@ class Meta: class WorkspaceMemberAdminSerializer(DynamicBaseSerializer): member = UserAdminLiteSerializer(read_only=True) - workspace = WorkspaceLiteSerializer(read_only=True) class Meta: model = WorkspaceMember @@ -314,6 +317,25 @@ class Meta: read_only_fields = ["workspace", "owner"] extra_kwargs = {"name": {"required": False}} + def validate(self, data): + # Validate description content for security + if "description" in data and data["description"]: + is_valid, error_msg = validate_json_content(data["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if "description_html" in data and data["description_html"]: + is_valid, error_msg = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + if "description_binary" in data and data["description_binary"]: + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": error_msg}) + + return data + class WorkspaceUserPreferenceSerializer(BaseSerializer): class Meta: diff --git a/apiserver/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py similarity index 100% rename from apiserver/plane/app/urls/__init__.py rename to apps/api/plane/app/urls/__init__.py diff --git a/apiserver/plane/app/urls/analytic.py b/apps/api/plane/app/urls/analytic.py similarity index 100% rename from apiserver/plane/app/urls/analytic.py rename to apps/api/plane/app/urls/analytic.py diff --git a/apiserver/plane/app/urls/api.py b/apps/api/plane/app/urls/api.py similarity index 100% rename from apiserver/plane/app/urls/api.py rename to apps/api/plane/app/urls/api.py diff --git a/apiserver/plane/app/urls/asset.py b/apps/api/plane/app/urls/asset.py similarity index 100% rename from apiserver/plane/app/urls/asset.py rename to apps/api/plane/app/urls/asset.py diff --git a/apiserver/plane/app/urls/cycle.py b/apps/api/plane/app/urls/cycle.py similarity index 100% rename from apiserver/plane/app/urls/cycle.py rename to apps/api/plane/app/urls/cycle.py diff --git a/apiserver/plane/app/urls/estimate.py b/apps/api/plane/app/urls/estimate.py similarity index 100% rename from apiserver/plane/app/urls/estimate.py rename to apps/api/plane/app/urls/estimate.py diff --git a/apiserver/plane/app/urls/external.py b/apps/api/plane/app/urls/external.py similarity index 100% rename from apiserver/plane/app/urls/external.py rename to apps/api/plane/app/urls/external.py diff --git a/apiserver/plane/app/urls/intake.py b/apps/api/plane/app/urls/intake.py similarity index 100% rename from apiserver/plane/app/urls/intake.py rename to apps/api/plane/app/urls/intake.py diff --git a/apiserver/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py similarity index 100% rename from apiserver/plane/app/urls/issue.py rename to apps/api/plane/app/urls/issue.py diff --git a/apiserver/plane/app/urls/module.py b/apps/api/plane/app/urls/module.py similarity index 100% rename from apiserver/plane/app/urls/module.py rename to apps/api/plane/app/urls/module.py diff --git a/apiserver/plane/app/urls/notification.py b/apps/api/plane/app/urls/notification.py similarity index 100% rename from apiserver/plane/app/urls/notification.py rename to apps/api/plane/app/urls/notification.py diff --git a/apiserver/plane/app/urls/page.py b/apps/api/plane/app/urls/page.py similarity index 100% rename from apiserver/plane/app/urls/page.py rename to apps/api/plane/app/urls/page.py diff --git a/apiserver/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py similarity index 100% rename from apiserver/plane/app/urls/project.py rename to apps/api/plane/app/urls/project.py diff --git a/apiserver/plane/app/urls/search.py b/apps/api/plane/app/urls/search.py similarity index 100% rename from apiserver/plane/app/urls/search.py rename to apps/api/plane/app/urls/search.py diff --git a/apiserver/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py similarity index 100% rename from apiserver/plane/app/urls/state.py rename to apps/api/plane/app/urls/state.py diff --git a/apiserver/plane/app/urls/timezone.py b/apps/api/plane/app/urls/timezone.py similarity index 100% rename from apiserver/plane/app/urls/timezone.py rename to apps/api/plane/app/urls/timezone.py diff --git a/apiserver/plane/app/urls/user.py b/apps/api/plane/app/urls/user.py similarity index 100% rename from apiserver/plane/app/urls/user.py rename to apps/api/plane/app/urls/user.py diff --git a/apiserver/plane/app/urls/views.py b/apps/api/plane/app/urls/views.py similarity index 100% rename from apiserver/plane/app/urls/views.py rename to apps/api/plane/app/urls/views.py diff --git a/apiserver/plane/app/urls/webhook.py b/apps/api/plane/app/urls/webhook.py similarity index 100% rename from apiserver/plane/app/urls/webhook.py rename to apps/api/plane/app/urls/webhook.py diff --git a/apiserver/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py similarity index 100% rename from apiserver/plane/app/urls/workspace.py rename to apps/api/plane/app/urls/workspace.py diff --git a/apiserver/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py similarity index 100% rename from apiserver/plane/app/views/__init__.py rename to apps/api/plane/app/views/__init__.py diff --git a/apiserver/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py similarity index 98% rename from apiserver/plane/app/views/analytic/advance.py rename to apps/api/plane/app/views/analytic/advance.py index 8a2aea90b7c..c690fbe7dc2 100644 --- a/apiserver/plane/app/views/analytic/advance.py +++ b/apps/api/plane/app/views/analytic/advance.py @@ -160,7 +160,8 @@ def get_project_issues_stats(self) -> QuerySet: ) return ( - base_queryset.values("project_id", "project__name").annotate( + base_queryset.values("project_id", "project__name") + .annotate( cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), completed_work_items=Count("id", filter=Q(state__group="completed")), backlog_work_items=Count("id", filter=Q(state__group="backlog")), @@ -173,8 +174,7 @@ def get_project_issues_stats(self) -> QuerySet: def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) return ( - base_queryset - .values("project_id", "project__name") + base_queryset.values("project_id", "project__name") .annotate( cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), completed_work_items=Count("id", filter=Q(state__group="completed")), diff --git a/apiserver/plane/app/views/analytic/base.py b/apps/api/plane/app/views/analytic/base.py similarity index 100% rename from apiserver/plane/app/views/analytic/base.py rename to apps/api/plane/app/views/analytic/base.py diff --git a/apiserver/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py similarity index 100% rename from apiserver/plane/app/views/analytic/project_analytics.py rename to apps/api/plane/app/views/analytic/project_analytics.py diff --git a/apiserver/plane/app/views/api.py b/apps/api/plane/app/views/api.py similarity index 100% rename from apiserver/plane/app/views/api.py rename to apps/api/plane/app/views/api.py diff --git a/apiserver/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py similarity index 100% rename from apiserver/plane/app/views/asset/base.py rename to apps/api/plane/app/views/asset/base.py diff --git a/apiserver/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py similarity index 99% rename from apiserver/plane/app/views/asset/v2.py rename to apps/api/plane/app/views/asset/v2.py index 5994ffd8c16..7e0c14fdd8a 100644 --- a/apiserver/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -740,7 +740,8 @@ def get(self, request, slug, asset_id): storage = S3Storage(request=request) signed_url = storage.generate_presigned_url( object_name=asset.asset.name, - disposition=f"attachment; filename={asset.asset.name}", + disposition="attachment", + filename=asset.attributes.get("name", uuid.uuid4().hex), ) return HttpResponseRedirect(signed_url) @@ -767,7 +768,8 @@ def get(self, request, slug, project_id, asset_id): storage = S3Storage(request=request) signed_url = storage.generate_presigned_url( object_name=asset.asset.name, - disposition=f"attachment; filename={asset.asset.name}", + disposition="attachment", + filename=asset.attributes.get("name", uuid.uuid4().hex), ) return HttpResponseRedirect(signed_url) diff --git a/apiserver/plane/app/views/base.py b/apps/api/plane/app/views/base.py similarity index 100% rename from apiserver/plane/app/views/base.py rename to apps/api/plane/app/views/base.py diff --git a/apiserver/plane/app/views/cycle/archive.py b/apps/api/plane/app/views/cycle/archive.py similarity index 100% rename from apiserver/plane/app/views/cycle/archive.py rename to apps/api/plane/app/views/cycle/archive.py diff --git a/apiserver/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py similarity index 100% rename from apiserver/plane/app/views/cycle/base.py rename to apps/api/plane/app/views/cycle/base.py diff --git a/apiserver/plane/app/views/cycle/issue.py b/apps/api/plane/app/views/cycle/issue.py similarity index 100% rename from apiserver/plane/app/views/cycle/issue.py rename to apps/api/plane/app/views/cycle/issue.py diff --git a/apiserver/plane/app/views/error_404.py b/apps/api/plane/app/views/error_404.py similarity index 100% rename from apiserver/plane/app/views/error_404.py rename to apps/api/plane/app/views/error_404.py diff --git a/apiserver/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py similarity index 100% rename from apiserver/plane/app/views/estimate/base.py rename to apps/api/plane/app/views/estimate/base.py diff --git a/apiserver/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py similarity index 100% rename from apiserver/plane/app/views/exporter/base.py rename to apps/api/plane/app/views/exporter/base.py diff --git a/apiserver/plane/app/views/external/base.py b/apps/api/plane/app/views/external/base.py similarity index 100% rename from apiserver/plane/app/views/external/base.py rename to apps/api/plane/app/views/external/base.py diff --git a/apiserver/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py similarity index 100% rename from apiserver/plane/app/views/intake/base.py rename to apps/api/plane/app/views/intake/base.py diff --git a/apiserver/plane/app/views/issue/activity.py b/apps/api/plane/app/views/issue/activity.py similarity index 100% rename from apiserver/plane/app/views/issue/activity.py rename to apps/api/plane/app/views/issue/activity.py diff --git a/apiserver/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py similarity index 100% rename from apiserver/plane/app/views/issue/archive.py rename to apps/api/plane/app/views/issue/archive.py diff --git a/apiserver/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py similarity index 100% rename from apiserver/plane/app/views/issue/attachment.py rename to apps/api/plane/app/views/issue/attachment.py diff --git a/apiserver/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py similarity index 100% rename from apiserver/plane/app/views/issue/base.py rename to apps/api/plane/app/views/issue/base.py diff --git a/apiserver/plane/app/views/issue/comment.py b/apps/api/plane/app/views/issue/comment.py similarity index 100% rename from apiserver/plane/app/views/issue/comment.py rename to apps/api/plane/app/views/issue/comment.py diff --git a/apiserver/plane/app/views/issue/label.py b/apps/api/plane/app/views/issue/label.py similarity index 100% rename from apiserver/plane/app/views/issue/label.py rename to apps/api/plane/app/views/issue/label.py diff --git a/apiserver/plane/app/views/issue/link.py b/apps/api/plane/app/views/issue/link.py similarity index 100% rename from apiserver/plane/app/views/issue/link.py rename to apps/api/plane/app/views/issue/link.py diff --git a/apiserver/plane/app/views/issue/reaction.py b/apps/api/plane/app/views/issue/reaction.py similarity index 100% rename from apiserver/plane/app/views/issue/reaction.py rename to apps/api/plane/app/views/issue/reaction.py diff --git a/apiserver/plane/app/views/issue/relation.py b/apps/api/plane/app/views/issue/relation.py similarity index 100% rename from apiserver/plane/app/views/issue/relation.py rename to apps/api/plane/app/views/issue/relation.py diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py similarity index 100% rename from apiserver/plane/app/views/issue/sub_issue.py rename to apps/api/plane/app/views/issue/sub_issue.py diff --git a/apiserver/plane/app/views/issue/subscriber.py b/apps/api/plane/app/views/issue/subscriber.py similarity index 100% rename from apiserver/plane/app/views/issue/subscriber.py rename to apps/api/plane/app/views/issue/subscriber.py diff --git a/apiserver/plane/app/views/issue/version.py b/apps/api/plane/app/views/issue/version.py similarity index 100% rename from apiserver/plane/app/views/issue/version.py rename to apps/api/plane/app/views/issue/version.py diff --git a/apiserver/plane/app/views/module/archive.py b/apps/api/plane/app/views/module/archive.py similarity index 100% rename from apiserver/plane/app/views/module/archive.py rename to apps/api/plane/app/views/module/archive.py diff --git a/apiserver/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py similarity index 100% rename from apiserver/plane/app/views/module/base.py rename to apps/api/plane/app/views/module/base.py diff --git a/apiserver/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py similarity index 100% rename from apiserver/plane/app/views/module/issue.py rename to apps/api/plane/app/views/module/issue.py diff --git a/apps/api/plane/app/views/notification/base.py b/apps/api/plane/app/views/notification/base.py new file mode 100644 index 00000000000..e84cf4d2939 --- /dev/null +++ b/apps/api/plane/app/views/notification/base.py @@ -0,0 +1,362 @@ +# Django imports +from django.db.models import Exists, OuterRef, Q, Case, When, BooleanField +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +from plane.app.serializers import ( + NotificationSerializer, + UserNotificationPreferenceSerializer, +) +from plane.db.models import ( + Issue, + IssueAssignee, + IssueSubscriber, + Notification, + UserNotificationPreference, + WorkspaceMember, +) +from plane.utils.paginator import BasePaginator +from plane.app.permissions import allow_permission, ROLE + +# Module imports +from ..base import BaseAPIView, BaseViewSet + + +class NotificationViewSet(BaseViewSet, BasePaginator): + model = Notification + serializer_class = NotificationSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + receiver_id=self.request.user.id, + ) + .select_related("workspace", "project", "triggered_by", "receiver") + ) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + # Get query parameters + snoozed = request.GET.get("snoozed", "false") + archived = request.GET.get("archived", "false") + read = request.GET.get("read", None) + type = request.GET.get("type", "all") + mentioned = request.GET.get("mentioned", False) + q_filters = Q() + + intake_issue = Issue.objects.filter( + pk=OuterRef("entity_identifier"), + issue_intake__status__in=[0, 2, -2], + workspace__slug=self.kwargs.get("slug"), + ) + + notifications = ( + Notification.objects.filter( + workspace__slug=slug, receiver_id=request.user.id + ) + .filter(entity_name="issue") + .annotate(is_inbox_issue=Exists(intake_issue)) + .annotate(is_intake_issue=Exists(intake_issue)) + .annotate( + is_mentioned_notification=Case( + When(sender__icontains="mentioned", then=True), + default=False, + output_field=BooleanField(), + ) + ) + .select_related("workspace", "project", "triggered_by", "receiver") + .order_by("snoozed_till", "-created_at") + ) + + # Filters based on query parameters + snoozed_filters = { + "true": Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False), + "false": Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + } + + notifications = notifications.filter(snoozed_filters[snoozed]) + + archived_filters = { + "true": Q(archived_at__isnull=False), + "false": Q(archived_at__isnull=True), + } + + notifications = notifications.filter(archived_filters[archived]) + + if read == "false": + notifications = notifications.filter(read_at__isnull=True) + + if read == "true": + notifications = notifications.filter(read_at__isnull=False) + + if mentioned: + notifications = notifications.filter(sender__icontains="mentioned") + else: + notifications = notifications.exclude(sender__icontains="mentioned") + + type = type.split(",") + # Subscribed issues + if "subscribed" in type: + issue_ids = ( + IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ) + .annotate( + created=Exists( + Issue.objects.filter( + created_by=request.user, pk=OuterRef("issue_id") + ) + ) + ) + .annotate( + assigned=Exists( + IssueAssignee.objects.filter( + pk=OuterRef("issue_id"), assignee=request.user + ) + ) + ) + .filter(created=False, assigned=False) + .values_list("issue_id", flat=True) + ) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Assigned Issues + if "assigned" in type: + issue_ids = IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Created issues + if "created" in type: + if WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, role__lt=15, is_active=True + ).exists(): + notifications = notifications.none() + else: + issue_ids = Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Apply the combined Q object filters + notifications = notifications.filter(q_filters) + + # Pagination + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=(notifications), + on_results=lambda notifications: NotificationSerializer( + notifications, many=True + ).data, + ) + + serializer = NotificationSerializer(notifications, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def partial_update(self, request, slug, pk): + notification = Notification.objects.get( + workspace__slug=slug, pk=pk, receiver=request.user + ) + # Only read_at and snoozed_till can be updated + notification_data = {"snoozed_till": request.data.get("snoozed_till", None)} + serializer = NotificationSerializer( + notification, data=notification_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) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def mark_read(self, request, slug, pk): + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.read_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def mark_unread(self, request, slug, pk): + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.read_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def archive(self, request, slug, pk): + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.archived_at = timezone.now() + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def unarchive(self, request, slug, pk): + notification = Notification.objects.get( + receiver=request.user, workspace__slug=slug, pk=pk + ) + notification.archived_at = None + notification.save() + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UnreadNotificationEndpoint(BaseAPIView): + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def get(self, request, slug): + # Watching Issues Count + unread_notifications_count = ( + Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + snoozed_till__isnull=True, + ) + .exclude(sender__icontains="mentioned") + .count() + ) + + mention_notifications_count = Notification.objects.filter( + workspace__slug=slug, + receiver_id=request.user.id, + read_at__isnull=True, + archived_at__isnull=True, + snoozed_till__isnull=True, + sender__icontains="mentioned", + ).count() + + return Response( + { + "total_unread_notifications_count": int(unread_notifications_count), + "mention_unread_notifications_count": int(mention_notifications_count), + }, + status=status.HTTP_200_OK, + ) + + +class MarkAllReadNotificationViewSet(BaseViewSet): + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def create(self, request, slug): + snoozed = request.data.get("snoozed", False) + archived = request.data.get("archived", False) + type = request.data.get("type", "all") + + notifications = ( + Notification.objects.filter( + workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True + ) + .select_related("workspace", "project", "triggered_by", "receiver") + .order_by("snoozed_till", "-created_at") + ) + + # Filter for snoozed notifications + if snoozed: + notifications = notifications.filter( + Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) + ) + else: + notifications = notifications.filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True) + ) + + # Filter for archived or unarchive + if archived: + notifications = notifications.filter(archived_at__isnull=False) + else: + notifications = notifications.filter(archived_at__isnull=True) + + # Subscribed issues + if type == "watching": + issue_ids = IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # Assigned Issues + if type == "assigned": + issue_ids = IssueAssignee.objects.filter( + workspace__slug=slug, assignee_id=request.user.id + ).values_list("issue_id", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # Created issues + if type == "created": + if WorkspaceMember.objects.filter( + workspace__slug=slug, member=request.user, role__lt=15, is_active=True + ).exists(): + notifications = Notification.objects.none() + else: + issue_ids = Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + updated_notifications = [] + for notification in notifications: + notification.read_at = timezone.now() + updated_notifications.append(notification) + Notification.objects.bulk_update( + updated_notifications, ["read_at"], batch_size=100 + ) + return Response({"message": "Successful"}, status=status.HTTP_200_OK) + + +class UserNotificationPreferenceEndpoint(BaseAPIView): + model = UserNotificationPreference + serializer_class = UserNotificationPreferenceSerializer + + # request the object + def get(self, request): + user_notification_preference = UserNotificationPreference.objects.get( + user=request.user + ) + serializer = UserNotificationPreferenceSerializer(user_notification_preference) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update the object + def patch(self, request): + user_notification_preference = UserNotificationPreference.objects.get( + user=request.user + ) + serializer = UserNotificationPreferenceSerializer( + user_notification_preference, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py new file mode 100644 index 00000000000..96de81abfb2 --- /dev/null +++ b/apps/api/plane/app/views/page/base.py @@ -0,0 +1,626 @@ +# Python imports +import json +import base64 +from datetime import datetime +from django.core.serializers.json import DjangoJSONEncoder + +# Django imports +from django.db import connection +from django.db.models import Exists, OuterRef, Q, Value, UUIDField +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from django.http import StreamingHttpResponse +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ( + PageLogSerializer, + PageSerializer, + SubPageSerializer, + PageDetailSerializer, + PageBinaryUpdateSerializer, +) +from plane.db.models import ( + Page, + PageLog, + UserFavorite, + ProjectMember, + ProjectPage, + Project, + UserRecentVisit, +) +from plane.utils.error_codes import ERROR_CODES +from ..base import BaseAPIView, BaseViewSet +from plane.bgtasks.page_transaction_task import page_transaction +from plane.bgtasks.page_version_task import page_version +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets + + +def unarchive_archive_page_and_descendants(page_id, archived_at): + # Your SQL query + sql = """ + WITH RECURSIVE descendants AS ( + SELECT id FROM pages WHERE id = %s + UNION ALL + SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id + ) + UPDATE pages SET archived_at = %s WHERE id IN (SELECT id FROM descendants); + """ + + # Execute the SQL query + with connection.cursor() as cursor: + cursor.execute(sql, [page_id, archived_at]) + + +class PageViewSet(BaseViewSet): + serializer_class = PageSerializer + model = Page + search_fields = ["name"] + + def get_queryset(self): + subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_type="page", + entity_identifier=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(parent__isnull=True) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .prefetch_related("projects") + .select_related("workspace") + .select_related("owned_by") + .annotate(is_favorite=Exists(subquery)) + .order_by(self.request.GET.get("order_by", "-created_at")) + .prefetch_related("labels") + .order_by("-is_favorite", "-created_at") + .annotate( + project=Exists( + ProjectPage.objects.filter( + page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") + ) + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .filter(project=True) + .distinct() + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): + serializer = PageSerializer( + data=request.data, + context={ + "project_id": project_id, + "owned_by_id": request.user.id, + "description": request.data.get("description", {}), + "description_binary": request.data.get("description_binary", None), + "description_html": request.data.get("description_html", "

"), + }, + ) + + if serializer.is_valid(): + serializer.save() + # capture the page transaction + page_transaction.delay(request.data, None, serializer.data["id"]) + page = self.get_queryset().get(pk=serializer.data["id"]) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def partial_update(self, request, slug, project_id, pk): + try: + page = Page.objects.get( + pk=pk, workspace__slug=slug, projects__id=project_id + ) + + if page.is_locked: + return Response( + {"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST + ) + + parent = request.data.get("parent", None) + if parent: + _ = Page.objects.get( + pk=parent, workspace__slug=slug, projects__id=project_id + ) + + # Only update access if the page owner is the requesting user + if ( + page.access != request.data.get("access", page.access) + and page.owned_by_id != request.user.id + ): + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = PageDetailSerializer(page, data=request.data, partial=True) + page_description = page.description_html + if serializer.is_valid(): + serializer.save() + # capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_value=request.data, + old_value=json.dumps( + {"description_html": page_description}, + cls=DjangoJSONEncoder, + ), + page_id=pk, + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Page.DoesNotExist: + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def retrieve(self, request, slug, project_id, pk=None): + page = self.get_queryset().filter(pk=pk).first() + project = Project.objects.get(pk=project_id) + + """ + if the role is guest and guest_view_all_features is false and owned by is not + the requesting user then dont show the page + """ + + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + and not page.owned_by == request.user + ): + return Response( + {"error": "You are not allowed to view this page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page is None: + return Response( + {"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND + ) + else: + issue_ids = PageLog.objects.filter( + page_id=pk, entity_name="issue" + ).values_list("entity_identifier", flat=True) + data = PageDetailSerializer(page).data + data["issue_ids"] = issue_ids + recent_visited_task.delay( + slug=slug, + entity_name="page", + entity_identifier=pk, + user_id=request.user.id, + project_id=project_id, + ) + return Response(data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], model=Page, creator=True) + def lock(self, request, slug, project_id, pk): + page = Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ).first() + + page.is_locked = True + page.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN], model=Page, creator=True) + def unlock(self, request, slug, project_id, pk): + page = Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ).first() + + page.is_locked = False + page.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN], model=Page, creator=True) + def access(self, request, slug, project_id, pk): + access = request.data.get("access", 0) + page = Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ).first() + + # Only update access if the page owner is the requesting user + if ( + page.access != request.data.get("access", page.access) + and page.owned_by_id != request.user.id + ): + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + page.access = access + page.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def list(self, request, slug, project_id): + queryset = self.get_queryset() + project = Project.objects.get(pk=project_id) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + queryset = queryset.filter(owned_by=request.user) + pages = PageSerializer(queryset, many=True).data + return Response(pages, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], model=Page, creator=True) + def archive(self, request, slug, project_id, pk): + page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) + + # only the owner or admin can archive the page + if ( + ProjectMember.objects.filter( + project_id=project_id, member=request.user, is_active=True, role__lte=15 + ).exists() + and request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner or admin can archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + UserFavorite.objects.filter( + entity_type="page", + entity_identifier=pk, + project_id=project_id, + workspace__slug=slug, + ).delete() + + unarchive_archive_page_and_descendants(pk, datetime.now()) + + return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN], model=Page, creator=True) + def unarchive(self, request, slug, project_id, pk): + page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) + + # only the owner or admin can un archive the page + if ( + ProjectMember.objects.filter( + project_id=project_id, member=request.user, is_active=True, role__lte=15 + ).exists() + and request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner or admin can un archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # if parent page is archived then the page will be un archived breaking the hierarchy + if page.parent_id and page.parent.archived_at: + page.parent = None + page.save(update_fields=["parent"]) + + unarchive_archive_page_and_descendants(pk, None) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN], model=Page, creator=True) + def destroy(self, request, slug, project_id, pk): + page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id) + + if page.archived_at is None: + return Response( + {"error": "The page should be archived before deleting"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.owned_by_id != request.user.id and ( + not ProjectMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or owner can delete the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # remove parent from all the children + _ = Page.objects.filter( + parent_id=pk, projects__id=project_id, workspace__slug=slug + ).update(parent=None) + + page.delete() + # Delete the user favorite page + UserFavorite.objects.filter( + project=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_type="page", + ).delete() + # Delete the page from recent visit + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="page", + ).delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageFavoriteViewSet(BaseViewSet): + model = UserFavorite + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id, pk): + _ = UserFavorite.objects.create( + project_id=project_id, + entity_identifier=pk, + entity_type="page", + user=request.user, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, pk): + page_favorite = UserFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + entity_identifier=pk, + entity_type="page", + ) + page_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageLogEndpoint(BaseAPIView): + serializer_class = PageLogSerializer + model = PageLog + + def post(self, request, slug, project_id, page_id): + serializer = PageLogSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, page_id=page_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, slug, project_id, page_id, transaction): + page_transaction = PageLog.objects.get( + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + transaction=transaction, + ) + serializer = PageLogSerializer( + page_transaction, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, page_id, transaction): + transaction = PageLog.objects.get( + workspace__slug=slug, + project_id=project_id, + page_id=page_id, + transaction=transaction, + ) + # Delete the transaction object + transaction.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class SubPagesEndpoint(BaseAPIView): + @method_decorator(gzip_page) + def get(self, request, slug, project_id, page_id): + pages = ( + PageLog.objects.filter( + page_id=page_id, + workspace__slug=slug, + entity_name__in=["forward_link", "back_link"], + ) + .select_related("project") + .select_related("workspace") + ) + return Response( + SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK + ) + + +class PagesDescriptionViewSet(BaseViewSet): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def retrieve(self, request, slug, project_id, pk): + page = ( + Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() + ) + if page is None: + return Response({"error": "Page not found"}, status=404) + binary_data = page.description_binary + + def stream_data(): + if binary_data: + yield binary_data + else: + yield b"" + + response = StreamingHttpResponse( + stream_data(), content_type="application/octet-stream" + ) + response["Content-Disposition"] = 'attachment; filename="page_description.bin"' + return response + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def partial_update(self, request, slug, project_id, pk): + page = ( + Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() + ) + + if page is None: + return Response({"error": "Page not found"}, status=404) + + if page.is_locked: + return Response( + { + "error_code": ERROR_CODES["PAGE_LOCKED"], + "error_message": "PAGE_LOCKED", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.archived_at: + return Response( + { + "error_code": ERROR_CODES["PAGE_ARCHIVED"], + "error_message": "PAGE_ARCHIVED", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Serialize the existing instance + existing_instance = json.dumps( + {"description_html": page.description_html}, cls=DjangoJSONEncoder + ) + + # Use serializer for validation and update + serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + # Capture the page transaction + if request.data.get("description_html"): + page_transaction.delay( + new_value=request.data, old_value=existing_instance, page_id=pk + ) + + # Update the page using serializer + updated_page = serializer.save() + + # Run background tasks + page_version.delay( + page_id=updated_page.id, + existing_instance=existing_instance, + user_id=request.user.id, + ) + return Response({"message": "Updated successfully"}) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PageDuplicateEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, page_id): + page = Page.objects.filter( + pk=page_id, workspace__slug=slug, projects__id=project_id + ).first() + + # check for permission + if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: + return Response( + {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN + ) + + # get all the project ids where page is present + project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( + "project_id", flat=True + ) + + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.owned_by = request.user + page.created_by = request.user + page.updated_by = request.user + page.save() + + for project_id in project_ids: + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + page_transaction.delay( + {"description_html": page.description_html}, None, page.id + ) + + # Copy the s3 objects uploaded in the page + copy_s3_objects_of_description_and_assets.delay( + entity_name="PAGE", + entity_identifier=page.id, + project_id=project_id, + slug=slug, + user_id=request.user.id, + ) + + page = ( + Page.objects.filter(pk=page.id) + .annotate( + project_ids=Coalesce( + ArrayAgg( + "projects__id", distinct=True, filter=~Q(projects__id=True) + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) + .first() + ) + serializer = PageDetailSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apiserver/plane/app/views/page/version.py b/apps/api/plane/app/views/page/version.py similarity index 100% rename from apiserver/plane/app/views/page/version.py rename to apps/api/plane/app/views/page/version.py diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py new file mode 100644 index 00000000000..1da2aa84b29 --- /dev/null +++ b/apps/api/plane/app/views/project/base.py @@ -0,0 +1,649 @@ +# Python imports +import boto3 +from django.conf import settings +from django.utils import timezone +import json + +# Django imports +from django.db import IntegrityError +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery +from django.core.serializers.json import DjangoJSONEncoder + +# Third Party imports +from rest_framework.response import Response +from rest_framework import serializers, status +from rest_framework.permissions import AllowAny + +# Module imports +from plane.app.views.base import BaseViewSet, BaseAPIView +from plane.app.serializers import ( + ProjectSerializer, + ProjectListSerializer, + DeployBoardSerializer, +) + +from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE +from plane.db.models import ( + UserFavorite, + Intake, + DeployBoard, + IssueUserProperty, + Project, + ProjectIdentifier, + ProjectMember, + State, + Workspace, + WorkspaceMember, +) +from plane.utils.cache import cache_response +from plane.bgtasks.webhook_task import model_activity, webhook_activity +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.exception_logger import log_exception +from plane.utils.host import base_host + + +class ProjectViewSet(BaseViewSet): + serializer_class = ProjectListSerializer + model = Project + webhook_event = "project" + + def get_queryset(self): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related( + "workspace", "workspace__owner", "default_assignee", "project_lead" + ) + .annotate( + is_favorite=Exists( + UserFavorite.objects.filter( + user=self.request.user, + entity_identifier=OuterRef("pk"), + entity_type="project", + project_id=OuterRef("pk"), + ) + ) + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + anchor=DeployBoard.objects.filter( + entity_name="project", + entity_identifier=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ).values("anchor") + ) + .annotate(sort_order=Subquery(sort_order)) + .prefetch_related( + Prefetch( + "project_projectmember", + queryset=ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), is_active=True + ).select_related("member"), + to_attr="members_list", + ) + ) + .distinct() + ) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list_detail(self, request, slug): + fields = [field for field in request.GET.get("fields", "").split(",") if field] + projects = self.get_queryset().order_by("sort_order", "name") + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=5 + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=15 + ).exists(): + projects = projects.filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), + request=request, + queryset=(projects), + on_results=lambda projects: ProjectListSerializer( + projects, many=True + ).data, + ) + + projects = ProjectListSerializer( + projects, many=True, fields=fields if fields else None + ).data + return Response(projects, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + + projects = ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .select_related( + "workspace", "workspace__owner", "default_assignee", "project_lead" + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate(inbox_view=F("intake_view")) + .annotate(sort_order=Subquery(sort_order)) + .distinct() + ).values( + "id", + "name", + "identifier", + "sort_order", + "logo_props", + "member_role", + "archived_at", + "workspace", + "cycle_view", + "issue_views_view", + "module_view", + "page_view", + "inbox_view", + "guest_view_all_features", + "project_lead", + "network", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=5 + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=15 + ).exists(): + projects = projects.filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + return Response(projects, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def retrieve(self, request, slug, pk): + project = ( + self.get_queryset() + .filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + .filter(archived_at__isnull=True) + .filter(pk=pk) + ).first() + + if project is None: + return Response( + {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND + ) + + recent_visited_task.delay( + slug=slug, + project_id=pk, + entity_name="project", + entity_identifier=pk, + user_id=request.user.id, + ) + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + serializer = ProjectSerializer( + data={**request.data}, context={"workspace_id": workspace.id} + ) + if serializer.is_valid(): + serializer.save() + + # Add the user as Administrator to the project + _ = ProjectMember.objects.create( + project_id=serializer.data["id"], member=request.user, role=20 + ) + # Also create the issue property for the user + _ = IssueUserProperty.objects.create( + project_id=serializer.data["id"], user=request.user + ) + + if serializer.data["project_lead"] is not None and str( + serializer.data["project_lead"] + ) != str(request.user.id): + ProjectMember.objects.create( + project_id=serializer.data["id"], + member_id=serializer.data["project_lead"], + role=20, + ) + # Also create the issue property for the user + IssueUserProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], + ) + + # Default states + states = [ + { + "name": "Backlog", + "color": "#60646C", + "sequence": 15000, + "group": "backlog", + "default": True, + }, + { + "name": "Todo", + "color": "#60646C", + "sequence": 25000, + "group": "unstarted", + }, + { + "name": "In Progress", + "color": "#F59E0B", + "sequence": 35000, + "group": "started", + }, + { + "name": "Done", + "color": "#46A758", + "sequence": 45000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#9AA4BC", + "sequence": 55000, + "group": "cancelled", + }, + ] + + State.objects.bulk_create( + [ + State( + name=state["name"], + color=state["color"], + project=serializer.instance, + sequence=state["sequence"], + workspace=serializer.instance.workspace, + group=state["group"], + default=state.get("default", False), + created_by=request.user, + ) + for state in states + ] + ) + + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + + # Create the model activity + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def partial_update(self, request, slug, pk=None): + # try: + if not ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=pk, + role=20, + is_active=True, + ).exists(): + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + workspace = Workspace.objects.get(slug=slug) + + project = Project.objects.get(pk=pk) + intake_view = request.data.get("inbox_view", project.intake_view) + current_instance = json.dumps( + ProjectSerializer(project).data, cls=DjangoJSONEncoder + ) + if project.archived_at: + return Response( + {"error": "Archived projects cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProjectSerializer( + project, + data={**request.data, "intake_view": intake_view}, + context={"workspace_id": workspace.id}, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + if intake_view: + intake = Intake.objects.filter(project=project, is_default=True).first() + if not intake: + Intake.objects.create( + name=f"{project.name} Intake", + project=project, + is_default=True, + ) + + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + + model_activity.delay( + model_name="project", + model_id=str(project.id), + requested_data=request.data, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, slug, pk): + if ( + WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=20 + ).exists() + or ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + project_id=pk, + role=20, + is_active=True, + ).exists() + ): + project = Project.objects.get(pk=pk, workspace__slug=slug) + project.delete() + webhook_activity.delay( + event="project", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=request.user.id, + slug=slug, + current_site=base_host(request=request, is_app=True), + event_id=project.id, + old_identifier=None, + new_identifier=None, + ) + # Delete the project members + DeployBoard.objects.filter(project_id=pk, workspace__slug=slug).delete() + + # Delete the user favorite + UserFavorite.objects.filter(project_id=pk, workspace__slug=slug).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + else: + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_403_FORBIDDEN, + ) + + +class ProjectArchiveUnarchiveEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = timezone.now() + project.save() + UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() + return Response( + {"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def delete(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + project.archived_at = None + project.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectIdentifierEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def get(self, request, slug): + name = request.GET.get("name", "").strip().upper() + + if name == "": + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + exists = ProjectIdentifier.objects.filter( + name=name, workspace__slug=slug + ).values("id", "name", "project") + + return Response( + {"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + def delete(self, request, slug): + name = request.data.get("name", "").strip().upper() + + if name == "": + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): + return Response( + {"error": "Cannot delete an identifier of an existing project"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectUserViewsEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + project = Project.objects.get(pk=project_id, workspace__slug=slug) + + project_member = ProjectMember.objects.filter( + member=request.user, project=project, is_active=True + ).first() + + if project_member is None: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + view_props = project_member.view_props + default_props = project_member.default_props + preferences = project_member.preferences + sort_order = project_member.sort_order + + project_member.view_props = request.data.get("view_props", view_props) + project_member.default_props = request.data.get("default_props", default_props) + project_member.preferences = request.data.get("preferences", preferences) + project_member.sort_order = request.data.get("sort_order", sort_order) + + project_member.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectFavoritesViewSet(BaseViewSet): + model = UserFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related( + "project", "project__project_lead", "project__default_assignee" + ) + .select_related("workspace", "workspace__owner") + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, slug): + _ = UserFavorite.objects.create( + user=request.user, + entity_type="project", + entity_identifier=request.data.get("project"), + project_id=request.data.get("project"), + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + def destroy(self, request, slug, project_id): + project_favorite = UserFavorite.objects.get( + entity_identifier=project_id, + entity_type="project", + project=project_id, + user=request.user, + workspace__slug=slug, + ) + project_favorite.delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPublicCoverImagesEndpoint(BaseAPIView): + permission_classes = [AllowAny] + + # Cache the below api for 24 hours + @cache_response(60 * 60 * 24, user=False) + def get(self, request): + files = [] + if settings.USE_MINIO: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + else: + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + params = { + "Bucket": settings.AWS_STORAGE_BUCKET_NAME, + "Prefix": "static/project-cover/", + } + + try: + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) + + return Response(files, status=status.HTTP_200_OK) + except Exception as e: + log_exception(e) + return Response([], status=status.HTTP_200_OK) + + +class DeployBoardViewSet(BaseViewSet): + permission_classes = [ProjectMemberPermission] + serializer_class = DeployBoardSerializer + model = DeployBoard + + def list(self, request, slug, project_id): + project_deploy_board = DeployBoard.objects.filter( + entity_name="project", entity_identifier=project_id, workspace__slug=slug + ).first() + + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) + + def create(self, request, slug, project_id): + comments = request.data.get("is_comments_enabled", False) + reactions = request.data.get("is_reactions_enabled", False) + intake = request.data.get("intake", None) + votes = request.data.get("is_votes_enabled", False) + views = request.data.get( + "views", + { + "list": True, + "kanban": True, + "calendar": True, + "gantt": True, + "spreadsheet": True, + }, + ) + + project_deploy_board, _ = DeployBoard.objects.get_or_create( + entity_name="project", entity_identifier=project_id, project_id=project_id + ) + project_deploy_board.intake = intake + project_deploy_board.view_props = views + project_deploy_board.is_votes_enabled = votes + project_deploy_board.is_comments_enabled = comments + project_deploy_board.is_reactions_enabled = reactions + + project_deploy_board.save() + + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py similarity index 100% rename from apiserver/plane/app/views/project/invite.py rename to apps/api/plane/app/views/project/invite.py diff --git a/apiserver/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py similarity index 100% rename from apiserver/plane/app/views/project/member.py rename to apps/api/plane/app/views/project/member.py diff --git a/apiserver/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py similarity index 100% rename from apiserver/plane/app/views/search/base.py rename to apps/api/plane/app/views/search/base.py diff --git a/apiserver/plane/app/views/search/issue.py b/apps/api/plane/app/views/search/issue.py similarity index 100% rename from apiserver/plane/app/views/search/issue.py rename to apps/api/plane/app/views/search/issue.py diff --git a/apiserver/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py similarity index 100% rename from apiserver/plane/app/views/state/base.py rename to apps/api/plane/app/views/state/base.py diff --git a/apps/api/plane/app/views/timezone/base.py b/apps/api/plane/app/views/timezone/base.py new file mode 100644 index 00000000000..bb3f10c0b0a --- /dev/null +++ b/apps/api/plane/app/views/timezone/base.py @@ -0,0 +1,214 @@ +# Python imports +import pytz +from datetime import datetime + +# Django imports +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + +# Module imports +from plane.authentication.rate_limit import AuthenticationThrottle + + +class TimezoneEndpoint(APIView): + permission_classes = [AllowAny] + + throttle_classes = [AuthenticationThrottle] + + @method_decorator(cache_page(60 * 60 * 2)) + def get(self, request): + timezone_locations = [ + ("Midway Island", "Pacific/Midway"), # UTC-11:00 + ("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00 + ("Hawaii", "Pacific/Honolulu"), # UTC-10:00 + ("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00) + ("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30 + ("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00) + ("Gambier Islands", "Pacific/Gambier"), # UTC-09:00 + ( + "Pacific Time (US and Canada)", + "America/Los_Angeles", + ), # UTC-08:00 (DST: UTC-07:00) + ("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00) + ( + "Mountain Time (US and Canada)", + "America/Denver", + ), # UTC-07:00 (DST: UTC-06:00) + ("Arizona", "America/Phoenix"), # UTC-07:00 + ("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00) + ( + "Central Time (US and Canada)", + "America/Chicago", + ), # UTC-06:00 (DST: UTC-05:00) + ("Saskatchewan", "America/Regina"), # UTC-06:00 + ( + "Guadalajara, Mexico City, Monterrey", + "America/Mexico_City", + ), # UTC-06:00 (DST: UTC-05:00) + ("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00 + ("Costa Rica", "America/Costa_Rica"), # UTC-06:00 + ( + "Eastern Time (US and Canada)", + "America/New_York", + ), # UTC-05:00 (DST: UTC-04:00) + ("Lima", "America/Lima"), # UTC-05:00 + ("Bogota", "America/Bogota"), # UTC-05:00 + ("Quito", "America/Guayaquil"), # UTC-05:00 + ("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00) + ("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30 + ("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00) + ("Caracas", "America/Caracas"), # UTC-04:00 + ("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00) + ("La Paz", "America/La_Paz"), # UTC-04:00 + ("Manaus", "America/Manaus"), # UTC-04:00 + ("Georgetown", "America/Guyana"), # UTC-04:00 + ("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00) + ( + "Newfoundland Time (Canada)", + "America/St_Johns", + ), # UTC-03:30 (DST: UTC-02:30) + ("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00 + ("Brasilia", "America/Sao_Paulo"), # UTC-03:00 + ("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00) + ("Montevideo", "America/Montevideo"), # UTC-03:00 + ("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00 + ( + "South Georgia and the South Sandwich Islands", + "Atlantic/South_Georgia", + ), # UTC-02:00 + ("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00) + ("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00 + ("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00) + ("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00 + ("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00) + ("Monrovia", "Africa/Monrovia"), # UTC+00:00 + ("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00) + ( + "Central European Time (Berlin, Rome, Paris)", + "Europe/Paris", + ), # UTC+01:00 (DST: UTC+02:00) + ("West Central Africa", "Africa/Lagos"), # UTC+01:00 + ("Algiers", "Africa/Algiers"), # UTC+01:00 + ("Lagos", "Africa/Lagos"), # UTC+01:00 + ("Tunis", "Africa/Tunis"), # UTC+01:00 + ( + "Eastern European Time (Cairo, Helsinki, Kyiv)", + "Europe/Kiev", + ), # UTC+02:00 (DST: UTC+03:00) + ("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00) + ("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00) + ("Johannesburg", "Africa/Johannesburg"), # UTC+02:00 + ("Harare, Pretoria", "Africa/Harare"), # UTC+02:00 + ("Moscow Time", "Europe/Moscow"), # UTC+03:00 + ("Baghdad", "Asia/Baghdad"), # UTC+03:00 + ("Nairobi", "Africa/Nairobi"), # UTC+03:00 + ("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00 + ("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30) + ("Abu Dhabi", "Asia/Dubai"), # UTC+04:00 + ("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00) + ("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00) + ("Astrakhan", "Europe/Astrakhan"), # UTC+04:00 + ("Tbilisi", "Asia/Tbilisi"), # UTC+04:00 + ("Mauritius", "Indian/Mauritius"), # UTC+04:00 + ("Kabul", "Asia/Kabul"), # UTC+04:30 + ("Islamabad", "Asia/Karachi"), # UTC+05:00 + ("Karachi", "Asia/Karachi"), # UTC+05:00 + ("Tashkent", "Asia/Tashkent"), # UTC+05:00 + ("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00 + ("Maldives", "Indian/Maldives"), # UTC+05:00 + ("Chagos", "Indian/Chagos"), # UTC+05:00 + ("Chennai", "Asia/Kolkata"), # UTC+05:30 + ("Kolkata", "Asia/Kolkata"), # UTC+05:30 + ("Mumbai", "Asia/Kolkata"), # UTC+05:30 + ("New Delhi", "Asia/Kolkata"), # UTC+05:30 + ("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30 + ("Kathmandu", "Asia/Kathmandu"), # UTC+05:45 + ("Dhaka", "Asia/Dhaka"), # UTC+06:00 + ("Almaty", "Asia/Almaty"), # UTC+06:00 + ("Bishkek", "Asia/Bishkek"), # UTC+06:00 + ("Thimphu", "Asia/Thimphu"), # UTC+06:00 + ("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30 + ("Cocos Islands", "Indian/Cocos"), # UTC+06:30 + ("Bangkok", "Asia/Bangkok"), # UTC+07:00 + ("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00 + ("Jakarta", "Asia/Jakarta"), # UTC+07:00 + ("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00 + ("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00 + ("Beijing", "Asia/Shanghai"), # UTC+08:00 + ("Singapore", "Asia/Singapore"), # UTC+08:00 + ("Perth", "Australia/Perth"), # UTC+08:00 + ("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00 + ("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00 + ("Palau", "Pacific/Palau"), # UTC+08:00 + ("Eucla", "Australia/Eucla"), # UTC+08:45 + ("Tokyo", "Asia/Tokyo"), # UTC+09:00 + ("Seoul", "Asia/Seoul"), # UTC+09:00 + ("Yakutsk", "Asia/Yakutsk"), # UTC+09:00 + ("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30) + ("Darwin", "Australia/Darwin"), # UTC+09:30 + ("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00) + ("Brisbane", "Australia/Brisbane"), # UTC+10:00 + ("Guam", "Pacific/Guam"), # UTC+10:00 + ("Vladivostok", "Asia/Vladivostok"), # UTC+10:00 + ("Tahiti", "Pacific/Tahiti"), # UTC+10:00 + ("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00) + ("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00 + ("Magadan", "Asia/Magadan"), # UTC+11:00 + ("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00 + ("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00 + ("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00 + ("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) + ("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00) + ("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00) + ("Anadyr", "Asia/Anadyr"), # UTC+12:00 + ("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45) + ("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00 + ("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00) + ("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00 + ] + + timezone_list = [] + now = datetime.now() + + # Process timezone mapping + for friendly_name, tz_identifier in timezone_locations: + try: + tz = pytz.timezone(tz_identifier) + current_offset = now.astimezone(tz).strftime("%z") + + # converting and formatting UTC offset to GMT offset + current_utc_offset = now.astimezone(tz).utcoffset() + total_seconds = int(current_utc_offset.total_seconds()) + hours_offset = total_seconds // 3600 + minutes_offset = abs(total_seconds % 3600) // 60 + offset = ( + f"{'+' if hours_offset >= 0 else '-'}" + f"{abs(hours_offset):02}:{minutes_offset:02}" + ) + + timezone_value = { + "offset": int(current_offset), + "utc_offset": f"UTC{offset}", + "gmt_offset": f"GMT{offset}", + "value": tz_identifier, + "label": f"{friendly_name}", + } + + timezone_list.append(timezone_value) + except pytz.exceptions.UnknownTimeZoneError: + continue + + # Sort by offset and then by label + timezone_list.sort(key=lambda x: (x["offset"], x["label"])) + + # Remove offset from final output + for tz in timezone_list: + del tz["offset"] + + return Response({"timezones": timezone_list}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/user/base.py b/apps/api/plane/app/views/user/base.py similarity index 100% rename from apiserver/plane/app/views/user/base.py rename to apps/api/plane/app/views/user/base.py diff --git a/apiserver/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py similarity index 100% rename from apiserver/plane/app/views/view/base.py rename to apps/api/plane/app/views/view/base.py diff --git a/apiserver/plane/app/views/webhook/base.py b/apps/api/plane/app/views/webhook/base.py similarity index 100% rename from apiserver/plane/app/views/webhook/base.py rename to apps/api/plane/app/views/webhook/base.py diff --git a/apiserver/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py similarity index 100% rename from apiserver/plane/app/views/workspace/base.py rename to apps/api/plane/app/views/workspace/base.py diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apps/api/plane/app/views/workspace/cycle.py similarity index 100% rename from apiserver/plane/app/views/workspace/cycle.py rename to apps/api/plane/app/views/workspace/cycle.py diff --git a/apiserver/plane/app/views/workspace/draft.py b/apps/api/plane/app/views/workspace/draft.py similarity index 100% rename from apiserver/plane/app/views/workspace/draft.py rename to apps/api/plane/app/views/workspace/draft.py diff --git a/apiserver/plane/app/views/workspace/estimate.py b/apps/api/plane/app/views/workspace/estimate.py similarity index 100% rename from apiserver/plane/app/views/workspace/estimate.py rename to apps/api/plane/app/views/workspace/estimate.py diff --git a/apiserver/plane/app/views/workspace/favorite.py b/apps/api/plane/app/views/workspace/favorite.py similarity index 100% rename from apiserver/plane/app/views/workspace/favorite.py rename to apps/api/plane/app/views/workspace/favorite.py diff --git a/apiserver/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py similarity index 100% rename from apiserver/plane/app/views/workspace/home.py rename to apps/api/plane/app/views/workspace/home.py diff --git a/apiserver/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py similarity index 99% rename from apiserver/plane/app/views/workspace/invite.py rename to apps/api/plane/app/views/workspace/invite.py index a60dd3fc9e5..84ef7c36198 100644 --- a/apiserver/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -80,7 +80,7 @@ def create(self, request, slug): workspace_id=workspace.id, member__email__in=[email.get("email") for email in emails], is_active=True, - ).select_related("member", "workspace", "workspace__owner") + ).select_related("member", "member__avatar_asset") if workspace_members: return Response( diff --git a/apiserver/plane/app/views/workspace/label.py b/apps/api/plane/app/views/workspace/label.py similarity index 100% rename from apiserver/plane/app/views/workspace/label.py rename to apps/api/plane/app/views/workspace/label.py diff --git a/apiserver/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py similarity index 98% rename from apiserver/plane/app/views/workspace/member.py rename to apps/api/plane/app/views/workspace/member.py index eda7c34fda2..7743ff4cd06 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -33,9 +33,8 @@ def get_queryset(self): return self.filter_queryset( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug"), is_active=True) - .select_related("workspace", "workspace__owner") - .select_related("member") + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("member", "member__avatar_asset") ) @allow_permission( diff --git a/apiserver/plane/app/views/workspace/module.py b/apps/api/plane/app/views/workspace/module.py similarity index 100% rename from apiserver/plane/app/views/workspace/module.py rename to apps/api/plane/app/views/workspace/module.py diff --git a/apiserver/plane/app/views/workspace/quick_link.py b/apps/api/plane/app/views/workspace/quick_link.py similarity index 100% rename from apiserver/plane/app/views/workspace/quick_link.py rename to apps/api/plane/app/views/workspace/quick_link.py diff --git a/apiserver/plane/app/views/workspace/recent_visit.py b/apps/api/plane/app/views/workspace/recent_visit.py similarity index 100% rename from apiserver/plane/app/views/workspace/recent_visit.py rename to apps/api/plane/app/views/workspace/recent_visit.py diff --git a/apiserver/plane/app/views/workspace/state.py b/apps/api/plane/app/views/workspace/state.py similarity index 100% rename from apiserver/plane/app/views/workspace/state.py rename to apps/api/plane/app/views/workspace/state.py diff --git a/apiserver/plane/app/views/workspace/sticky.py b/apps/api/plane/app/views/workspace/sticky.py similarity index 100% rename from apiserver/plane/app/views/workspace/sticky.py rename to apps/api/plane/app/views/workspace/sticky.py diff --git a/apiserver/plane/app/views/workspace/user.py b/apps/api/plane/app/views/workspace/user.py similarity index 100% rename from apiserver/plane/app/views/workspace/user.py rename to apps/api/plane/app/views/workspace/user.py diff --git a/apiserver/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py similarity index 100% rename from apiserver/plane/app/views/workspace/user_preference.py rename to apps/api/plane/app/views/workspace/user_preference.py diff --git a/apiserver/plane/asgi.py b/apps/api/plane/asgi.py similarity index 100% rename from apiserver/plane/asgi.py rename to apps/api/plane/asgi.py diff --git a/apiserver/plane/authentication/__init__.py b/apps/api/plane/authentication/__init__.py similarity index 100% rename from apiserver/plane/authentication/__init__.py rename to apps/api/plane/authentication/__init__.py diff --git a/apiserver/plane/authentication/adapter/__init__.py b/apps/api/plane/authentication/adapter/__init__.py similarity index 100% rename from apiserver/plane/authentication/adapter/__init__.py rename to apps/api/plane/authentication/adapter/__init__.py diff --git a/apiserver/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py similarity index 100% rename from apiserver/plane/authentication/adapter/base.py rename to apps/api/plane/authentication/adapter/base.py diff --git a/apiserver/plane/authentication/adapter/credential.py b/apps/api/plane/authentication/adapter/credential.py similarity index 100% rename from apiserver/plane/authentication/adapter/credential.py rename to apps/api/plane/authentication/adapter/credential.py diff --git a/apiserver/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py similarity index 100% rename from apiserver/plane/authentication/adapter/error.py rename to apps/api/plane/authentication/adapter/error.py diff --git a/apiserver/plane/authentication/adapter/exception.py b/apps/api/plane/authentication/adapter/exception.py similarity index 100% rename from apiserver/plane/authentication/adapter/exception.py rename to apps/api/plane/authentication/adapter/exception.py diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py similarity index 100% rename from apiserver/plane/authentication/adapter/oauth.py rename to apps/api/plane/authentication/adapter/oauth.py diff --git a/apiserver/plane/authentication/apps.py b/apps/api/plane/authentication/apps.py similarity index 100% rename from apiserver/plane/authentication/apps.py rename to apps/api/plane/authentication/apps.py diff --git a/apiserver/plane/authentication/middleware/__init__.py b/apps/api/plane/authentication/middleware/__init__.py similarity index 100% rename from apiserver/plane/authentication/middleware/__init__.py rename to apps/api/plane/authentication/middleware/__init__.py diff --git a/apiserver/plane/authentication/middleware/session.py b/apps/api/plane/authentication/middleware/session.py similarity index 100% rename from apiserver/plane/authentication/middleware/session.py rename to apps/api/plane/authentication/middleware/session.py diff --git a/apiserver/plane/authentication/provider/__init__.py b/apps/api/plane/authentication/provider/__init__.py similarity index 100% rename from apiserver/plane/authentication/provider/__init__.py rename to apps/api/plane/authentication/provider/__init__.py diff --git a/apiserver/plane/authentication/provider/credentials/__init__.py b/apps/api/plane/authentication/provider/credentials/__init__.py similarity index 100% rename from apiserver/plane/authentication/provider/credentials/__init__.py rename to apps/api/plane/authentication/provider/credentials/__init__.py diff --git a/apiserver/plane/authentication/provider/credentials/email.py b/apps/api/plane/authentication/provider/credentials/email.py similarity index 100% rename from apiserver/plane/authentication/provider/credentials/email.py rename to apps/api/plane/authentication/provider/credentials/email.py diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apps/api/plane/authentication/provider/credentials/magic_code.py similarity index 100% rename from apiserver/plane/authentication/provider/credentials/magic_code.py rename to apps/api/plane/authentication/provider/credentials/magic_code.py diff --git a/apiserver/plane/authentication/provider/oauth/__init__.py b/apps/api/plane/authentication/provider/oauth/__init__.py similarity index 100% rename from apiserver/plane/authentication/provider/oauth/__init__.py rename to apps/api/plane/authentication/provider/oauth/__init__.py diff --git a/apiserver/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py similarity index 100% rename from apiserver/plane/authentication/provider/oauth/github.py rename to apps/api/plane/authentication/provider/oauth/github.py diff --git a/apiserver/plane/authentication/provider/oauth/gitlab.py b/apps/api/plane/authentication/provider/oauth/gitlab.py similarity index 100% rename from apiserver/plane/authentication/provider/oauth/gitlab.py rename to apps/api/plane/authentication/provider/oauth/gitlab.py diff --git a/apiserver/plane/authentication/provider/oauth/google.py b/apps/api/plane/authentication/provider/oauth/google.py similarity index 100% rename from apiserver/plane/authentication/provider/oauth/google.py rename to apps/api/plane/authentication/provider/oauth/google.py diff --git a/apiserver/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py similarity index 100% rename from apiserver/plane/authentication/rate_limit.py rename to apps/api/plane/authentication/rate_limit.py diff --git a/apiserver/plane/authentication/session.py b/apps/api/plane/authentication/session.py similarity index 100% rename from apiserver/plane/authentication/session.py rename to apps/api/plane/authentication/session.py diff --git a/apiserver/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py similarity index 100% rename from apiserver/plane/authentication/urls.py rename to apps/api/plane/authentication/urls.py diff --git a/apiserver/plane/authentication/utils/host.py b/apps/api/plane/authentication/utils/host.py similarity index 100% rename from apiserver/plane/authentication/utils/host.py rename to apps/api/plane/authentication/utils/host.py diff --git a/apiserver/plane/authentication/utils/login.py b/apps/api/plane/authentication/utils/login.py similarity index 100% rename from apiserver/plane/authentication/utils/login.py rename to apps/api/plane/authentication/utils/login.py diff --git a/apiserver/plane/authentication/utils/redirection_path.py b/apps/api/plane/authentication/utils/redirection_path.py similarity index 100% rename from apiserver/plane/authentication/utils/redirection_path.py rename to apps/api/plane/authentication/utils/redirection_path.py diff --git a/apiserver/plane/authentication/utils/user_auth_workflow.py b/apps/api/plane/authentication/utils/user_auth_workflow.py similarity index 100% rename from apiserver/plane/authentication/utils/user_auth_workflow.py rename to apps/api/plane/authentication/utils/user_auth_workflow.py diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py similarity index 100% rename from apiserver/plane/authentication/utils/workspace_project_join.py rename to apps/api/plane/authentication/utils/workspace_project_join.py diff --git a/apiserver/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py similarity index 100% rename from apiserver/plane/authentication/views/__init__.py rename to apps/api/plane/authentication/views/__init__.py diff --git a/apiserver/plane/authentication/views/app/check.py b/apps/api/plane/authentication/views/app/check.py similarity index 100% rename from apiserver/plane/authentication/views/app/check.py rename to apps/api/plane/authentication/views/app/check.py diff --git a/apiserver/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py similarity index 100% rename from apiserver/plane/authentication/views/app/email.py rename to apps/api/plane/authentication/views/app/email.py diff --git a/apiserver/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py similarity index 100% rename from apiserver/plane/authentication/views/app/github.py rename to apps/api/plane/authentication/views/app/github.py diff --git a/apiserver/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py similarity index 100% rename from apiserver/plane/authentication/views/app/gitlab.py rename to apps/api/plane/authentication/views/app/gitlab.py diff --git a/apiserver/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py similarity index 100% rename from apiserver/plane/authentication/views/app/google.py rename to apps/api/plane/authentication/views/app/google.py diff --git a/apiserver/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py similarity index 100% rename from apiserver/plane/authentication/views/app/magic.py rename to apps/api/plane/authentication/views/app/magic.py diff --git a/apiserver/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py similarity index 100% rename from apiserver/plane/authentication/views/app/password_management.py rename to apps/api/plane/authentication/views/app/password_management.py diff --git a/apiserver/plane/authentication/views/app/signout.py b/apps/api/plane/authentication/views/app/signout.py similarity index 100% rename from apiserver/plane/authentication/views/app/signout.py rename to apps/api/plane/authentication/views/app/signout.py diff --git a/apiserver/plane/authentication/views/common.py b/apps/api/plane/authentication/views/common.py similarity index 100% rename from apiserver/plane/authentication/views/common.py rename to apps/api/plane/authentication/views/common.py diff --git a/apiserver/plane/authentication/views/space/check.py b/apps/api/plane/authentication/views/space/check.py similarity index 100% rename from apiserver/plane/authentication/views/space/check.py rename to apps/api/plane/authentication/views/space/check.py diff --git a/apiserver/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py similarity index 100% rename from apiserver/plane/authentication/views/space/email.py rename to apps/api/plane/authentication/views/space/email.py diff --git a/apiserver/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py similarity index 100% rename from apiserver/plane/authentication/views/space/github.py rename to apps/api/plane/authentication/views/space/github.py diff --git a/apiserver/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py similarity index 100% rename from apiserver/plane/authentication/views/space/gitlab.py rename to apps/api/plane/authentication/views/space/gitlab.py diff --git a/apiserver/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py similarity index 100% rename from apiserver/plane/authentication/views/space/google.py rename to apps/api/plane/authentication/views/space/google.py diff --git a/apiserver/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py similarity index 100% rename from apiserver/plane/authentication/views/space/magic.py rename to apps/api/plane/authentication/views/space/magic.py diff --git a/apiserver/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py similarity index 100% rename from apiserver/plane/authentication/views/space/password_management.py rename to apps/api/plane/authentication/views/space/password_management.py diff --git a/apiserver/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py similarity index 100% rename from apiserver/plane/authentication/views/space/signout.py rename to apps/api/plane/authentication/views/space/signout.py diff --git a/apiserver/plane/bgtasks/__init__.py b/apps/api/plane/bgtasks/__init__.py similarity index 100% rename from apiserver/plane/bgtasks/__init__.py rename to apps/api/plane/bgtasks/__init__.py diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py similarity index 100% rename from apiserver/plane/bgtasks/analytic_plot_export.py rename to apps/api/plane/bgtasks/analytic_plot_export.py diff --git a/apiserver/plane/bgtasks/api_logs_task.py b/apps/api/plane/bgtasks/api_logs_task.py similarity index 100% rename from apiserver/plane/bgtasks/api_logs_task.py rename to apps/api/plane/bgtasks/api_logs_task.py diff --git a/apiserver/plane/bgtasks/apps.py b/apps/api/plane/bgtasks/apps.py similarity index 100% rename from apiserver/plane/bgtasks/apps.py rename to apps/api/plane/bgtasks/apps.py diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py new file mode 100644 index 00000000000..c8d9fc480ae --- /dev/null +++ b/apps/api/plane/bgtasks/copy_s3_object.py @@ -0,0 +1,165 @@ +# Python imports +import uuid +import base64 +import requests +from bs4 import BeautifulSoup + +# Django imports +from django.conf import settings + +# Module imports +from plane.db.models import FileAsset, Page, Issue +from plane.utils.exception_logger import log_exception +from plane.settings.storage import S3Storage +from celery import shared_task +from plane.utils.url import normalize_url_path + + +def get_entity_id_field(entity_type, entity_id): + entity_mapping = { + FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id}, + FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id}, + FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id}, + FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id}, + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id}, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id}, + FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id}, + FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id}, + FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: { + "draft_issue_id": entity_id + }, + } + return entity_mapping.get(entity_type, {}) + + +def extract_asset_ids(html, tag): + try: + soup = BeautifulSoup(html, "html.parser") + return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")] + except Exception as e: + log_exception(e) + return [] + + +def replace_asset_ids(html, tag, duplicated_assets): + try: + soup = BeautifulSoup(html, "html.parser") + for mention_tag in soup.find_all(tag): + for asset in duplicated_assets: + if mention_tag.get("src") == asset["old_asset_id"]: + mention_tag["src"] = asset["new_asset_id"] + return str(soup) + except Exception as e: + log_exception(e) + return html + + +def update_description(entity, duplicated_assets, tag): + updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets) + entity.description_html = updated_html + entity.save() + return updated_html + + +# Get the description binary and description from the live server +def sync_with_external_service(entity_name, description_html): + try: + data = { + "description_html": description_html, + "variant": "rich" if entity_name == "PAGE" else "document", + } + + live_url = settings.LIVE_URL + if not live_url: + return {} + + url = normalize_url_path(f"{live_url}/convert-document/") + + response = requests.post(url, json=data, headers=None) + if response.status_code == 200: + return response.json() + except requests.RequestException as e: + log_exception(e) + return {} + + +def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id): + duplicated_assets = [] + workspace = entity.workspace + storage = S3Storage() + original_assets = FileAsset.objects.filter( + workspace=workspace, project_id=project_id, id__in=asset_ids + ) + + for original_asset in original_assets: + destination_key = ( + f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" + ) + duplicated_asset = FileAsset.objects.create( + attributes={ + "name": original_asset.attributes.get("name"), + "type": original_asset.attributes.get("type"), + "size": original_asset.attributes.get("size"), + }, + asset=destination_key, + size=original_asset.size, + workspace=workspace, + created_by_id=user_id, + entity_type=original_asset.entity_type, + project_id=project_id, + storage_metadata=original_asset.storage_metadata, + **get_entity_id_field(original_asset.entity_type, entity_identifier), + ) + storage.copy_object(original_asset.asset, destination_key) + duplicated_assets.append( + { + "new_asset_id": str(duplicated_asset.id), + "old_asset_id": str(original_asset.id), + } + ) + if duplicated_assets: + FileAsset.objects.filter( + pk__in=[item["new_asset_id"] for item in duplicated_assets] + ).update(is_uploaded=True) + + return duplicated_assets + + +@shared_task +def copy_s3_objects_of_description_and_assets( + entity_name, entity_identifier, project_id, slug, user_id +): + """ + Step 1: Extract asset ids from the description_html of the entity + Step 2: Duplicate the assets + Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag) + Step 4: Request the live server to generate the description_binary and description for the entity + + """ + try: + model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name) + if not model_class: + raise ValueError(f"Unsupported entity_name: {entity_name}") + + entity = model_class.objects.get(id=entity_identifier) + asset_ids = extract_asset_ids(entity.description_html, "image-component") + + duplicated_assets = copy_assets( + entity, entity_identifier, project_id, asset_ids, user_id + ) + + updated_html = update_description(entity, duplicated_assets, "image-component") + + external_data = sync_with_external_service(entity_name, updated_html) + + if external_data: + entity.description = external_data.get("description") + entity.description_binary = base64.b64decode( + external_data.get("description_binary") + ) + entity.save() + + return + except Exception as e: + log_exception(e) + return [] diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apps/api/plane/bgtasks/deletion_task.py similarity index 100% rename from apiserver/plane/bgtasks/deletion_task.py rename to apps/api/plane/bgtasks/deletion_task.py diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py similarity index 100% rename from apiserver/plane/bgtasks/dummy_data_task.py rename to apps/api/plane/bgtasks/dummy_data_task.py diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py similarity index 98% rename from apiserver/plane/bgtasks/email_notification_task.py rename to apps/api/plane/bgtasks/email_notification_task.py index dcc37796dd3..5a601dcb8a1 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apps/api/plane/bgtasks/email_notification_task.py @@ -21,8 +21,8 @@ def remove_unwanted_characters(input_text): - # Keep only alphanumeric characters, spaces, and dashes. - processed_text = re.sub(r"[^a-zA-Z0-9 \-]", "", input_text) + # Remove only control characters and potentially problematic characters for email subjects + processed_text = re.sub(r"[\x00-\x1F\x7F-\x9F]", "", input_text) return processed_text diff --git a/apiserver/plane/bgtasks/event_tracking_task.py b/apps/api/plane/bgtasks/event_tracking_task.py similarity index 100% rename from apiserver/plane/bgtasks/event_tracking_task.py rename to apps/api/plane/bgtasks/event_tracking_task.py diff --git a/apiserver/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py similarity index 100% rename from apiserver/plane/bgtasks/export_task.py rename to apps/api/plane/bgtasks/export_task.py diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apps/api/plane/bgtasks/exporter_expired_task.py similarity index 100% rename from apiserver/plane/bgtasks/exporter_expired_task.py rename to apps/api/plane/bgtasks/exporter_expired_task.py diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apps/api/plane/bgtasks/file_asset_task.py similarity index 100% rename from apiserver/plane/bgtasks/file_asset_task.py rename to apps/api/plane/bgtasks/file_asset_task.py diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apps/api/plane/bgtasks/forgot_password_task.py similarity index 100% rename from apiserver/plane/bgtasks/forgot_password_task.py rename to apps/api/plane/bgtasks/forgot_password_task.py diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py similarity index 100% rename from apiserver/plane/bgtasks/issue_activities_task.py rename to apps/api/plane/bgtasks/issue_activities_task.py diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apps/api/plane/bgtasks/issue_automation_task.py similarity index 100% rename from apiserver/plane/bgtasks/issue_automation_task.py rename to apps/api/plane/bgtasks/issue_automation_task.py diff --git a/apiserver/plane/bgtasks/issue_description_version_sync.py b/apps/api/plane/bgtasks/issue_description_version_sync.py similarity index 100% rename from apiserver/plane/bgtasks/issue_description_version_sync.py rename to apps/api/plane/bgtasks/issue_description_version_sync.py diff --git a/apiserver/plane/bgtasks/issue_description_version_task.py b/apps/api/plane/bgtasks/issue_description_version_task.py similarity index 100% rename from apiserver/plane/bgtasks/issue_description_version_task.py rename to apps/api/plane/bgtasks/issue_description_version_task.py diff --git a/apiserver/plane/bgtasks/issue_version_sync.py b/apps/api/plane/bgtasks/issue_version_sync.py similarity index 100% rename from apiserver/plane/bgtasks/issue_version_sync.py rename to apps/api/plane/bgtasks/issue_version_sync.py diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apps/api/plane/bgtasks/magic_link_code_task.py similarity index 100% rename from apiserver/plane/bgtasks/magic_link_code_task.py rename to apps/api/plane/bgtasks/magic_link_code_task.py diff --git a/apiserver/plane/bgtasks/notification_task.py b/apps/api/plane/bgtasks/notification_task.py similarity index 100% rename from apiserver/plane/bgtasks/notification_task.py rename to apps/api/plane/bgtasks/notification_task.py diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apps/api/plane/bgtasks/page_transaction_task.py similarity index 100% rename from apiserver/plane/bgtasks/page_transaction_task.py rename to apps/api/plane/bgtasks/page_transaction_task.py diff --git a/apiserver/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py similarity index 100% rename from apiserver/plane/bgtasks/page_version_task.py rename to apps/api/plane/bgtasks/page_version_task.py diff --git a/apiserver/plane/bgtasks/project_add_user_email_task.py b/apps/api/plane/bgtasks/project_add_user_email_task.py similarity index 100% rename from apiserver/plane/bgtasks/project_add_user_email_task.py rename to apps/api/plane/bgtasks/project_add_user_email_task.py diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apps/api/plane/bgtasks/project_invitation_task.py similarity index 100% rename from apiserver/plane/bgtasks/project_invitation_task.py rename to apps/api/plane/bgtasks/project_invitation_task.py diff --git a/apiserver/plane/bgtasks/recent_visited_task.py b/apps/api/plane/bgtasks/recent_visited_task.py similarity index 100% rename from apiserver/plane/bgtasks/recent_visited_task.py rename to apps/api/plane/bgtasks/recent_visited_task.py diff --git a/apiserver/plane/bgtasks/storage_metadata_task.py b/apps/api/plane/bgtasks/storage_metadata_task.py similarity index 100% rename from apiserver/plane/bgtasks/storage_metadata_task.py rename to apps/api/plane/bgtasks/storage_metadata_task.py diff --git a/apiserver/plane/bgtasks/user_activation_email_task.py b/apps/api/plane/bgtasks/user_activation_email_task.py similarity index 100% rename from apiserver/plane/bgtasks/user_activation_email_task.py rename to apps/api/plane/bgtasks/user_activation_email_task.py diff --git a/apiserver/plane/bgtasks/user_deactivation_email_task.py b/apps/api/plane/bgtasks/user_deactivation_email_task.py similarity index 100% rename from apiserver/plane/bgtasks/user_deactivation_email_task.py rename to apps/api/plane/bgtasks/user_deactivation_email_task.py diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py similarity index 93% rename from apiserver/plane/bgtasks/webhook_task.py rename to apps/api/plane/bgtasks/webhook_task.py index 0bcfd2693a2..ae7c30ac924 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -12,6 +12,7 @@ # Django imports from django.conf import settings +from django.db.models import Prefetch from django.core.mail import EmailMultiAlternatives, get_connection from django.core.serializers.json import DjangoJSONEncoder from django.template.loader import render_to_string @@ -42,6 +43,8 @@ Webhook, WebhookLog, IntakeIssue, + IssueLabel, + IssueAssignee, ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception @@ -74,6 +77,15 @@ logger = logging.getLogger("plane.worker") +def get_issue_prefetches(): + return [ + Prefetch("label_issue", queryset=IssueLabel.objects.select_related("label")), + Prefetch( + "issue_assignee", queryset=IssueAssignee.objects.select_related("assignee") + ), + ] + + def get_model_data( event: str, event_id: Union[str, List[str]], many: bool = False ) -> Dict[str, Any]: @@ -103,10 +115,27 @@ def get_model_data( queryset = model.objects.get(pk=event_id) serializer = SERIALIZER_MAPPER.get(event) + if serializer is None: raise ValueError(f"Serializer not found for event: {event}") - return serializer(queryset, many=many).data + issue_prefetches = get_issue_prefetches() + if event == "issue": + if many: + queryset = queryset.prefetch_related(*issue_prefetches) + else: + issue_id = queryset.id + queryset = ( + model.objects.filter(pk=issue_id) + .prefetch_related(*issue_prefetches) + .first() + ) + + return serializer( + queryset, many=many, context={"expand": ["labels", "assignees"]} + ).data + else: + return serializer(queryset, many=many).data except ObjectDoesNotExist: raise ObjectDoesNotExist(f"No {event} found with id: {event_id}") diff --git a/apiserver/plane/bgtasks/work_item_link_task.py b/apps/api/plane/bgtasks/work_item_link_task.py similarity index 99% rename from apiserver/plane/bgtasks/work_item_link_task.py rename to apps/api/plane/bgtasks/work_item_link_task.py index 1ba48caf918..721231be1ef 100644 --- a/apiserver/plane/bgtasks/work_item_link_task.py +++ b/apps/api/plane/bgtasks/work_item_link_task.py @@ -19,6 +19,7 @@ DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501 + def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]: """ Crawls a URL to extract the title and favicon. @@ -118,7 +119,7 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s if response.status_code == 200: return fallback_url except requests.RequestException as e: - log_exception(e) + log_exception(e, warning=True) return None return None diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py similarity index 100% rename from apiserver/plane/bgtasks/workspace_invitation_task.py rename to apps/api/plane/bgtasks/workspace_invitation_task.py diff --git a/apiserver/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py similarity index 100% rename from apiserver/plane/bgtasks/workspace_seed_task.py rename to apps/api/plane/bgtasks/workspace_seed_task.py diff --git a/apiserver/plane/celery.py b/apps/api/plane/celery.py similarity index 100% rename from apiserver/plane/celery.py rename to apps/api/plane/celery.py diff --git a/apiserver/plane/db/__init__.py b/apps/api/plane/db/__init__.py similarity index 100% rename from apiserver/plane/db/__init__.py rename to apps/api/plane/db/__init__.py diff --git a/apiserver/plane/db/apps.py b/apps/api/plane/db/apps.py similarity index 100% rename from apiserver/plane/db/apps.py rename to apps/api/plane/db/apps.py diff --git a/apiserver/plane/db/management/__init__.py b/apps/api/plane/db/management/__init__.py similarity index 100% rename from apiserver/plane/db/management/__init__.py rename to apps/api/plane/db/management/__init__.py diff --git a/apiserver/plane/db/management/commands/__init__.py b/apps/api/plane/db/management/commands/__init__.py similarity index 100% rename from apiserver/plane/db/management/commands/__init__.py rename to apps/api/plane/db/management/commands/__init__.py diff --git a/apiserver/plane/db/management/commands/activate_user.py b/apps/api/plane/db/management/commands/activate_user.py similarity index 100% rename from apiserver/plane/db/management/commands/activate_user.py rename to apps/api/plane/db/management/commands/activate_user.py diff --git a/apiserver/plane/db/management/commands/clear_cache.py b/apps/api/plane/db/management/commands/clear_cache.py similarity index 100% rename from apiserver/plane/db/management/commands/clear_cache.py rename to apps/api/plane/db/management/commands/clear_cache.py diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apps/api/plane/db/management/commands/create_bucket.py similarity index 100% rename from apiserver/plane/db/management/commands/create_bucket.py rename to apps/api/plane/db/management/commands/create_bucket.py diff --git a/apiserver/plane/db/management/commands/create_dummy_data.py b/apps/api/plane/db/management/commands/create_dummy_data.py similarity index 99% rename from apiserver/plane/db/management/commands/create_dummy_data.py rename to apps/api/plane/db/management/commands/create_dummy_data.py index 3eedc390ce4..0915cd9d81d 100644 --- a/apiserver/plane/db/management/commands/create_dummy_data.py +++ b/apps/api/plane/db/management/commands/create_dummy_data.py @@ -50,7 +50,7 @@ def handle(self, *args: Any, **options: Any) -> str | None: project_count = int(input("Number of projects to be created: ")) for i in range(project_count): - print(f"Please provide the following details for project {i+1}:") + print(f"Please provide the following details for project {i + 1}:") issue_count = int(input("Number of issues to be created: ")) cycle_count = int(input("Number of cycles to be created: ")) module_count = int(input("Number of modules to be created: ")) diff --git a/apiserver/plane/db/management/commands/create_instance_admin.py b/apps/api/plane/db/management/commands/create_instance_admin.py similarity index 100% rename from apiserver/plane/db/management/commands/create_instance_admin.py rename to apps/api/plane/db/management/commands/create_instance_admin.py diff --git a/apiserver/plane/db/management/commands/create_project_member.py b/apps/api/plane/db/management/commands/create_project_member.py similarity index 100% rename from apiserver/plane/db/management/commands/create_project_member.py rename to apps/api/plane/db/management/commands/create_project_member.py diff --git a/apiserver/plane/db/management/commands/fix_duplicate_sequences.py b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py similarity index 100% rename from apiserver/plane/db/management/commands/fix_duplicate_sequences.py rename to apps/api/plane/db/management/commands/fix_duplicate_sequences.py diff --git a/apiserver/plane/db/management/commands/reset_password.py b/apps/api/plane/db/management/commands/reset_password.py similarity index 100% rename from apiserver/plane/db/management/commands/reset_password.py rename to apps/api/plane/db/management/commands/reset_password.py diff --git a/apiserver/plane/db/management/commands/sync_issue_description_version.py b/apps/api/plane/db/management/commands/sync_issue_description_version.py similarity index 100% rename from apiserver/plane/db/management/commands/sync_issue_description_version.py rename to apps/api/plane/db/management/commands/sync_issue_description_version.py diff --git a/apiserver/plane/db/management/commands/sync_issue_version.py b/apps/api/plane/db/management/commands/sync_issue_version.py similarity index 100% rename from apiserver/plane/db/management/commands/sync_issue_version.py rename to apps/api/plane/db/management/commands/sync_issue_version.py diff --git a/apiserver/plane/db/management/commands/test_email.py b/apps/api/plane/db/management/commands/test_email.py similarity index 100% rename from apiserver/plane/db/management/commands/test_email.py rename to apps/api/plane/db/management/commands/test_email.py diff --git a/apiserver/plane/db/management/commands/update_bucket.py b/apps/api/plane/db/management/commands/update_bucket.py similarity index 100% rename from apiserver/plane/db/management/commands/update_bucket.py rename to apps/api/plane/db/management/commands/update_bucket.py diff --git a/apiserver/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py similarity index 100% rename from apiserver/plane/db/management/commands/update_deleted_workspace_slug.py rename to apps/api/plane/db/management/commands/update_deleted_workspace_slug.py diff --git a/apiserver/plane/db/management/commands/wait_for_db.py b/apps/api/plane/db/management/commands/wait_for_db.py similarity index 100% rename from apiserver/plane/db/management/commands/wait_for_db.py rename to apps/api/plane/db/management/commands/wait_for_db.py diff --git a/apiserver/plane/db/management/commands/wait_for_migrations.py b/apps/api/plane/db/management/commands/wait_for_migrations.py similarity index 100% rename from apiserver/plane/db/management/commands/wait_for_migrations.py rename to apps/api/plane/db/management/commands/wait_for_migrations.py diff --git a/apiserver/plane/db/migrations/0001_initial.py b/apps/api/plane/db/migrations/0001_initial.py similarity index 100% rename from apiserver/plane/db/migrations/0001_initial.py rename to apps/api/plane/db/migrations/0001_initial.py diff --git a/apiserver/plane/db/migrations/0002_auto_20221104_2239.py b/apps/api/plane/db/migrations/0002_auto_20221104_2239.py similarity index 100% rename from apiserver/plane/db/migrations/0002_auto_20221104_2239.py rename to apps/api/plane/db/migrations/0002_auto_20221104_2239.py diff --git a/apiserver/plane/db/migrations/0003_auto_20221109_2320.py b/apps/api/plane/db/migrations/0003_auto_20221109_2320.py similarity index 100% rename from apiserver/plane/db/migrations/0003_auto_20221109_2320.py rename to apps/api/plane/db/migrations/0003_auto_20221109_2320.py diff --git a/apiserver/plane/db/migrations/0004_alter_state_sequence.py b/apps/api/plane/db/migrations/0004_alter_state_sequence.py similarity index 100% rename from apiserver/plane/db/migrations/0004_alter_state_sequence.py rename to apps/api/plane/db/migrations/0004_alter_state_sequence.py diff --git a/apiserver/plane/db/migrations/0005_auto_20221114_2127.py b/apps/api/plane/db/migrations/0005_auto_20221114_2127.py similarity index 100% rename from apiserver/plane/db/migrations/0005_auto_20221114_2127.py rename to apps/api/plane/db/migrations/0005_auto_20221114_2127.py diff --git a/apiserver/plane/db/migrations/0006_alter_cycle_status.py b/apps/api/plane/db/migrations/0006_alter_cycle_status.py similarity index 100% rename from apiserver/plane/db/migrations/0006_alter_cycle_status.py rename to apps/api/plane/db/migrations/0006_alter_cycle_status.py diff --git a/apiserver/plane/db/migrations/0007_label_parent.py b/apps/api/plane/db/migrations/0007_label_parent.py similarity index 100% rename from apiserver/plane/db/migrations/0007_label_parent.py rename to apps/api/plane/db/migrations/0007_label_parent.py diff --git a/apiserver/plane/db/migrations/0008_label_colour.py b/apps/api/plane/db/migrations/0008_label_colour.py similarity index 100% rename from apiserver/plane/db/migrations/0008_label_colour.py rename to apps/api/plane/db/migrations/0008_label_colour.py diff --git a/apiserver/plane/db/migrations/0009_auto_20221208_0310.py b/apps/api/plane/db/migrations/0009_auto_20221208_0310.py similarity index 100% rename from apiserver/plane/db/migrations/0009_auto_20221208_0310.py rename to apps/api/plane/db/migrations/0009_auto_20221208_0310.py diff --git a/apiserver/plane/db/migrations/0010_auto_20221213_0037.py b/apps/api/plane/db/migrations/0010_auto_20221213_0037.py similarity index 100% rename from apiserver/plane/db/migrations/0010_auto_20221213_0037.py rename to apps/api/plane/db/migrations/0010_auto_20221213_0037.py diff --git a/apiserver/plane/db/migrations/0011_auto_20221222_2357.py b/apps/api/plane/db/migrations/0011_auto_20221222_2357.py similarity index 100% rename from apiserver/plane/db/migrations/0011_auto_20221222_2357.py rename to apps/api/plane/db/migrations/0011_auto_20221222_2357.py diff --git a/apiserver/plane/db/migrations/0012_auto_20230104_0117.py b/apps/api/plane/db/migrations/0012_auto_20230104_0117.py similarity index 100% rename from apiserver/plane/db/migrations/0012_auto_20230104_0117.py rename to apps/api/plane/db/migrations/0012_auto_20230104_0117.py diff --git a/apiserver/plane/db/migrations/0013_auto_20230107_0041.py b/apps/api/plane/db/migrations/0013_auto_20230107_0041.py similarity index 100% rename from apiserver/plane/db/migrations/0013_auto_20230107_0041.py rename to apps/api/plane/db/migrations/0013_auto_20230107_0041.py diff --git a/apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py b/apps/api/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py similarity index 100% rename from apiserver/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py rename to apps/api/plane/db/migrations/0014_alter_workspacememberinvite_unique_together.py diff --git a/apiserver/plane/db/migrations/0015_auto_20230107_1636.py b/apps/api/plane/db/migrations/0015_auto_20230107_1636.py similarity index 100% rename from apiserver/plane/db/migrations/0015_auto_20230107_1636.py rename to apps/api/plane/db/migrations/0015_auto_20230107_1636.py diff --git a/apiserver/plane/db/migrations/0016_auto_20230107_1735.py b/apps/api/plane/db/migrations/0016_auto_20230107_1735.py similarity index 100% rename from apiserver/plane/db/migrations/0016_auto_20230107_1735.py rename to apps/api/plane/db/migrations/0016_auto_20230107_1735.py diff --git a/apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py b/apps/api/plane/db/migrations/0017_alter_workspace_unique_together.py similarity index 100% rename from apiserver/plane/db/migrations/0017_alter_workspace_unique_together.py rename to apps/api/plane/db/migrations/0017_alter_workspace_unique_together.py diff --git a/apiserver/plane/db/migrations/0018_auto_20230130_0119.py b/apps/api/plane/db/migrations/0018_auto_20230130_0119.py similarity index 100% rename from apiserver/plane/db/migrations/0018_auto_20230130_0119.py rename to apps/api/plane/db/migrations/0018_auto_20230130_0119.py diff --git a/apiserver/plane/db/migrations/0019_auto_20230131_0049.py b/apps/api/plane/db/migrations/0019_auto_20230131_0049.py similarity index 100% rename from apiserver/plane/db/migrations/0019_auto_20230131_0049.py rename to apps/api/plane/db/migrations/0019_auto_20230131_0049.py diff --git a/apiserver/plane/db/migrations/0020_auto_20230214_0118.py b/apps/api/plane/db/migrations/0020_auto_20230214_0118.py similarity index 100% rename from apiserver/plane/db/migrations/0020_auto_20230214_0118.py rename to apps/api/plane/db/migrations/0020_auto_20230214_0118.py diff --git a/apiserver/plane/db/migrations/0021_auto_20230223_0104.py b/apps/api/plane/db/migrations/0021_auto_20230223_0104.py similarity index 100% rename from apiserver/plane/db/migrations/0021_auto_20230223_0104.py rename to apps/api/plane/db/migrations/0021_auto_20230223_0104.py diff --git a/apiserver/plane/db/migrations/0022_auto_20230307_0304.py b/apps/api/plane/db/migrations/0022_auto_20230307_0304.py similarity index 100% rename from apiserver/plane/db/migrations/0022_auto_20230307_0304.py rename to apps/api/plane/db/migrations/0022_auto_20230307_0304.py diff --git a/apiserver/plane/db/migrations/0023_auto_20230316_0040.py b/apps/api/plane/db/migrations/0023_auto_20230316_0040.py similarity index 100% rename from apiserver/plane/db/migrations/0023_auto_20230316_0040.py rename to apps/api/plane/db/migrations/0023_auto_20230316_0040.py diff --git a/apiserver/plane/db/migrations/0024_auto_20230322_0138.py b/apps/api/plane/db/migrations/0024_auto_20230322_0138.py similarity index 100% rename from apiserver/plane/db/migrations/0024_auto_20230322_0138.py rename to apps/api/plane/db/migrations/0024_auto_20230322_0138.py diff --git a/apiserver/plane/db/migrations/0025_auto_20230331_0203.py b/apps/api/plane/db/migrations/0025_auto_20230331_0203.py similarity index 100% rename from apiserver/plane/db/migrations/0025_auto_20230331_0203.py rename to apps/api/plane/db/migrations/0025_auto_20230331_0203.py diff --git a/apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py b/apps/api/plane/db/migrations/0026_alter_projectmember_view_props.py similarity index 100% rename from apiserver/plane/db/migrations/0026_alter_projectmember_view_props.py rename to apps/api/plane/db/migrations/0026_alter_projectmember_view_props.py diff --git a/apiserver/plane/db/migrations/0027_auto_20230409_0312.py b/apps/api/plane/db/migrations/0027_auto_20230409_0312.py similarity index 100% rename from apiserver/plane/db/migrations/0027_auto_20230409_0312.py rename to apps/api/plane/db/migrations/0027_auto_20230409_0312.py diff --git a/apiserver/plane/db/migrations/0028_auto_20230414_1703.py b/apps/api/plane/db/migrations/0028_auto_20230414_1703.py similarity index 100% rename from apiserver/plane/db/migrations/0028_auto_20230414_1703.py rename to apps/api/plane/db/migrations/0028_auto_20230414_1703.py diff --git a/apiserver/plane/db/migrations/0029_auto_20230502_0126.py b/apps/api/plane/db/migrations/0029_auto_20230502_0126.py similarity index 100% rename from apiserver/plane/db/migrations/0029_auto_20230502_0126.py rename to apps/api/plane/db/migrations/0029_auto_20230502_0126.py diff --git a/apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py b/apps/api/plane/db/migrations/0030_alter_estimatepoint_unique_together.py similarity index 100% rename from apiserver/plane/db/migrations/0030_alter_estimatepoint_unique_together.py rename to apps/api/plane/db/migrations/0030_alter_estimatepoint_unique_together.py diff --git a/apiserver/plane/db/migrations/0031_analyticview.py b/apps/api/plane/db/migrations/0031_analyticview.py similarity index 100% rename from apiserver/plane/db/migrations/0031_analyticview.py rename to apps/api/plane/db/migrations/0031_analyticview.py diff --git a/apiserver/plane/db/migrations/0032_auto_20230520_2015.py b/apps/api/plane/db/migrations/0032_auto_20230520_2015.py similarity index 100% rename from apiserver/plane/db/migrations/0032_auto_20230520_2015.py rename to apps/api/plane/db/migrations/0032_auto_20230520_2015.py diff --git a/apiserver/plane/db/migrations/0033_auto_20230618_2125.py b/apps/api/plane/db/migrations/0033_auto_20230618_2125.py similarity index 100% rename from apiserver/plane/db/migrations/0033_auto_20230618_2125.py rename to apps/api/plane/db/migrations/0033_auto_20230618_2125.py diff --git a/apiserver/plane/db/migrations/0034_auto_20230628_1046.py b/apps/api/plane/db/migrations/0034_auto_20230628_1046.py similarity index 100% rename from apiserver/plane/db/migrations/0034_auto_20230628_1046.py rename to apps/api/plane/db/migrations/0034_auto_20230628_1046.py diff --git a/apiserver/plane/db/migrations/0035_auto_20230704_2225.py b/apps/api/plane/db/migrations/0035_auto_20230704_2225.py similarity index 100% rename from apiserver/plane/db/migrations/0035_auto_20230704_2225.py rename to apps/api/plane/db/migrations/0035_auto_20230704_2225.py diff --git a/apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py b/apps/api/plane/db/migrations/0036_alter_workspace_organization_size.py similarity index 100% rename from apiserver/plane/db/migrations/0036_alter_workspace_organization_size.py rename to apps/api/plane/db/migrations/0036_alter_workspace_organization_size.py diff --git a/apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py b/apps/api/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py rename to apps/api/plane/db/migrations/0037_issue_archived_at_project_archive_in_and_more.py diff --git a/apiserver/plane/db/migrations/0038_auto_20230720_1505.py b/apps/api/plane/db/migrations/0038_auto_20230720_1505.py similarity index 100% rename from apiserver/plane/db/migrations/0038_auto_20230720_1505.py rename to apps/api/plane/db/migrations/0038_auto_20230720_1505.py diff --git a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py b/apps/api/plane/db/migrations/0039_auto_20230723_2203.py similarity index 100% rename from apiserver/plane/db/migrations/0039_auto_20230723_2203.py rename to apps/api/plane/db/migrations/0039_auto_20230723_2203.py diff --git a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py b/apps/api/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py rename to apps/api/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py diff --git a/apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py b/apps/api/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py rename to apps/api/plane/db/migrations/0041_cycle_sort_order_issuecomment_access_and_more.py diff --git a/apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py b/apps/api/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py rename to apps/api/plane/db/migrations/0042_alter_analyticview_created_by_and_more.py diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apps/api/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py rename to apps/api/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py diff --git a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apps/api/plane/db/migrations/0044_auto_20230913_0709.py similarity index 100% rename from apiserver/plane/db/migrations/0044_auto_20230913_0709.py rename to apps/api/plane/db/migrations/0044_auto_20230913_0709.py diff --git a/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py b/apps/api/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py rename to apps/api/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py diff --git a/apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py b/apps/api/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py rename to apps/api/plane/db/migrations/0046_label_sort_order_alter_analyticview_created_by_and_more.py diff --git a/apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py b/apps/api/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py rename to apps/api/plane/db/migrations/0047_webhook_apitoken_description_apitoken_expired_at_and_more.py diff --git a/apiserver/plane/db/migrations/0048_auto_20231116_0713.py b/apps/api/plane/db/migrations/0048_auto_20231116_0713.py similarity index 100% rename from apiserver/plane/db/migrations/0048_auto_20231116_0713.py rename to apps/api/plane/db/migrations/0048_auto_20231116_0713.py diff --git a/apiserver/plane/db/migrations/0049_auto_20231116_0713.py b/apps/api/plane/db/migrations/0049_auto_20231116_0713.py similarity index 100% rename from apiserver/plane/db/migrations/0049_auto_20231116_0713.py rename to apps/api/plane/db/migrations/0049_auto_20231116_0713.py diff --git a/apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py b/apps/api/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py similarity index 100% rename from apiserver/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py rename to apps/api/plane/db/migrations/0050_user_use_case_alter_workspace_organization_size.py diff --git a/apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py b/apps/api/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py rename to apps/api/plane/db/migrations/0051_cycle_external_id_cycle_external_source_and_more.py diff --git a/apiserver/plane/db/migrations/0052_auto_20231220_1141.py b/apps/api/plane/db/migrations/0052_auto_20231220_1141.py similarity index 100% rename from apiserver/plane/db/migrations/0052_auto_20231220_1141.py rename to apps/api/plane/db/migrations/0052_auto_20231220_1141.py diff --git a/apiserver/plane/db/migrations/0053_auto_20240102_1315.py b/apps/api/plane/db/migrations/0053_auto_20240102_1315.py similarity index 100% rename from apiserver/plane/db/migrations/0053_auto_20240102_1315.py rename to apps/api/plane/db/migrations/0053_auto_20240102_1315.py diff --git a/apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py b/apps/api/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py similarity index 100% rename from apiserver/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py rename to apps/api/plane/db/migrations/0054_dashboard_widget_dashboardwidget.py diff --git a/apiserver/plane/db/migrations/0055_auto_20240108_0648.py b/apps/api/plane/db/migrations/0055_auto_20240108_0648.py similarity index 100% rename from apiserver/plane/db/migrations/0055_auto_20240108_0648.py rename to apps/api/plane/db/migrations/0055_auto_20240108_0648.py diff --git a/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py b/apps/api/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py similarity index 100% rename from apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py rename to apps/api/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py diff --git a/apiserver/plane/db/migrations/0057_auto_20240122_0901.py b/apps/api/plane/db/migrations/0057_auto_20240122_0901.py similarity index 100% rename from apiserver/plane/db/migrations/0057_auto_20240122_0901.py rename to apps/api/plane/db/migrations/0057_auto_20240122_0901.py diff --git a/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py b/apps/api/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py rename to apps/api/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py diff --git a/apiserver/plane/db/migrations/0059_auto_20240208_0957.py b/apps/api/plane/db/migrations/0059_auto_20240208_0957.py similarity index 100% rename from apiserver/plane/db/migrations/0059_auto_20240208_0957.py rename to apps/api/plane/db/migrations/0059_auto_20240208_0957.py diff --git a/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py b/apps/api/plane/db/migrations/0060_cycle_progress_snapshot.py similarity index 100% rename from apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py rename to apps/api/plane/db/migrations/0060_cycle_progress_snapshot.py diff --git a/apiserver/plane/db/migrations/0061_project_logo_props.py b/apps/api/plane/db/migrations/0061_project_logo_props.py similarity index 100% rename from apiserver/plane/db/migrations/0061_project_logo_props.py rename to apps/api/plane/db/migrations/0061_project_logo_props.py diff --git a/apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py b/apps/api/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py rename to apps/api/plane/db/migrations/0062_cycle_archived_at_module_archived_at_and_more.py diff --git a/apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py b/apps/api/plane/db/migrations/0063_state_is_triage_alter_state_group.py similarity index 100% rename from apiserver/plane/db/migrations/0063_state_is_triage_alter_state_group.py rename to apps/api/plane/db/migrations/0063_state_is_triage_alter_state_group.py diff --git a/apiserver/plane/db/migrations/0064_auto_20240409_1134.py b/apps/api/plane/db/migrations/0064_auto_20240409_1134.py similarity index 100% rename from apiserver/plane/db/migrations/0064_auto_20240409_1134.py rename to apps/api/plane/db/migrations/0064_auto_20240409_1134.py diff --git a/apiserver/plane/db/migrations/0065_auto_20240415_0937.py b/apps/api/plane/db/migrations/0065_auto_20240415_0937.py similarity index 100% rename from apiserver/plane/db/migrations/0065_auto_20240415_0937.py rename to apps/api/plane/db/migrations/0065_auto_20240415_0937.py diff --git a/apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py b/apps/api/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py similarity index 100% rename from apiserver/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py rename to apps/api/plane/db/migrations/0066_account_id_token_cycle_logo_props_module_logo_props.py diff --git a/apiserver/plane/db/migrations/0067_issue_estimate.py b/apps/api/plane/db/migrations/0067_issue_estimate.py similarity index 100% rename from apiserver/plane/db/migrations/0067_issue_estimate.py rename to apps/api/plane/db/migrations/0067_issue_estimate.py diff --git a/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py b/apps/api/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py rename to apps/api/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py diff --git a/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py b/apps/api/plane/db/migrations/0069_alter_account_provider_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py rename to apps/api/plane/db/migrations/0069_alter_account_provider_and_more.py diff --git a/apiserver/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py b/apps/api/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py rename to apps/api/plane/db/migrations/0070_apitoken_is_service_exporterhistory_filters_and_more.py diff --git a/apiserver/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py b/apps/api/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py rename to apps/api/plane/db/migrations/0071_rename_issueproperty_issueuserproperty_and_more.py diff --git a/apiserver/plane/db/migrations/0072_issueattachment_external_id_and_more.py b/apps/api/plane/db/migrations/0072_issueattachment_external_id_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0072_issueattachment_external_id_and_more.py rename to apps/api/plane/db/migrations/0072_issueattachment_external_id_and_more.py diff --git a/apiserver/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py b/apps/api/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py rename to apps/api/plane/db/migrations/0073_alter_commentreaction_unique_together_and_more.py diff --git a/apiserver/plane/db/migrations/0074_deploy_board_and_project_issues.py b/apps/api/plane/db/migrations/0074_deploy_board_and_project_issues.py similarity index 100% rename from apiserver/plane/db/migrations/0074_deploy_board_and_project_issues.py rename to apps/api/plane/db/migrations/0074_deploy_board_and_project_issues.py diff --git a/apiserver/plane/db/migrations/0075_alter_fileasset_asset.py b/apps/api/plane/db/migrations/0075_alter_fileasset_asset.py similarity index 100% rename from apiserver/plane/db/migrations/0075_alter_fileasset_asset.py rename to apps/api/plane/db/migrations/0075_alter_fileasset_asset.py diff --git a/apiserver/plane/db/migrations/0076_alter_projectmember_role_and_more.py b/apps/api/plane/db/migrations/0076_alter_projectmember_role_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0076_alter_projectmember_role_and_more.py rename to apps/api/plane/db/migrations/0076_alter_projectmember_role_and_more.py diff --git a/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py b/apps/api/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py rename to apps/api/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py diff --git a/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py b/apps/api/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py rename to apps/api/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py diff --git a/apiserver/plane/db/migrations/0079_auto_20241009_0619.py b/apps/api/plane/db/migrations/0079_auto_20241009_0619.py similarity index 100% rename from apiserver/plane/db/migrations/0079_auto_20241009_0619.py rename to apps/api/plane/db/migrations/0079_auto_20241009_0619.py diff --git a/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py b/apps/api/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py similarity index 100% rename from apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py rename to apps/api/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py diff --git a/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py b/apps/api/plane/db/migrations/0081_remove_globalview_created_by_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py rename to apps/api/plane/db/migrations/0081_remove_globalview_created_by_and_more.py diff --git a/apiserver/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py b/apps/api/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py rename to apps/api/plane/db/migrations/0082_alter_issue_managers_alter_cycleissue_issue_and_more.py diff --git a/apiserver/plane/db/migrations/0083_device_workspace_timezone_and_more.py b/apps/api/plane/db/migrations/0083_device_workspace_timezone_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0083_device_workspace_timezone_and_more.py rename to apps/api/plane/db/migrations/0083_device_workspace_timezone_and_more.py diff --git a/apiserver/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py b/apps/api/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py rename to apps/api/plane/db/migrations/0084_remove_label_label_unique_name_project_when_deleted_at_null_and_more.py diff --git a/apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py b/apps/api/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py rename to apps/api/plane/db/migrations/0085_intake_intakeissue_remove_inboxissue_created_by_and_more.py diff --git a/apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py b/apps/api/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py rename to apps/api/plane/db/migrations/0086_issueversion_alter_teampage_unique_together_and_more.py diff --git a/apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py b/apps/api/plane/db/migrations/0087_remove_issueversion_description_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0087_remove_issueversion_description_and_more.py rename to apps/api/plane/db/migrations/0087_remove_issueversion_description_and_more.py diff --git a/apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py b/apps/api/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py similarity index 100% rename from apiserver/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py rename to apps/api/plane/db/migrations/0088_sticky_sort_order_workspaceuserlink.py diff --git a/apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py b/apps/api/plane/db/migrations/0089_workspacehomepreference_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0089_workspacehomepreference_and_more.py rename to apps/api/plane/db/migrations/0089_workspacehomepreference_and_more.py diff --git a/apiserver/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py b/apps/api/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py rename to apps/api/plane/db/migrations/0090_rename_dashboard_deprecateddashboard_and_more.py diff --git a/apiserver/plane/db/migrations/0091_issuecomment_edited_at_and_more.py b/apps/api/plane/db/migrations/0091_issuecomment_edited_at_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0091_issuecomment_edited_at_and_more.py rename to apps/api/plane/db/migrations/0091_issuecomment_edited_at_and_more.py diff --git a/apiserver/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py b/apps/api/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py rename to apps/api/plane/db/migrations/0092_alter_deprecateddashboardwidget_unique_together_and_more.py diff --git a/apiserver/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py b/apps/api/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py similarity index 100% rename from apiserver/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py rename to apps/api/plane/db/migrations/0093_page_moved_to_page_page_moved_to_project_and_more.py diff --git a/apiserver/plane/db/migrations/0094_auto_20250425_0902.py b/apps/api/plane/db/migrations/0094_auto_20250425_0902.py similarity index 100% rename from apiserver/plane/db/migrations/0094_auto_20250425_0902.py rename to apps/api/plane/db/migrations/0094_auto_20250425_0902.py diff --git a/apiserver/plane/db/migrations/0095_page_external_id_page_external_source.py b/apps/api/plane/db/migrations/0095_page_external_id_page_external_source.py similarity index 100% rename from apiserver/plane/db/migrations/0095_page_external_id_page_external_source.py rename to apps/api/plane/db/migrations/0095_page_external_id_page_external_source.py diff --git a/apiserver/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py b/apps/api/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py similarity index 100% rename from apiserver/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py rename to apps/api/plane/db/migrations/0096_user_is_email_valid_user_masked_at.py diff --git a/apiserver/plane/db/migrations/0097_project_external_id_project_external_source.py b/apps/api/plane/db/migrations/0097_project_external_id_project_external_source.py similarity index 100% rename from apiserver/plane/db/migrations/0097_project_external_id_project_external_source.py rename to apps/api/plane/db/migrations/0097_project_external_id_project_external_source.py diff --git a/apps/api/plane/db/migrations/0098_profile_is_app_rail_docked_and_more.py b/apps/api/plane/db/migrations/0098_profile_is_app_rail_docked_and_more.py new file mode 100644 index 00000000000..db648ad563f --- /dev/null +++ b/apps/api/plane/db/migrations/0098_profile_is_app_rail_docked_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.21 on 2025-07-14 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0097_project_external_id_project_external_source'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_app_rail_docked', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='commentreaction', + name='reaction', + field=models.TextField(), + ), + migrations.AlterField( + model_name='issuereaction', + name='reaction', + field=models.TextField(), + ), + ] diff --git a/apiserver/plane/db/migrations/__init__.py b/apps/api/plane/db/migrations/__init__.py similarity index 100% rename from apiserver/plane/db/migrations/__init__.py rename to apps/api/plane/db/migrations/__init__.py diff --git a/apiserver/plane/db/mixins.py b/apps/api/plane/db/mixins.py similarity index 100% rename from apiserver/plane/db/mixins.py rename to apps/api/plane/db/mixins.py diff --git a/apiserver/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py similarity index 100% rename from apiserver/plane/db/models/__init__.py rename to apps/api/plane/db/models/__init__.py diff --git a/apiserver/plane/db/models/analytic.py b/apps/api/plane/db/models/analytic.py similarity index 100% rename from apiserver/plane/db/models/analytic.py rename to apps/api/plane/db/models/analytic.py diff --git a/apiserver/plane/db/models/api.py b/apps/api/plane/db/models/api.py similarity index 100% rename from apiserver/plane/db/models/api.py rename to apps/api/plane/db/models/api.py diff --git a/apiserver/plane/db/models/asset.py b/apps/api/plane/db/models/asset.py similarity index 100% rename from apiserver/plane/db/models/asset.py rename to apps/api/plane/db/models/asset.py diff --git a/apiserver/plane/db/models/base.py b/apps/api/plane/db/models/base.py similarity index 100% rename from apiserver/plane/db/models/base.py rename to apps/api/plane/db/models/base.py diff --git a/apiserver/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py similarity index 100% rename from apiserver/plane/db/models/cycle.py rename to apps/api/plane/db/models/cycle.py diff --git a/apiserver/plane/db/models/deploy_board.py b/apps/api/plane/db/models/deploy_board.py similarity index 100% rename from apiserver/plane/db/models/deploy_board.py rename to apps/api/plane/db/models/deploy_board.py diff --git a/apiserver/plane/db/models/device.py b/apps/api/plane/db/models/device.py similarity index 100% rename from apiserver/plane/db/models/device.py rename to apps/api/plane/db/models/device.py diff --git a/apiserver/plane/db/models/draft.py b/apps/api/plane/db/models/draft.py similarity index 100% rename from apiserver/plane/db/models/draft.py rename to apps/api/plane/db/models/draft.py diff --git a/apiserver/plane/db/models/estimate.py b/apps/api/plane/db/models/estimate.py similarity index 100% rename from apiserver/plane/db/models/estimate.py rename to apps/api/plane/db/models/estimate.py diff --git a/apiserver/plane/db/models/exporter.py b/apps/api/plane/db/models/exporter.py similarity index 100% rename from apiserver/plane/db/models/exporter.py rename to apps/api/plane/db/models/exporter.py diff --git a/apiserver/plane/db/models/favorite.py b/apps/api/plane/db/models/favorite.py similarity index 100% rename from apiserver/plane/db/models/favorite.py rename to apps/api/plane/db/models/favorite.py diff --git a/apiserver/plane/db/models/importer.py b/apps/api/plane/db/models/importer.py similarity index 100% rename from apiserver/plane/db/models/importer.py rename to apps/api/plane/db/models/importer.py diff --git a/apiserver/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py similarity index 100% rename from apiserver/plane/db/models/intake.py rename to apps/api/plane/db/models/intake.py diff --git a/apiserver/plane/db/models/integration/__init__.py b/apps/api/plane/db/models/integration/__init__.py similarity index 100% rename from apiserver/plane/db/models/integration/__init__.py rename to apps/api/plane/db/models/integration/__init__.py diff --git a/apiserver/plane/db/models/integration/base.py b/apps/api/plane/db/models/integration/base.py similarity index 100% rename from apiserver/plane/db/models/integration/base.py rename to apps/api/plane/db/models/integration/base.py diff --git a/apiserver/plane/db/models/integration/github.py b/apps/api/plane/db/models/integration/github.py similarity index 100% rename from apiserver/plane/db/models/integration/github.py rename to apps/api/plane/db/models/integration/github.py diff --git a/apiserver/plane/db/models/integration/slack.py b/apps/api/plane/db/models/integration/slack.py similarity index 100% rename from apiserver/plane/db/models/integration/slack.py rename to apps/api/plane/db/models/integration/slack.py diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py new file mode 100644 index 00000000000..a3994d79e00 --- /dev/null +++ b/apps/api/plane/db/models/issue.py @@ -0,0 +1,837 @@ +# Python import +from uuid import uuid4 + +# Django imports +from django.conf import settings +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models, transaction, connection +from django.utils import timezone +from django.db.models import Q +from django import apps + +# Module imports +from plane.utils.html_processor import strip_tags +from plane.db.mixins import SoftDeletionManager +from plane.utils.exception_logger import log_exception +from .project import ProjectBaseModel +from plane.utils.uuid import convert_uuid_to_integer + + +def get_default_properties(): + return { + "assignee": True, + "start_date": True, + "due_date": True, + "labels": True, + "key": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "link": True, + "attachment_count": True, + "estimate": True, + "created_on": True, + "updated_on": True, + } + + +def get_default_filters(): + return { + "priority": None, + "state": None, + "state_group": None, + "assignees": None, + "created_by": None, + "labels": None, + "start_date": None, + "target_date": None, + "subscriber": None, + } + + +def get_default_display_filters(): + return { + "group_by": None, + "order_by": "-created_at", + "type": None, + "sub_issue": True, + "show_empty_groups": True, + "layout": "list", + "calendar_date_range": "", + } + + +def get_default_display_properties(): + return { + "assignee": True, + "attachment_count": True, + "created_on": True, + "due_date": True, + "estimate": True, + "key": True, + "labels": True, + "link": True, + "priority": True, + "start_date": True, + "state": True, + "sub_issue_count": True, + "updated_on": True, + } + + +# TODO: Handle identifiers for Bulk Inserts - nk +class IssueManager(SoftDeletionManager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + models.Q(issue_intake__status=1) + | models.Q(issue_intake__status=-1) + | models.Q(issue_intake__status=2) + | models.Q(issue_intake__isnull=True) + ) + .filter(deleted_at__isnull=True) + .filter(state__is_triage=False) + .exclude(archived_at__isnull=False) + .exclude(project__archived_at__isnull=False) + .exclude(is_draft=True) + ) + + +class Issue(ProjectBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_issue", + ) + point = models.IntegerField( + validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True + ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="issue_estimates", + null=True, + blank=True, + ) + name = models.CharField(max_length=255, verbose_name="Issue Name") + description = models.JSONField(blank=True, default=dict) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + description_binary = models.BinaryField(null=True) + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + default="none", + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="assignee", + through="IssueAssignee", + through_fields=("issue", "assignee"), + ) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + labels = models.ManyToManyField( + "db.Label", blank=True, related_name="labels", through="IssueLabel" + ) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + archived_at = models.DateField(null=True) + is_draft = models.BooleanField(default=False) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + type = models.ForeignKey( + "db.IssueType", + on_delete=models.SET_NULL, + related_name="issue_type", + null=True, + blank=True, + ) + + issue_objects = IssueManager() + + class Meta: + verbose_name = "Issue" + verbose_name_plural = "Issues" + db_table = "issues" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self.state is None: + try: + from plane.db.models import State + + default_state = State.objects.filter( + ~models.Q(is_triage=True), project=self.project, default=True + ).first() + if default_state is None: + random_state = State.objects.filter( + ~models.Q(is_triage=True), project=self.project + ).first() + self.state = random_state + else: + self.state = default_state + except ImportError: + pass + else: + try: + from plane.db.models import State + + if self.state.group == "completed": + self.completed_at = timezone.now() + else: + self.completed_at = None + except ImportError: + pass + + if self._state.adding: + with transaction.atomic(): + # Create a lock for this specific project using an advisory lock + # This ensures only one transaction per project can execute this code at a time + lock_key = convert_uuid_to_integer(self.project.id) + + with connection.cursor() as cursor: + # Get an exclusive lock using the project ID as the lock key + cursor.execute("SELECT pg_advisory_lock(%s)", [lock_key]) + + try: + # Get the last sequence for the project + last_sequence = IssueSequence.objects.filter( + project=self.project + ).aggregate(largest=models.Max("sequence"))["largest"] + self.sequence_id = last_sequence + 1 if last_sequence else 1 + # Strip the html tags using html parser + self.description_stripped = ( + None + if ( + self.description_html == "" or self.description_html is None + ) + else strip_tags(self.description_html) + ) + largest_sort_order = Issue.objects.filter( + project=self.project, state=self.state + ).aggregate(largest=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + super(Issue, self).save(*args, **kwargs) + + IssueSequence.objects.create( + issue=self, sequence=self.sequence_id, project=self.project + ) + finally: + # Release the lock + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key]) + else: + # Strip the html tags using html parser + self.description_stripped = ( + None + if (self.description_html == "" or self.description_html is None) + else strip_tags(self.description_html) + ) + super(Issue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the issue""" + return f"{self.name} <{self.project.name}>" + + +class IssueBlocker(ProjectBaseModel): + block = models.ForeignKey( + Issue, related_name="blocker_issues", on_delete=models.CASCADE + ) + blocked_by = models.ForeignKey( + Issue, related_name="blocked_issues", on_delete=models.CASCADE + ) + + class Meta: + verbose_name = "Issue Blocker" + verbose_name_plural = "Issue Blockers" + db_table = "issue_blockers" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.block.name} {self.blocked_by.name}" + + +class IssueRelationChoices(models.TextChoices): + DUPLICATE = "duplicate", "Duplicate" + RELATES_TO = "relates_to", "Relates To" + BLOCKED_BY = "blocked_by", "Blocked By" + START_BEFORE = "start_before", "Start Before" + FINISH_BEFORE = "finish_before", "Finish Before" + + +class IssueRelation(ProjectBaseModel): + issue = models.ForeignKey( + Issue, related_name="issue_relation", on_delete=models.CASCADE + ) + related_issue = models.ForeignKey( + Issue, related_name="issue_related", on_delete=models.CASCADE + ) + relation_type = models.CharField( + max_length=20, + choices=IssueRelationChoices.choices, + verbose_name="Issue Relation Type", + default=IssueRelationChoices.BLOCKED_BY, + ) + + class Meta: + unique_together = ["issue", "related_issue", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "related_issue"], + condition=Q(deleted_at__isnull=True), + name="issue_relation_unique_issue_related_issue_when_deleted_at_null", + ) + ] + verbose_name = "Issue Relation" + verbose_name_plural = "Issue Relations" + db_table = "issue_relations" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.related_issue.name}" + + +class IssueMention(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_mention" + ) + mention = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention" + ) + + class Meta: + unique_together = ["issue", "mention", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "mention"], + condition=Q(deleted_at__isnull=True), + name="issue_mention_unique_issue_mention_when_deleted_at_null", + ) + ] + verbose_name = "Issue Mention" + verbose_name_plural = "Issue Mentions" + db_table = "issue_mentions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.mention.email}" + + +class IssueAssignee(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_assignee" + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_assignee", + ) + + class Meta: + unique_together = ["issue", "assignee", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "assignee"], + condition=Q(deleted_at__isnull=True), + name="issue_assignee_unique_issue_assignee_when_deleted_at_null", + ) + ] + verbose_name = "Issue Assignee" + verbose_name_plural = "Issue Assignees" + db_table = "issue_assignees" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.assignee.email}" + + +class IssueLink(ProjectBaseModel): + title = models.CharField(max_length=255, null=True, blank=True) + url = models.TextField() + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="issue_link" + ) + metadata = models.JSONField(default=dict) + + class Meta: + verbose_name = "Issue Link" + verbose_name_plural = "Issue Links" + db_table = "issue_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.url}" + + +def get_upload_path(instance, filename): + return f"{instance.workspace.id}/{uuid4().hex}-{filename}" + + +def file_size(value): + # File limit check is only for cloud hosted + if value.size > settings.FILE_SIZE_LIMIT: + raise ValidationError("File too large. Size should not exceed 5 MB.") + + +class IssueAttachment(ProjectBaseModel): + attributes = models.JSONField(default=dict) + asset = models.FileField(upload_to=get_upload_path, validators=[file_size]) + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" + ) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + + class Meta: + verbose_name = "Issue Attachment" + verbose_name_plural = "Issue Attachments" + db_table = "issue_attachments" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.asset}" + + +class IssueActivity(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" + ) + verb = models.CharField(max_length=255, verbose_name="Action", default="created") + field = models.CharField( + max_length=255, verbose_name="Field Name", blank=True, null=True + ) + old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) + new_value = models.TextField(verbose_name="New Value", blank=True, null=True) + + comment = models.TextField(verbose_name="Comment", blank=True) + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue_comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.SET_NULL, + related_name="issue_comment", + null=True, + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="issue_activities", + ) + old_identifier = models.UUIDField(null=True) + new_identifier = models.UUIDField(null=True) + epoch = models.FloatField(null=True) + + class Meta: + verbose_name = "Issue Activity" + verbose_name_plural = "Issue Activities" + db_table = "issue_activities" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class IssueComment(ProjectBaseModel): + comment_stripped = models.TextField(verbose_name="Comment", blank=True) + comment_json = models.JSONField(blank=True, default=dict) + comment_html = models.TextField(blank=True, default="

") + attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_comments" + ) + # System can also create comment + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="comments", + null=True, + ) + access = models.CharField( + choices=(("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")), + default="INTERNAL", + max_length=100, + ) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + edited_at = models.DateTimeField(null=True, blank=True) + + def save(self, *args, **kwargs): + self.comment_stripped = ( + strip_tags(self.comment_html) if self.comment_html != "" else "" + ) + return super(IssueComment, self).save(*args, **kwargs) + + class Meta: + verbose_name = "Issue Comment" + verbose_name_plural = "Issue Comments" + db_table = "issue_comments" + ordering = ("-created_at",) + + def __str__(self): + """Return issue of the comment""" + return str(self.issue) + + +class IssueUserProperty(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_property_user", + ) + filters = models.JSONField(default=get_default_filters) + display_filters = models.JSONField(default=get_default_display_filters) + display_properties = models.JSONField(default=get_default_display_properties) + + class Meta: + verbose_name = "Issue User Property" + verbose_name_plural = "Issue User Properties" + db_table = "issue_user_properties" + ordering = ("-created_at",) + unique_together = ["user", "project", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "project"], + condition=Q(deleted_at__isnull=True), + name="issue_user_property_unique_user_project_when_deleted_at_null", + ) + ] + + def __str__(self): + """Return properties status of the issue""" + return str(self.user) + + +class IssueLabel(ProjectBaseModel): + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="label_issue" + ) + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="label_issue" + ) + + class Meta: + verbose_name = "Issue Label" + verbose_name_plural = "Issue Labels" + db_table = "issue_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.label.name}" + + +class IssueSequence(ProjectBaseModel): + issue = models.ForeignKey( + Issue, + on_delete=models.SET_NULL, + related_name="issue_sequence", + null=True, # This is set to null because we want to keep the sequence even if the issue is deleted + ) + sequence = models.PositiveBigIntegerField(default=1, db_index=True) + deleted = models.BooleanField(default=False) + + class Meta: + verbose_name = "Issue Sequence" + verbose_name_plural = "Issue Sequences" + db_table = "issue_sequences" + ordering = ("-created_at",) + + +class IssueSubscriber(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_subscribers" + ) + subscriber = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_subscribers", + ) + + class Meta: + unique_together = ["issue", "subscriber", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "subscriber"], + condition=models.Q(deleted_at__isnull=True), + name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null", + ) + ] + verbose_name = "Issue Subscriber" + verbose_name_plural = "Issue Subscribers" + db_table = "issue_subscribers" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.subscriber.email}" + + +class IssueReaction(ProjectBaseModel): + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_reactions", + ) + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_reactions" + ) + reaction = models.TextField() + + class Meta: + unique_together = ["issue", "actor", "reaction", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "actor", "reaction"], + condition=models.Q(deleted_at__isnull=True), + name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null", + ) + ] + verbose_name = "Issue Reaction" + verbose_name_plural = "Issue Reactions" + db_table = "issue_reactions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +class CommentReaction(ProjectBaseModel): + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="comment_reactions", + ) + comment = models.ForeignKey( + IssueComment, on_delete=models.CASCADE, related_name="comment_reactions" + ) + reaction = models.TextField() + + class Meta: + unique_together = ["comment", "actor", "reaction", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["comment", "actor", "reaction"], + condition=models.Q(deleted_at__isnull=True), + name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null", + ) + ] + verbose_name = "Comment Reaction" + verbose_name_plural = "Comment Reactions" + db_table = "comment_reactions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +class IssueVote(ProjectBaseModel): + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes") + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" + ) + vote = models.IntegerField(choices=((-1, "DOWNVOTE"), (1, "UPVOTE")), default=1) + + class Meta: + unique_together = ["issue", "actor", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["issue", "actor"], + condition=models.Q(deleted_at__isnull=True), + name="issue_vote_unique_issue_actor_when_deleted_at_null", + ) + ] + verbose_name = "Issue Vote" + verbose_name_plural = "Issue Votes" + db_table = "issue_votes" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +class IssueVersion(ProjectBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + + parent = models.UUIDField(blank=True, null=True) + state = models.UUIDField(blank=True, null=True) + estimate_point = models.UUIDField(blank=True, null=True) + name = models.CharField(max_length=255, verbose_name="Issue Name") + priority = models.CharField( + max_length=30, + choices=PRIORITY_CHOICES, + verbose_name="Issue Priority", + default="none", + ) + start_date = models.DateField(null=True, blank=True) + target_date = models.DateField(null=True, blank=True) + assignees = ArrayField(models.UUIDField(), blank=True, default=list) + sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") + labels = ArrayField(models.UUIDField(), blank=True, default=list) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + archived_at = models.DateField(null=True) + is_draft = models.BooleanField(default=False) + external_source = models.CharField(max_length=255, null=True, blank=True) + external_id = models.CharField(max_length=255, blank=True, null=True) + type = models.UUIDField(blank=True, null=True) + cycle = models.UUIDField(null=True, blank=True) + modules = ArrayField(models.UUIDField(), blank=True, default=list) + properties = models.JSONField(default=dict) # issue properties + meta = models.JSONField(default=dict) # issue meta + last_saved_at = models.DateTimeField(default=timezone.now) + + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="versions" + ) + activity = models.ForeignKey( + "db.IssueActivity", + on_delete=models.SET_NULL, + null=True, + related_name="versions", + ) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_versions", + ) + + class Meta: + verbose_name = "Issue Version" + verbose_name_plural = "Issue Versions" + db_table = "issue_versions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.name} <{self.project.name}>" + + @classmethod + def log_issue_version(cls, issue, user): + try: + """ + Log the issue version + """ + + Module = apps.get_model("db.Module") + CycleIssue = apps.get_model("db.CycleIssue") + IssueAssignee = apps.get_model("db.IssueAssignee") + IssueLabel = apps.get_model("db.IssueLabel") + + cycle_issue = CycleIssue.objects.filter(issue=issue).first() + + cls.objects.create( + issue=issue, + parent=issue.parent_id, + state=issue.state_id, + estimate_point=issue.estimate_point_id, + name=issue.name, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + assignees=list( + IssueAssignee.objects.filter(issue=issue).values_list( + "assignee_id", flat=True + ) + ), + sequence_id=issue.sequence_id, + labels=list( + IssueLabel.objects.filter(issue=issue).values_list( + "label_id", flat=True + ) + ), + sort_order=issue.sort_order, + completed_at=issue.completed_at, + archived_at=issue.archived_at, + is_draft=issue.is_draft, + external_source=issue.external_source, + external_id=issue.external_id, + type=issue.type_id, + cycle=cycle_issue.cycle_id if cycle_issue else None, + modules=list( + Module.objects.filter(issue=issue).values_list("id", flat=True) + ), + properties={}, + meta={}, + last_saved_at=timezone.now(), + owned_by=user, + ) + return True + except Exception as e: + log_exception(e) + return False + + +class IssueDescriptionVersion(ProjectBaseModel): + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="description_versions" + ) + description_binary = models.BinaryField(null=True) + description_html = models.TextField(blank=True, default="

") + description_stripped = models.TextField(blank=True, null=True) + description_json = models.JSONField(default=dict, blank=True) + last_saved_at = models.DateTimeField(default=timezone.now) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_description_versions", + ) + + class Meta: + verbose_name = "Issue Description Version" + verbose_name_plural = "Issue Description Versions" + db_table = "issue_description_versions" + + @classmethod + def log_issue_description_version(cls, issue, user): + try: + """ + Log the issue description version + """ + cls.objects.create( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + owned_by_id=user, + last_saved_at=timezone.now(), + issue_id=issue.id, + description_binary=issue.description_binary, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_json=issue.description, + ) + return True + except Exception as e: + log_exception(e) + return False diff --git a/apiserver/plane/db/models/issue_type.py b/apps/api/plane/db/models/issue_type.py similarity index 100% rename from apiserver/plane/db/models/issue_type.py rename to apps/api/plane/db/models/issue_type.py diff --git a/apiserver/plane/db/models/label.py b/apps/api/plane/db/models/label.py similarity index 100% rename from apiserver/plane/db/models/label.py rename to apps/api/plane/db/models/label.py diff --git a/apiserver/plane/db/models/module.py b/apps/api/plane/db/models/module.py similarity index 100% rename from apiserver/plane/db/models/module.py rename to apps/api/plane/db/models/module.py diff --git a/apiserver/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py similarity index 100% rename from apiserver/plane/db/models/notification.py rename to apps/api/plane/db/models/notification.py diff --git a/apiserver/plane/db/models/page.py b/apps/api/plane/db/models/page.py similarity index 100% rename from apiserver/plane/db/models/page.py rename to apps/api/plane/db/models/page.py diff --git a/apiserver/plane/db/models/project.py b/apps/api/plane/db/models/project.py similarity index 100% rename from apiserver/plane/db/models/project.py rename to apps/api/plane/db/models/project.py diff --git a/apiserver/plane/db/models/recent_visit.py b/apps/api/plane/db/models/recent_visit.py similarity index 100% rename from apiserver/plane/db/models/recent_visit.py rename to apps/api/plane/db/models/recent_visit.py diff --git a/apiserver/plane/db/models/session.py b/apps/api/plane/db/models/session.py similarity index 100% rename from apiserver/plane/db/models/session.py rename to apps/api/plane/db/models/session.py diff --git a/apiserver/plane/db/models/social_connection.py b/apps/api/plane/db/models/social_connection.py similarity index 100% rename from apiserver/plane/db/models/social_connection.py rename to apps/api/plane/db/models/social_connection.py diff --git a/apiserver/plane/db/models/state.py b/apps/api/plane/db/models/state.py similarity index 100% rename from apiserver/plane/db/models/state.py rename to apps/api/plane/db/models/state.py diff --git a/apiserver/plane/db/models/sticky.py b/apps/api/plane/db/models/sticky.py similarity index 100% rename from apiserver/plane/db/models/sticky.py rename to apps/api/plane/db/models/sticky.py diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py new file mode 100644 index 00000000000..b2613a42782 --- /dev/null +++ b/apps/api/plane/db/models/user.py @@ -0,0 +1,278 @@ +# Python imports +import random +import string +import uuid + +import pytz +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager + +# Django imports +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone + +# Module imports +from plane.db.models import FileAsset +from ..mixins import TimeAuditModel + + +def get_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_invite": False, + "workspace_join": False, + } + + +def get_mobile_default_onboarding(): + return { + "profile_complete": False, + "workspace_create": False, + "workspace_join": False, + } + + +class User(AbstractBaseUser, PermissionsMixin): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + username = models.CharField(max_length=128, unique=True) + # user fields + mobile_number = models.CharField(max_length=255, blank=True, null=True) + email = models.CharField(max_length=255, null=True, blank=True, unique=True) + + # identity + display_name = models.CharField(max_length=255, default="") + first_name = models.CharField(max_length=255, blank=True) + last_name = models.CharField(max_length=255, blank=True) + # avatar + avatar = models.TextField(blank=True) + avatar_asset = models.ForeignKey( + FileAsset, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_avatar", + ) + # cover image + cover_image = models.URLField(blank=True, null=True, max_length=800) + cover_image_asset = models.ForeignKey( + FileAsset, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_cover_image", + ) + + # tracking metrics + date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At") + last_location = models.CharField(max_length=255, blank=True) + created_location = models.CharField(max_length=255, blank=True) + + # the is' es + is_superuser = models.BooleanField(default=False) + is_managed = models.BooleanField(default=False) + is_password_expired = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_email_verified = models.BooleanField(default=False) + is_password_autoset = models.BooleanField(default=False) + + # random token generated + token = models.CharField(max_length=64, blank=True) + + last_active = models.DateTimeField(default=timezone.now, null=True) + last_login_time = models.DateTimeField(null=True) + last_logout_time = models.DateTimeField(null=True) + last_login_ip = models.CharField(max_length=255, blank=True) + last_logout_ip = models.CharField(max_length=255, blank=True) + last_login_medium = models.CharField(max_length=20, default="email") + last_login_uagent = models.TextField(blank=True) + token_updated_at = models.DateTimeField(null=True) + # my_issues_prop = models.JSONField(null=True) + + is_bot = models.BooleanField(default=False) + bot_type = models.CharField( + max_length=30, verbose_name="Bot Type", blank=True, null=True + ) + + # timezone + USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + user_timezone = models.CharField( + max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES + ) + + # email validation + is_email_valid = models.BooleanField(default=False) + + # masking + masked_at = models.DateTimeField(null=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + objects = UserManager() + + class Meta: + verbose_name = "User" + verbose_name_plural = "Users" + db_table = "users" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.username} <{self.email}>" + + @property + def avatar_url(self): + # Return the logo asset url if it exists + if self.avatar_asset: + return self.avatar_asset.asset_url + + # Return the logo url if it exists + if self.avatar: + return self.avatar + return None + + @property + def cover_image_url(self): + # Return the logo asset url if it exists + if self.cover_image_asset: + return self.cover_image_asset.asset_url + + # Return the logo url if it exists + if self.cover_image: + return self.cover_image + return None + + def save(self, *args, **kwargs): + self.email = self.email.lower().strip() + self.mobile_number = self.mobile_number + + if self.token_updated_at is not None: + self.token = uuid.uuid4().hex + uuid.uuid4().hex + self.token_updated_at = timezone.now() + + if not self.display_name: + self.display_name = ( + self.email.split("@")[0] + if len(self.email.split("@")) + else "".join(random.choice(string.ascii_letters) for _ in range(6)) + ) + + if self.is_superuser: + self.is_staff = True + + super(User, self).save(*args, **kwargs) + + +class Profile(TimeAuditModel): + SUNDAY = 0 + MONDAY = 1 + TUESDAY = 2 + WEDNESDAY = 3 + THURSDAY = 4 + FRIDAY = 5 + SATURDAY = 6 + + START_OF_THE_WEEK_CHOICES = ( + (SUNDAY, "Sunday"), + (MONDAY, "Monday"), + (TUESDAY, "Tuesday"), + (WEDNESDAY, "Wednesday"), + (THURSDAY, "Thursday"), + (FRIDAY, "Friday"), + (SATURDAY, "Saturday"), + ) + + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + # User + user = models.OneToOneField( + "db.User", on_delete=models.CASCADE, related_name="profile" + ) + # General + theme = models.JSONField(default=dict) + is_app_rail_docked = models.BooleanField(default=True) + # Onboarding + is_tour_completed = models.BooleanField(default=False) + onboarding_step = models.JSONField(default=get_default_onboarding) + use_case = models.TextField(blank=True, null=True) + role = models.CharField(max_length=300, null=True, blank=True) # job role + is_onboarded = models.BooleanField(default=False) + # Last visited workspace + last_workspace_id = models.UUIDField(null=True) + # address data + billing_address_country = models.CharField(max_length=255, default="INDIA") + billing_address = models.JSONField(null=True) + has_billing_address = models.BooleanField(default=False) + company_name = models.CharField(max_length=255, blank=True) + + is_smooth_cursor_enabled = models.BooleanField(default=False) + # mobile + is_mobile_onboarded = models.BooleanField(default=False) + mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding) + mobile_timezone_auto_set = models.BooleanField(default=False) + # language + language = models.CharField(max_length=255, default="en") + start_of_the_week = models.PositiveSmallIntegerField( + choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY + ) + + class Meta: + verbose_name = "Profile" + verbose_name_plural = "Profiles" + db_table = "profiles" + ordering = ("-created_at",) + + +class Account(TimeAuditModel): + PROVIDER_CHOICES = ( + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ) + + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + user = models.ForeignKey( + "db.User", on_delete=models.CASCADE, related_name="accounts" + ) + provider_account_id = models.CharField(max_length=255) + provider = models.CharField(choices=PROVIDER_CHOICES) + access_token = models.TextField() + access_token_expired_at = models.DateTimeField(null=True) + refresh_token = models.TextField(null=True, blank=True) + refresh_token_expired_at = models.DateTimeField(null=True) + last_connected_at = models.DateTimeField(default=timezone.now) + id_token = models.TextField(blank=True) + metadata = models.JSONField(default=dict) + + class Meta: + unique_together = ["provider", "provider_account_id"] + verbose_name = "Account" + verbose_name_plural = "Accounts" + db_table = "accounts" + ordering = ("-created_at",) + + +@receiver(post_save, sender=User) +def create_user_notification(sender, instance, created, **kwargs): + # create preferences + if created and not instance.is_bot: + # Module imports + from plane.db.models import UserNotificationPreference + + UserNotificationPreference.objects.create( + user=instance, + property_change=False, + state_change=False, + comment=False, + mention=False, + issue_completed=False, + ) diff --git a/apiserver/plane/db/models/view.py b/apps/api/plane/db/models/view.py similarity index 100% rename from apiserver/plane/db/models/view.py rename to apps/api/plane/db/models/view.py diff --git a/apiserver/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py similarity index 100% rename from apiserver/plane/db/models/webhook.py rename to apps/api/plane/db/models/webhook.py diff --git a/apiserver/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py similarity index 100% rename from apiserver/plane/db/models/workspace.py rename to apps/api/plane/db/models/workspace.py diff --git a/apiserver/plane/license/__init__.py b/apps/api/plane/license/__init__.py similarity index 100% rename from apiserver/plane/license/__init__.py rename to apps/api/plane/license/__init__.py diff --git a/apiserver/plane/license/api/__init__.py b/apps/api/plane/license/api/__init__.py similarity index 100% rename from apiserver/plane/license/api/__init__.py rename to apps/api/plane/license/api/__init__.py diff --git a/apiserver/plane/license/api/permissions/__init__.py b/apps/api/plane/license/api/permissions/__init__.py similarity index 100% rename from apiserver/plane/license/api/permissions/__init__.py rename to apps/api/plane/license/api/permissions/__init__.py diff --git a/apiserver/plane/license/api/permissions/instance.py b/apps/api/plane/license/api/permissions/instance.py similarity index 100% rename from apiserver/plane/license/api/permissions/instance.py rename to apps/api/plane/license/api/permissions/instance.py diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apps/api/plane/license/api/serializers/__init__.py similarity index 100% rename from apiserver/plane/license/api/serializers/__init__.py rename to apps/api/plane/license/api/serializers/__init__.py diff --git a/apiserver/plane/license/api/serializers/admin.py b/apps/api/plane/license/api/serializers/admin.py similarity index 100% rename from apiserver/plane/license/api/serializers/admin.py rename to apps/api/plane/license/api/serializers/admin.py diff --git a/apiserver/plane/license/api/serializers/base.py b/apps/api/plane/license/api/serializers/base.py similarity index 100% rename from apiserver/plane/license/api/serializers/base.py rename to apps/api/plane/license/api/serializers/base.py diff --git a/apiserver/plane/license/api/serializers/configuration.py b/apps/api/plane/license/api/serializers/configuration.py similarity index 100% rename from apiserver/plane/license/api/serializers/configuration.py rename to apps/api/plane/license/api/serializers/configuration.py diff --git a/apiserver/plane/license/api/serializers/instance.py b/apps/api/plane/license/api/serializers/instance.py similarity index 100% rename from apiserver/plane/license/api/serializers/instance.py rename to apps/api/plane/license/api/serializers/instance.py diff --git a/apiserver/plane/license/api/serializers/user.py b/apps/api/plane/license/api/serializers/user.py similarity index 100% rename from apiserver/plane/license/api/serializers/user.py rename to apps/api/plane/license/api/serializers/user.py diff --git a/apiserver/plane/license/api/serializers/workspace.py b/apps/api/plane/license/api/serializers/workspace.py similarity index 100% rename from apiserver/plane/license/api/serializers/workspace.py rename to apps/api/plane/license/api/serializers/workspace.py diff --git a/apps/api/plane/license/api/views/__init__.py b/apps/api/plane/license/api/views/__init__.py new file mode 100644 index 00000000000..7f30d53fe66 --- /dev/null +++ b/apps/api/plane/license/api/views/__init__.py @@ -0,0 +1,24 @@ +from .instance import InstanceEndpoint, SignUpScreenVisitedEndpoint + + +from .configuration import ( + EmailCredentialCheckEndpoint, + InstanceConfigurationEndpoint, + DisableEmailFeatureEndpoint, +) + + +from .admin import ( + InstanceAdminEndpoint, + InstanceAdminSignInEndpoint, + InstanceAdminSignUpEndpoint, + InstanceAdminUserMeEndpoint, + InstanceAdminSignOutEndpoint, + InstanceAdminUserSessionEndpoint, +) + + +from .workspace import ( + InstanceWorkSpaceAvailabilityCheckEndpoint, + InstanceWorkSpaceEndpoint, +) diff --git a/apiserver/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py similarity index 100% rename from apiserver/plane/license/api/views/admin.py rename to apps/api/plane/license/api/views/admin.py diff --git a/apiserver/plane/license/api/views/base.py b/apps/api/plane/license/api/views/base.py similarity index 100% rename from apiserver/plane/license/api/views/base.py rename to apps/api/plane/license/api/views/base.py diff --git a/apiserver/plane/license/api/views/configuration.py b/apps/api/plane/license/api/views/configuration.py similarity index 84% rename from apiserver/plane/license/api/views/configuration.py rename to apps/api/plane/license/api/views/configuration.py index 594a899eba4..3bf996db929 100644 --- a/apiserver/plane/license/api/views/configuration.py +++ b/apps/api/plane/license/api/views/configuration.py @@ -9,6 +9,7 @@ # Django imports from django.core.mail import BadHeaderError, EmailMultiAlternatives, get_connection +from django.db.models import Q, Case, When, Value # Third party imports from rest_framework import status @@ -57,6 +58,34 @@ def patch(self, request): return Response(serializer.data, status=status.HTTP_200_OK) +class DisableEmailFeatureEndpoint(BaseAPIView): + permission_classes = [InstanceAdminPermission] + + @invalidate_cache(path="/api/instances/", user=False) + def delete(self, request): + try: + InstanceConfiguration.objects.filter( + Q( + key__in=[ + "EMAIL_HOST", + "EMAIL_HOST_USER", + "EMAIL_HOST_PASSWORD", + "ENABLE_SMTP", + "EMAIL_PORT", + "EMAIL_FROM", + ] + ) + ).update( + value=Case(When(key="ENABLE_SMTP", then=Value("0")), default=Value("")) + ) + return Response(status=status.HTTP_200_OK) + except Exception: + return Response( + {"error": "Failed to disable email configuration"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + class EmailCredentialCheckEndpoint(BaseAPIView): def post(self, request): receiver_email = request.data.get("receiver_email", False) diff --git a/apiserver/plane/license/api/views/instance.py b/apps/api/plane/license/api/views/instance.py similarity index 100% rename from apiserver/plane/license/api/views/instance.py rename to apps/api/plane/license/api/views/instance.py diff --git a/apiserver/plane/license/api/views/workspace.py b/apps/api/plane/license/api/views/workspace.py similarity index 100% rename from apiserver/plane/license/api/views/workspace.py rename to apps/api/plane/license/api/views/workspace.py diff --git a/apiserver/plane/license/apps.py b/apps/api/plane/license/apps.py similarity index 100% rename from apiserver/plane/license/apps.py rename to apps/api/plane/license/apps.py diff --git a/apiserver/plane/license/bgtasks/__init__.py b/apps/api/plane/license/bgtasks/__init__.py similarity index 100% rename from apiserver/plane/license/bgtasks/__init__.py rename to apps/api/plane/license/bgtasks/__init__.py diff --git a/apiserver/plane/license/bgtasks/tracer.py b/apps/api/plane/license/bgtasks/tracer.py similarity index 100% rename from apiserver/plane/license/bgtasks/tracer.py rename to apps/api/plane/license/bgtasks/tracer.py diff --git a/apiserver/plane/license/management/__init__.py b/apps/api/plane/license/management/__init__.py similarity index 100% rename from apiserver/plane/license/management/__init__.py rename to apps/api/plane/license/management/__init__.py diff --git a/apiserver/plane/license/management/commands/__init__.py b/apps/api/plane/license/management/commands/__init__.py similarity index 100% rename from apiserver/plane/license/management/commands/__init__.py rename to apps/api/plane/license/management/commands/__init__.py diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py similarity index 98% rename from apiserver/plane/license/management/commands/configure_instance.py rename to apps/api/plane/license/management/commands/configure_instance.py index 2e1b6a12356..1414c970c54 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -89,6 +89,12 @@ def handle(self, *args, **options): "category": "GITLAB", "is_encrypted": False, }, + { + "key": "ENABLE_SMTP", + "value": os.environ.get("ENABLE_SMTP", "0"), + "category": "SMTP", + "is_encrypted": False, + }, { "key": "GITLAB_CLIENT_SECRET", "value": os.environ.get("GITLAB_CLIENT_SECRET"), diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apps/api/plane/license/management/commands/register_instance.py similarity index 100% rename from apiserver/plane/license/management/commands/register_instance.py rename to apps/api/plane/license/management/commands/register_instance.py diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apps/api/plane/license/migrations/0001_initial.py similarity index 100% rename from apiserver/plane/license/migrations/0001_initial.py rename to apps/api/plane/license/migrations/0001_initial.py diff --git a/apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py b/apps/api/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py similarity index 100% rename from apiserver/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py rename to apps/api/plane/license/migrations/0002_rename_version_instance_current_version_and_more.py diff --git a/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py b/apps/api/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py similarity index 100% rename from apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py rename to apps/api/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py diff --git a/apiserver/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py b/apps/api/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py similarity index 100% rename from apiserver/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py rename to apps/api/plane/license/migrations/0004_changelog_deleted_at_instance_deleted_at_and_more.py diff --git a/apiserver/plane/license/migrations/0005_rename_product_instance_edition_and_more.py b/apps/api/plane/license/migrations/0005_rename_product_instance_edition_and_more.py similarity index 100% rename from apiserver/plane/license/migrations/0005_rename_product_instance_edition_and_more.py rename to apps/api/plane/license/migrations/0005_rename_product_instance_edition_and_more.py diff --git a/apiserver/plane/license/migrations/__init__.py b/apps/api/plane/license/migrations/__init__.py similarity index 100% rename from apiserver/plane/license/migrations/__init__.py rename to apps/api/plane/license/migrations/__init__.py diff --git a/apiserver/plane/license/models/__init__.py b/apps/api/plane/license/models/__init__.py similarity index 100% rename from apiserver/plane/license/models/__init__.py rename to apps/api/plane/license/models/__init__.py diff --git a/apiserver/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py similarity index 100% rename from apiserver/plane/license/models/instance.py rename to apps/api/plane/license/models/instance.py diff --git a/apiserver/plane/license/urls.py b/apps/api/plane/license/urls.py similarity index 91% rename from apiserver/plane/license/urls.py rename to apps/api/plane/license/urls.py index 9c3adbf98ab..4d306924eaf 100644 --- a/apiserver/plane/license/urls.py +++ b/apps/api/plane/license/urls.py @@ -6,6 +6,7 @@ InstanceAdminSignInEndpoint, InstanceAdminSignUpEndpoint, InstanceConfigurationEndpoint, + DisableEmailFeatureEndpoint, InstanceEndpoint, SignUpScreenVisitedEndpoint, InstanceAdminUserMeEndpoint, @@ -35,6 +36,11 @@ InstanceConfigurationEndpoint.as_view(), name="instance-configuration", ), + path( + "configurations/disable-email-feature/", + DisableEmailFeatureEndpoint.as_view(), + name="disable-email-configuration", + ), path( "admins/sign-in/", InstanceAdminSignInEndpoint.as_view(), diff --git a/apiserver/plane/license/utils/__init__.py b/apps/api/plane/license/utils/__init__.py similarity index 100% rename from apiserver/plane/license/utils/__init__.py rename to apps/api/plane/license/utils/__init__.py diff --git a/apiserver/plane/license/utils/encryption.py b/apps/api/plane/license/utils/encryption.py similarity index 100% rename from apiserver/plane/license/utils/encryption.py rename to apps/api/plane/license/utils/encryption.py diff --git a/apiserver/plane/license/utils/instance_value.py b/apps/api/plane/license/utils/instance_value.py similarity index 100% rename from apiserver/plane/license/utils/instance_value.py rename to apps/api/plane/license/utils/instance_value.py diff --git a/apiserver/plane/middleware/__init__.py b/apps/api/plane/middleware/__init__.py similarity index 100% rename from apiserver/plane/middleware/__init__.py rename to apps/api/plane/middleware/__init__.py diff --git a/apiserver/plane/middleware/apps.py b/apps/api/plane/middleware/apps.py similarity index 100% rename from apiserver/plane/middleware/apps.py rename to apps/api/plane/middleware/apps.py diff --git a/apiserver/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py similarity index 100% rename from apiserver/plane/middleware/logger.py rename to apps/api/plane/middleware/logger.py diff --git a/apiserver/plane/seeds/data/issues.json b/apps/api/plane/seeds/data/issues.json similarity index 100% rename from apiserver/plane/seeds/data/issues.json rename to apps/api/plane/seeds/data/issues.json diff --git a/apiserver/plane/seeds/data/labels.json b/apps/api/plane/seeds/data/labels.json similarity index 100% rename from apiserver/plane/seeds/data/labels.json rename to apps/api/plane/seeds/data/labels.json diff --git a/apiserver/plane/seeds/data/projects.json b/apps/api/plane/seeds/data/projects.json similarity index 100% rename from apiserver/plane/seeds/data/projects.json rename to apps/api/plane/seeds/data/projects.json diff --git a/apiserver/plane/seeds/data/states.json b/apps/api/plane/seeds/data/states.json similarity index 100% rename from apiserver/plane/seeds/data/states.json rename to apps/api/plane/seeds/data/states.json diff --git a/apiserver/plane/settings/__init__.py b/apps/api/plane/settings/__init__.py similarity index 100% rename from apiserver/plane/settings/__init__.py rename to apps/api/plane/settings/__init__.py diff --git a/apiserver/plane/settings/common.py b/apps/api/plane/settings/common.py similarity index 100% rename from apiserver/plane/settings/common.py rename to apps/api/plane/settings/common.py diff --git a/apiserver/plane/settings/local.py b/apps/api/plane/settings/local.py similarity index 100% rename from apiserver/plane/settings/local.py rename to apps/api/plane/settings/local.py diff --git a/apiserver/plane/settings/production.py b/apps/api/plane/settings/production.py similarity index 100% rename from apiserver/plane/settings/production.py rename to apps/api/plane/settings/production.py diff --git a/apiserver/plane/settings/redis.py b/apps/api/plane/settings/redis.py similarity index 100% rename from apiserver/plane/settings/redis.py rename to apps/api/plane/settings/redis.py diff --git a/apiserver/plane/settings/storage.py b/apps/api/plane/settings/storage.py similarity index 98% rename from apiserver/plane/settings/storage.py rename to apps/api/plane/settings/storage.py index f2be261ad3f..71709ebe03e 100644 --- a/apiserver/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -1,5 +1,6 @@ # Python imports import os +import uuid # Third party imports import boto3 @@ -101,6 +102,9 @@ def generate_presigned_post( def _get_content_disposition(self, disposition, filename=None): """Helper method to generate Content-Disposition header value""" + if filename is None: + filename = uuid.uuid4().hex + if filename: # Encode the filename to handle special characters encoded_filename = quote(filename) diff --git a/apiserver/plane/settings/test.py b/apps/api/plane/settings/test.py similarity index 100% rename from apiserver/plane/settings/test.py rename to apps/api/plane/settings/test.py diff --git a/apiserver/plane/space/__init__.py b/apps/api/plane/space/__init__.py similarity index 100% rename from apiserver/plane/space/__init__.py rename to apps/api/plane/space/__init__.py diff --git a/apiserver/plane/space/apps.py b/apps/api/plane/space/apps.py similarity index 100% rename from apiserver/plane/space/apps.py rename to apps/api/plane/space/apps.py diff --git a/apiserver/plane/space/serializer/__init__.py b/apps/api/plane/space/serializer/__init__.py similarity index 100% rename from apiserver/plane/space/serializer/__init__.py rename to apps/api/plane/space/serializer/__init__.py diff --git a/apiserver/plane/space/serializer/base.py b/apps/api/plane/space/serializer/base.py similarity index 100% rename from apiserver/plane/space/serializer/base.py rename to apps/api/plane/space/serializer/base.py diff --git a/apiserver/plane/space/serializer/cycle.py b/apps/api/plane/space/serializer/cycle.py similarity index 100% rename from apiserver/plane/space/serializer/cycle.py rename to apps/api/plane/space/serializer/cycle.py diff --git a/apiserver/plane/space/serializer/intake.py b/apps/api/plane/space/serializer/intake.py similarity index 100% rename from apiserver/plane/space/serializer/intake.py rename to apps/api/plane/space/serializer/intake.py diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py new file mode 100644 index 00000000000..3549e76262c --- /dev/null +++ b/apps/api/plane/space/serializer/issue.py @@ -0,0 +1,465 @@ +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .user import UserLiteSerializer +from .state import StateSerializer, StateLiteSerializer +from .project import ProjectLiteSerializer +from .cycle import CycleBaseSerializer +from .module import ModuleBaseSerializer +from .workspace import WorkspaceLiteSerializer +from plane.db.models import ( + User, + Issue, + IssueComment, + IssueAssignee, + IssueLabel, + Label, + CycleIssue, + ModuleIssue, + IssueLink, + FileAsset, + IssueReaction, + CommentReaction, + IssueVote, + IssueRelation, +) +from plane.utils.content_validator import ( + validate_html_content, + validate_json_content, + validate_binary_data, +) + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = ["id", "sequence_id", "name", "state_detail", "project_detail"] + + +class LabelSerializer(BaseSerializer): + workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Label + fields = "__all__" + read_only_fields = ["workspace", "project"] + + +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = ["id", "project_detail", "name", "sequence_id"] + read_only_fields = fields + + +class IssueRelationSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue") + + class Meta: + model = IssueRelation + fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] + read_only_fields = ["workspace", "project"] + + +class RelatedIssueSerializer(BaseSerializer): + issue_detail = IssueProjectLiteSerializer(read_only=True, source="issue") + + class Meta: + model = IssueRelation + fields = ["issue_detail", "relation_type", "related_issue", "issue", "id"] + read_only_fields = ["workspace", "project"] + + +class IssueCycleDetailSerializer(BaseSerializer): + cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") + + class Meta: + model = CycleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueModuleDetailSerializer(BaseSerializer): + module_detail = ModuleBaseSerializer(read_only=True, source="module") + + class Meta: + model = ModuleIssue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueLinkSerializer(BaseSerializer): + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + + class Meta: + model = IssueLink + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return IssueLink.objects.create(**validated_data) + + +class IssueAttachmentSerializer(BaseSerializer): + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + ] + + +class IssueReactionSerializer(BaseSerializer): + class Meta: + model = IssueReaction + fields = ["issue", "reaction", "workspace", "project", "actor"] + read_only_fields = ["workspace", "project", "issue", "actor"] + + +class IssueSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(read_only=True, source="project") + state_detail = StateSerializer(read_only=True, source="state") + parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") + label_details = LabelSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + related_issues = IssueRelationSerializer( + read_only=True, source="issue_relation", many=True + ) + issue_relations = RelatedIssueSerializer( + read_only=True, source="issue_related", many=True + ) + issue_cycle = IssueCycleDetailSerializer(read_only=True) + issue_module = IssueModuleDetailSerializer(read_only=True) + issue_link = IssueLinkSerializer(read_only=True, many=True) + issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueFlatSerializer(BaseSerializer): + ## Contain only flat fields + + class Meta: + model = Issue + fields = [ + "id", + "name", + "description", + "description_html", + "priority", + "start_date", + "target_date", + "sequence_id", + "sort_order", + "is_draft", + ] + + +class CommentReactionLiteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = CommentReaction + fields = ["id", "reaction", "comment", "actor_detail"] + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + is_member = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +##TODO: Find a better way to write this serializer +## Find a better approach to save manytomany? +class IssueCreateSerializer(BaseSerializer): + state_detail = StateSerializer(read_only=True, source="state") + created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + + assignees = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), + write_only=True, + required=False, + ) + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Issue + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + data["assignees"] = [str(assignee.id) for assignee in instance.assignees.all()] + data["labels"] = [str(label.id) for label in instance.labels.all()] + return data + + def validate(self, data): + if ( + data.get("start_date", None) is not None + and data.get("target_date", None) is not None + and data.get("start_date", None) > data.get("target_date", None) + ): + raise serializers.ValidationError("Start date cannot exceed target date") + + # Validate description content for security + if "description" in data and data["description"]: + is_valid, error_msg = validate_json_content(data["description"]) + if not is_valid: + raise serializers.ValidationError({"description": error_msg}) + + if "description_html" in data and data["description_html"]: + is_valid, error_msg = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"description_html": error_msg}) + + if "description_binary" in data and data["description_binary"]: + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": error_msg}) + + return data + + def create(self, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id + + if assignees is not None and len(assignees): + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + else: + # Then assign it to default assignee + if default_assignee_id is not None: + IssueAssignee.objects.create( + assignee_id=default_assignee_id, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + + if labels is not None and len(labels): + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=issue, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return issue + + def update(self, instance, validated_data): + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) + + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + + if assignees is not None: + IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee=user, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for user in assignees + ], + batch_size=10, + ) + + if labels is not None: + IssueLabel.objects.filter(issue=instance).delete() + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label=label, + issue=instance, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + # Time updation occues even when other related models are updated + instance.updated_at = timezone.now() + return super().update(instance, validated_data) + + +class CommentReactionSerializer(BaseSerializer): + class Meta: + model = CommentReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "comment", "actor"] + + +class IssueVoteSerializer(BaseSerializer): + class Meta: + model = IssueVote + fields = ["issue", "vote", "workspace", "project", "actor"] + read_only_fields = fields + + +class IssuePublicSerializer(BaseSerializer): + reactions = IssueReactionSerializer( + read_only=True, many=True, source="issue_reactions" + ) + votes = IssueVoteSerializer(read_only=True, many=True) + module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = Issue + fields = [ + "id", + "name", + "sequence_id", + "state", + "project", + "workspace", + "priority", + "target_date", + "reactions", + "votes", + "module_ids", + "created_by", + "label_ids", + "assignee_ids", + ] + read_only_fields = fields + + +class LabelLiteSerializer(BaseSerializer): + class Meta: + model = Label + fields = ["id", "name", "color"] diff --git a/apiserver/plane/space/serializer/module.py b/apps/api/plane/space/serializer/module.py similarity index 100% rename from apiserver/plane/space/serializer/module.py rename to apps/api/plane/space/serializer/module.py diff --git a/apiserver/plane/space/serializer/project.py b/apps/api/plane/space/serializer/project.py similarity index 100% rename from apiserver/plane/space/serializer/project.py rename to apps/api/plane/space/serializer/project.py diff --git a/apiserver/plane/space/serializer/state.py b/apps/api/plane/space/serializer/state.py similarity index 100% rename from apiserver/plane/space/serializer/state.py rename to apps/api/plane/space/serializer/state.py diff --git a/apiserver/plane/space/serializer/user.py b/apps/api/plane/space/serializer/user.py similarity index 100% rename from apiserver/plane/space/serializer/user.py rename to apps/api/plane/space/serializer/user.py diff --git a/apiserver/plane/space/serializer/workspace.py b/apps/api/plane/space/serializer/workspace.py similarity index 100% rename from apiserver/plane/space/serializer/workspace.py rename to apps/api/plane/space/serializer/workspace.py diff --git a/apiserver/plane/space/urls/__init__.py b/apps/api/plane/space/urls/__init__.py similarity index 100% rename from apiserver/plane/space/urls/__init__.py rename to apps/api/plane/space/urls/__init__.py diff --git a/apiserver/plane/space/urls/asset.py b/apps/api/plane/space/urls/asset.py similarity index 100% rename from apiserver/plane/space/urls/asset.py rename to apps/api/plane/space/urls/asset.py diff --git a/apiserver/plane/space/urls/intake.py b/apps/api/plane/space/urls/intake.py similarity index 100% rename from apiserver/plane/space/urls/intake.py rename to apps/api/plane/space/urls/intake.py diff --git a/apiserver/plane/space/urls/issue.py b/apps/api/plane/space/urls/issue.py similarity index 100% rename from apiserver/plane/space/urls/issue.py rename to apps/api/plane/space/urls/issue.py diff --git a/apiserver/plane/space/urls/project.py b/apps/api/plane/space/urls/project.py similarity index 100% rename from apiserver/plane/space/urls/project.py rename to apps/api/plane/space/urls/project.py diff --git a/apiserver/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py similarity index 100% rename from apiserver/plane/space/utils/grouper.py rename to apps/api/plane/space/utils/grouper.py diff --git a/apiserver/plane/space/views/__init__.py b/apps/api/plane/space/views/__init__.py similarity index 100% rename from apiserver/plane/space/views/__init__.py rename to apps/api/plane/space/views/__init__.py diff --git a/apiserver/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py similarity index 100% rename from apiserver/plane/space/views/asset.py rename to apps/api/plane/space/views/asset.py diff --git a/apiserver/plane/space/views/base.py b/apps/api/plane/space/views/base.py similarity index 100% rename from apiserver/plane/space/views/base.py rename to apps/api/plane/space/views/base.py diff --git a/apiserver/plane/space/views/cycle.py b/apps/api/plane/space/views/cycle.py similarity index 100% rename from apiserver/plane/space/views/cycle.py rename to apps/api/plane/space/views/cycle.py diff --git a/apiserver/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py similarity index 100% rename from apiserver/plane/space/views/intake.py rename to apps/api/plane/space/views/intake.py diff --git a/apiserver/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py similarity index 100% rename from apiserver/plane/space/views/issue.py rename to apps/api/plane/space/views/issue.py diff --git a/apiserver/plane/space/views/label.py b/apps/api/plane/space/views/label.py similarity index 100% rename from apiserver/plane/space/views/label.py rename to apps/api/plane/space/views/label.py diff --git a/apiserver/plane/space/views/meta.py b/apps/api/plane/space/views/meta.py similarity index 100% rename from apiserver/plane/space/views/meta.py rename to apps/api/plane/space/views/meta.py diff --git a/apiserver/plane/space/views/module.py b/apps/api/plane/space/views/module.py similarity index 100% rename from apiserver/plane/space/views/module.py rename to apps/api/plane/space/views/module.py diff --git a/apiserver/plane/space/views/project.py b/apps/api/plane/space/views/project.py similarity index 100% rename from apiserver/plane/space/views/project.py rename to apps/api/plane/space/views/project.py diff --git a/apiserver/plane/space/views/state.py b/apps/api/plane/space/views/state.py similarity index 100% rename from apiserver/plane/space/views/state.py rename to apps/api/plane/space/views/state.py diff --git a/apiserver/plane/static/css/style.css b/apps/api/plane/static/css/style.css similarity index 100% rename from apiserver/plane/static/css/style.css rename to apps/api/plane/static/css/style.css diff --git a/apiserver/plane/static/humans.txt b/apps/api/plane/static/humans.txt similarity index 100% rename from apiserver/plane/static/humans.txt rename to apps/api/plane/static/humans.txt diff --git a/apiserver/plane/static/js/script.js b/apps/api/plane/static/js/script.js similarity index 100% rename from apiserver/plane/static/js/script.js rename to apps/api/plane/static/js/script.js diff --git a/apiserver/plane/tests/README.md b/apps/api/plane/tests/README.md similarity index 100% rename from apiserver/plane/tests/README.md rename to apps/api/plane/tests/README.md diff --git a/apiserver/plane/tests/TESTING_GUIDE.md b/apps/api/plane/tests/TESTING_GUIDE.md similarity index 100% rename from apiserver/plane/tests/TESTING_GUIDE.md rename to apps/api/plane/tests/TESTING_GUIDE.md diff --git a/apiserver/plane/tests/__init__.py b/apps/api/plane/tests/__init__.py similarity index 100% rename from apiserver/plane/tests/__init__.py rename to apps/api/plane/tests/__init__.py diff --git a/apiserver/plane/tests/apps.py b/apps/api/plane/tests/apps.py similarity index 100% rename from apiserver/plane/tests/apps.py rename to apps/api/plane/tests/apps.py diff --git a/apiserver/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py similarity index 84% rename from apiserver/plane/tests/conftest.py rename to apps/api/plane/tests/conftest.py index 8325588104b..b70c9352a30 100644 --- a/apiserver/plane/tests/conftest.py +++ b/apps/api/plane/tests/conftest.py @@ -4,7 +4,7 @@ from pytest_django.fixtures import django_db_setup from unittest.mock import patch, MagicMock -from plane.db.models import User +from plane.db.models import User, Workspace, WorkspaceMember from plane.db.models.api import APIToken @@ -118,3 +118,23 @@ def plane_server(live_server): Returns a live Django server for testing HTTP requests. """ return live_server + + +@pytest.fixture +def workspace(create_user): + """ + Create a new workspace and return the + corresponding Workspace model instance. + """ + # Create the workspace using the model + created_workspace = Workspace.objects.create( + name="Test Workspace", + owner=create_user, + slug="test-workspace", + ) + + WorkspaceMember.objects.create( + workspace=created_workspace, member=create_user, role=20 + ) + + return created_workspace diff --git a/apiserver/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py similarity index 81% rename from apiserver/plane/tests/conftest_external.py rename to apps/api/plane/tests/conftest_external.py index d2d6a2df51e..50022b49063 100644 --- a/apiserver/plane/tests/conftest_external.py +++ b/apps/api/plane/tests/conftest_external.py @@ -21,7 +21,7 @@ def mock_redis(): mock_redis_client.ttl.return_value = -1 # Start the patch - with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client): + with patch("plane.settings.redis.redis_instance", return_value=mock_redis_client): yield mock_redis_client @@ -44,7 +44,7 @@ def mock_elasticsearch(): mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"} # Start the patch - with patch('elasticsearch.Elasticsearch', return_value=mock_es_client): + with patch("elasticsearch.Elasticsearch", return_value=mock_es_client): yield mock_es_client @@ -68,39 +68,30 @@ def mock_mongodb(): # Configure common MongoDB collection operations mock_mongo_collection.find_one.return_value = None mock_mongo_collection.find.return_value = MagicMock( - __iter__=lambda x: iter([]), - count=lambda: 0 + __iter__=lambda x: iter([]), count=lambda: 0 ) mock_mongo_collection.insert_one.return_value = MagicMock( - inserted_id="mock_id_123", - acknowledged=True + inserted_id="mock_id_123", acknowledged=True ) mock_mongo_collection.insert_many.return_value = MagicMock( - inserted_ids=["mock_id_123", "mock_id_456"], - acknowledged=True + inserted_ids=["mock_id_123", "mock_id_456"], acknowledged=True ) mock_mongo_collection.update_one.return_value = MagicMock( - modified_count=1, - matched_count=1, - acknowledged=True + modified_count=1, matched_count=1, acknowledged=True ) mock_mongo_collection.update_many.return_value = MagicMock( - modified_count=2, - matched_count=2, - acknowledged=True + modified_count=2, matched_count=2, acknowledged=True ) mock_mongo_collection.delete_one.return_value = MagicMock( - deleted_count=1, - acknowledged=True + deleted_count=1, acknowledged=True ) mock_mongo_collection.delete_many.return_value = MagicMock( - deleted_count=2, - acknowledged=True + deleted_count=2, acknowledged=True ) mock_mongo_collection.count_documents.return_value = 0 # Start the patch - with patch('pymongo.MongoClient', return_value=mock_mongo_client): + with patch("pymongo.MongoClient", return_value=mock_mongo_client): yield mock_mongo_client @@ -112,6 +103,6 @@ def mock_celery(): This fixture patches Celery's task.delay() to prevent actual task execution. """ # Start the patch - with patch('celery.app.task.Task.delay') as mock_delay: + with patch("celery.app.task.Task.delay") as mock_delay: mock_delay.return_value = MagicMock(id="mock-task-id") - yield mock_delay \ No newline at end of file + yield mock_delay diff --git a/apiserver/plane/tests/contract/__init__.py b/apps/api/plane/tests/contract/__init__.py similarity index 100% rename from apiserver/plane/tests/contract/__init__.py rename to apps/api/plane/tests/contract/__init__.py diff --git a/apiserver/plane/tests/contract/api/__init__.py b/apps/api/plane/tests/contract/api/__init__.py similarity index 100% rename from apiserver/plane/tests/contract/api/__init__.py rename to apps/api/plane/tests/contract/api/__init__.py diff --git a/apiserver/plane/tests/smoke/__init__.py b/apps/api/plane/tests/contract/app/__init__.py similarity index 100% rename from apiserver/plane/tests/smoke/__init__.py rename to apps/api/plane/tests/contract/app/__init__.py diff --git a/apiserver/plane/tests/contract/app/test_api_token.py b/apps/api/plane/tests/contract/app/test_api_token.py similarity index 100% rename from apiserver/plane/tests/contract/app/test_api_token.py rename to apps/api/plane/tests/contract/app/test_api_token.py diff --git a/apiserver/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py similarity index 88% rename from apiserver/plane/tests/contract/app/test_authentication.py rename to apps/api/plane/tests/contract/app/test_authentication.py index 0dc54871046..a52882b9d22 100644 --- a/apiserver/plane/tests/contract/app/test_authentication.py +++ b/apps/api/plane/tests/contract/app/test_authentication.py @@ -16,7 +16,9 @@ @pytest.fixture def setup_instance(db): """Create and configure an instance for authentication tests""" - instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id + instance_id = ( + uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id + ) # Create or update instance with all required fields instance, _ = Instance.objects.update_or_create( @@ -28,7 +30,7 @@ def setup_instance(db): "domain": "http://localhost:8000", "last_checked_at": timezone.now(), "is_setup_done": True, - } + }, ) return instance @@ -36,7 +38,9 @@ def setup_instance(db): @pytest.fixture def django_client(): """Return a Django test client with User-Agent header for handling redirects""" - client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") + client = Client( + HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1" + ) return client @@ -79,7 +83,9 @@ def test_email_validity(self, api_client, setup_user, setup_instance): @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance): + def test_magic_generate( + self, mock_magic_link, api_client, setup_user, setup_instance + ): """Test successful magic link generation""" url = reverse("magic-generate") @@ -97,7 +103,9 @@ def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_ins @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance): + def test_max_generate_attempt( + self, mock_magic_link, api_client, setup_user, setup_instance + ): """Test exceeding maximum magic link generation attempts""" url = reverse("magic-generate") @@ -163,10 +171,9 @@ def test_password_validity(self, django_client, setup_user, setup_instance): url, {"email": "user@plane.so", "password": "user123"}, follow=True ) - # Check for the specific authentication error in the URL redirect_urls = [url for url, _ in response.redirect_chain] - redirect_contents = ' '.join(redirect_urls) + redirect_contents = " ".join(redirect_urls) # The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents @@ -201,14 +208,13 @@ def test_next_path_redirection(self, django_client, setup_user, setup_instance): response = django_client.post( url, {"email": "user@plane.so", "password": "user@123", "next_path": next_path}, - follow=False + follow=False, ) # Check that the initial response is a redirect (302) without error code assert response.status_code == 302 assert "error_code" not in response.url - # In a real browser, the next_path would be used to build the absolute URL # Since we're just testing the authentication logic, we won't check for the exact URL structure # Instead, just verify that we're authenticated @@ -237,16 +243,16 @@ def test_without_data(self, django_client, setup_user, setup_instance): assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] @pytest.mark.django_db - def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance): + def test_expired_invalid_magic_link( + self, django_client, setup_user, setup_instance + ): """Test magic link sign-in with expired/invalid link""" ri = redis_instance() ri.delete("magic_user@plane.so") url = reverse("magic-sign-in") response = django_client.post( - url, - {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, - follow=False + url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False ) # Check that we get a redirect @@ -254,7 +260,10 @@ def test_expired_invalid_magic_link(self, django_client, setup_user, setup_insta # The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist) # or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match) - assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url + assert ( + "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url + or "INVALID_MAGIC_CODE_SIGN_IN" in response.url + ) @pytest.mark.django_db def test_user_does_not_exist(self, django_client, setup_instance): @@ -263,7 +272,7 @@ def test_user_does_not_exist(self, django_client, setup_instance): response = django_client.post( url, {"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"}, - follow=True + follow=True, ) # Check redirect contains error code @@ -271,7 +280,9 @@ def test_user_does_not_exist(self, django_client, setup_instance): @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + def test_magic_code_sign_in( + self, mock_magic_link, django_client, api_client, setup_user, setup_instance + ): """Test successful magic link sign-in process""" # First generate a magic link token gen_url = reverse("magic-generate") @@ -288,9 +299,7 @@ def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, se # Use Django client to test the redirect flow without following redirects url = reverse("magic-sign-in") response = django_client.post( - url, - {"email": "user@plane.so", "code": token}, - follow=False + url, {"email": "user@plane.so", "code": token}, follow=False ) # Check that the initial response is a redirect without error code @@ -302,7 +311,9 @@ def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, se @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + def test_magic_sign_in_with_next_path( + self, mock_magic_link, django_client, api_client, setup_user, setup_instance + ): """Test magic sign-in with next_path parameter""" # First generate a magic link token gen_url = reverse("magic-generate") @@ -322,7 +333,7 @@ def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_ response = django_client.post( url, {"email": "user@plane.so", "code": token, "next_path": next_path}, - follow=False + follow=False, ) # Check that the initial response is a redirect without error code @@ -357,9 +368,7 @@ def test_user_already_exists(self, django_client, db, setup_instance): url = reverse("magic-sign-up") response = django_client.post( - url, - {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, - follow=True + url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True ) # Check redirect contains error code @@ -370,9 +379,7 @@ def test_expired_invalid_magic_link(self, django_client, setup_instance): """Test magic link sign-up with expired/invalid link""" url = reverse("magic-sign-up") response = django_client.post( - url, - {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, - follow=False + url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False ) # Check that we get a redirect @@ -380,11 +387,16 @@ def test_expired_invalid_magic_link(self, django_client, setup_instance): # The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist) # or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match) - assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url + assert ( + "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url + or "INVALID_MAGIC_CODE_SIGN_UP" in response.url + ) @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance): + def test_magic_code_sign_up( + self, mock_magic_link, django_client, api_client, setup_instance + ): """Test successful magic link sign-up process""" email = "newuser@plane.so" @@ -403,9 +415,7 @@ def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, se # Use Django client to test the redirect flow without following redirects url = reverse("magic-sign-up") response = django_client.post( - url, - {"email": email, "code": token}, - follow=False + url, {"email": email, "code": token}, follow=False ) # Check that the initial response is a redirect without error code @@ -420,7 +430,9 @@ def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, se @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance): + def test_magic_sign_up_with_next_path( + self, mock_magic_link, django_client, api_client, setup_instance + ): """Test magic sign-up with next_path parameter""" email = "newuser2@plane.so" @@ -440,9 +452,7 @@ def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_ url = reverse("magic-sign-up") next_path = "onboarding" response = django_client.post( - url, - {"email": email, "code": token, "next_path": next_path}, - follow=False + url, {"email": email, "code": token, "next_path": next_path}, follow=False ) # Check that the initial response is a redirect without error code @@ -456,4 +466,4 @@ def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_ assert User.objects.filter(email=email).exists() # Check if user is authenticated - assert "_auth_user_id" in django_client.session \ No newline at end of file + assert "_auth_user_id" in django_client.session diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py new file mode 100644 index 00000000000..78bcd7aeace --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -0,0 +1,618 @@ +import pytest +from rest_framework import status +import uuid +from django.utils import timezone + +from plane.db.models import ( + Project, + ProjectMember, + IssueUserProperty, + State, + WorkspaceMember, + User, +) + + +class TestProjectBase: + def get_project_url( + self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False + ) -> str: + """ + Constructs the project endpoint URL for the given workspace as reverse() is + unreliable due to duplicate 'name' values in URL patterns ('api' and 'app'). + + Args: + workspace_slug (str): The slug of the workspace. + pk (uuid.UUID, optional): The primary key of a specific project. + details (bool, optional): If True, constructs the URL for the + project details endpoint. Defaults to False. + """ + # Establish the common base URL for all project-related endpoints. + base_url = f"/api/workspaces/{workspace_slug}/projects/" + + # Specific project instance URL. + if pk: + return f"{base_url}{pk}/" + + # Append 'details/' to the base URL. + if details: + return f"{base_url}details/" + + # Return the base project list URL. + return base_url + + +@pytest.mark.contract +class TestProjectAPIPost(TestProjectBase): + """Test project POST operations""" + + @pytest.mark.django_db + def test_create_project_empty_data(self, session_client, workspace): + """Test creating a project with empty data""" + + url = self.get_project_url(workspace.slug) + + # Test with empty data + response = session_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_valid_data(self, session_client, workspace, create_user): + url = self.get_project_url(workspace.slug) + + project_data = { + "name": "New Project Test", + "identifier": "NPT", + } + + user = create_user + + # Make the request + response = session_client.post(url, project_data, format="json") + + # Check response status + assert response.status_code == status.HTTP_201_CREATED + + # Verify project was created + assert Project.objects.count() == 1 + project = Project.objects.get(name=project_data["name"]) + assert project.workspace == workspace + + # Check if the member is created with the correct role + assert ProjectMember.objects.count() == 1 + project_member = ProjectMember.objects.filter( + project=project, member=user + ).first() + assert project_member.role == 20 # Administrator + assert project_member.is_active is True + + # Verify IssueUserProperty was created + assert IssueUserProperty.objects.filter(project=project, user=user).exists() + + # Verify default states were created + states = State.objects.filter(project=project) + assert states.count() == 5 + expected_states = ["Backlog", "Todo", "In Progress", "Done", "Cancelled"] + state_names = list(states.values_list("name", flat=True)) + assert set(state_names) == set(expected_states) + + @pytest.mark.django_db + def test_create_project_with_project_lead( + self, session_client, workspace, create_user + ): + """Test creating project with a different project lead""" + # Create another user to be project lead + project_lead = User.objects.create_user( + email="lead@example.com", username="projectlead" + ) + + # Add project lead to workspace + WorkspaceMember.objects.create( + workspace=workspace, member=project_lead, role=15 + ) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Project with Lead", + "identifier": "PWL", + "project_lead": project_lead.id, + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + # Verify both creator and project lead are administrators + project = Project.objects.get(name=project_data["name"]) + assert ProjectMember.objects.filter(project=project, role=20).count() == 2 + + # Verify both have IssueUserProperty + assert IssueUserProperty.objects.filter(project=project).count() == 2 + + @pytest.mark.django_db + def test_create_project_guest_forbidden(self, session_client, workspace): + """Test that guests cannot create projects""" + guest_user = User.objects.create_user( + email="guest@example.com", username="guest" + ) + WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5) + + session_client.force_authenticate(user=guest_user) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Guest Project", + "identifier": "GP", + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Project.objects.count() == 0 + + @pytest.mark.django_db + def test_create_project_unauthenticated(self, client, workspace): + """Test unauthenticated access""" + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Unauth Project", + "identifier": "UP", + } + + response = client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.django_db + def test_create_project_duplicate_name( + self, session_client, workspace, create_user + ): + """Test creating project with duplicate name""" + # Create first project + Project.objects.create( + name="Duplicate Name", identifier="DN1", workspace=workspace + ) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Duplicate Name", + "identifier": "DN2", + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_duplicate_identifier( + self, session_client, workspace, create_user + ): + """Test creating project with duplicate identifier""" + Project.objects.create( + name="First Project", identifier="DUP", workspace=workspace + ) + + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Second Project", + "identifier": "DUP", + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_missing_required_fields( + self, session_client, workspace, create_user + ): + """Test validation with missing required fields""" + url = self.get_project_url(workspace.slug) + + # Test missing name + response = session_client.post(url, {"identifier": "MN"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Test missing identifier + response = session_client.post( + url, {"name": "Missing Identifier"}, format="json" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_project_with_all_optional_fields( + self, session_client, workspace, create_user + ): + """Test creating project with all optional fields""" + url = self.get_project_url(workspace.slug) + project_data = { + "name": "Full Project", + "identifier": "FP", + "description": "A comprehensive test project", + "network": 2, + "cycle_view": True, + "issue_views_view": False, + "module_view": True, + "page_view": False, + "inbox_view": True, + "guest_view_all_features": True, + "logo_props": { + "in_use": "emoji", + "emoji": {"value": "🚀", "unicode": "1f680"}, + }, + } + + response = session_client.post(url, project_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + response_data = response.json() + assert response_data["description"] == project_data["description"] + assert response_data["network"] == project_data["network"] + + +@pytest.mark.contract +class TestProjectAPIGet(TestProjectBase): + """Test project GET operations""" + + @pytest.mark.django_db + def test_list_projects_authenticated_admin( + self, session_client, workspace, create_user + ): + """Test listing projects as workspace admin""" + # Create a project + project = Project.objects.create( + name="Test Project", identifier="TP", workspace=workspace + ) + + # Add user as project member + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Test Project" + assert data[0]["identifier"] == "TP" + + @pytest.mark.django_db + def test_list_projects_authenticated_guest(self, session_client, workspace): + """Test listing projects as workspace guest""" + # Create a guest user + guest_user = User.objects.create_user( + email="guest@example.com", username="guest" + ) + WorkspaceMember.objects.create( + workspace=workspace, member=guest_user, role=5, is_active=True + ) + + # Create projects + project1 = Project.objects.create( + name="Project 1", identifier="P1", workspace=workspace + ) + + Project.objects.create(name="Project 2", identifier="P2", workspace=workspace) + + # Add guest to only one project + ProjectMember.objects.create( + project=project1, member=guest_user, role=10, is_active=True + ) + + session_client.force_authenticate(user=guest_user) + + url = self.get_project_url(workspace.slug) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + # Guest should only see projects they're members of + assert len(data) == 1 + assert data[0]["name"] == "Project 1" + + @pytest.mark.django_db + def test_list_projects_unauthenticated(self, client, workspace): + """Test listing projects without authentication""" + url = self.get_project_url(workspace.slug) + response = client.get(url) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.django_db + def test_list_detail_projects(self, session_client, workspace, create_user): + """Test listing projects with detailed information""" + # Create a project + project = Project.objects.create( + name="Detailed Project", + identifier="DP", + workspace=workspace, + description="A detailed test project", + ) + + # Add user as project member + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, details=True) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Detailed Project" + assert data[0]["description"] == "A detailed test project" + + @pytest.mark.django_db + def test_retrieve_project_success(self, session_client, workspace, create_user): + """Test retrieving a specific project""" + # Create a project + project = Project.objects.create( + name="Retrieve Test Project", + identifier="RTP", + workspace=workspace, + description="Test project for retrieval", + ) + + # Add user as project member + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Retrieve Test Project" + assert data["identifier"] == "RTP" + assert data["description"] == "Test project for retrieval" + + @pytest.mark.django_db + def test_retrieve_project_not_found(self, session_client, workspace, create_user): + """Test retrieving a non-existent project""" + fake_uuid = uuid.uuid4() + url = self.get_project_url(workspace.slug, pk=fake_uuid) + response = session_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_retrieve_archived_project(self, session_client, workspace, create_user): + """Test retrieving an archived project""" + # Create an archived project + project = Project.objects.create( + name="Archived Project", + identifier="AP", + workspace=workspace, + archived_at=timezone.now(), + ) + + # Add user as project member + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.contract +class TestProjectAPIPatchDelete(TestProjectBase): + """Test project PATCH, and DELETE operations""" + + @pytest.mark.django_db + def test_partial_update_project_success( + self, session_client, workspace, create_user + ): + """Test successful partial update of project""" + # Create a project + project = Project.objects.create( + name="Original Project", + identifier="OP", + workspace=workspace, + description="Original description", + ) + + # Add user as project administrator + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project.id) + update_data = { + "name": "Updated Project", + "description": "Updated description", + "cycle_view": True, + "module_view": False, + } + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_200_OK + + # Verify project was updated + project.refresh_from_db() + assert project.name == "Updated Project" + assert project.description == "Updated description" + assert project.cycle_view is True + assert project.module_view is False + + @pytest.mark.django_db + def test_partial_update_project_forbidden_non_admin( + self, session_client, workspace + ): + """Test that non-admin project members cannot update project""" + # Create a project + project = Project.objects.create( + name="Protected Project", identifier="PP", workspace=workspace + ) + + # Create a member user (not admin) + member_user = User.objects.create_user( + email="member@example.com", username="member" + ) + WorkspaceMember.objects.create( + workspace=workspace, member=member_user, role=15, is_active=True + ) + ProjectMember.objects.create( + project=project, member=member_user, role=15, is_active=True + ) + + session_client.force_authenticate(user=member_user) + + url = self.get_project_url(workspace.slug, pk=project.id) + update_data = {"name": "Hacked Project"} + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_partial_update_duplicate_name_conflict( + self, session_client, workspace, create_user + ): + """Test updating project with duplicate name returns conflict""" + # Create two projects + Project.objects.create(name="Project One", identifier="P1", workspace=workspace) + project2 = Project.objects.create( + name="Project Two", identifier="P2", workspace=workspace + ) + + ProjectMember.objects.create( + project=project2, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project2.id) + update_data = {"name": "Project One"} # Duplicate name + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_partial_update_duplicate_identifier_conflict( + self, session_client, workspace, create_user + ): + """Test updating project with duplicate identifier returns conflict""" + # Create two projects + Project.objects.create(name="Project One", identifier="P1", workspace=workspace) + project2 = Project.objects.create( + name="Project Two", identifier="P2", workspace=workspace + ) + + ProjectMember.objects.create( + project=project2, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project2.id) + update_data = {"identifier": "P1"} # Duplicate identifier + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_partial_update_invalid_data(self, session_client, workspace, create_user): + """Test partial update with invalid data""" + project = Project.objects.create( + name="Valid Project", identifier="VP", workspace=workspace + ) + + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project.id) + update_data = {"name": ""} + + response = session_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_delete_project_success_project_admin( + self, session_client, workspace, create_user + ): + """Test successful project deletion by project admin""" + project = Project.objects.create( + name="Delete Me", identifier="DM", workspace=workspace + ) + + ProjectMember.objects.create( + project=project, member=create_user, role=20, is_active=True + ) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Project.objects.filter(id=project.id).exists() + + @pytest.mark.django_db + def test_delete_project_success_workspace_admin(self, session_client, workspace): + """Test successful project deletion by workspace admin""" + # Create workspace admin user + workspace_admin = User.objects.create_user( + email="admin@example.com", username="admin" + ) + WorkspaceMember.objects.create( + workspace=workspace, member=workspace_admin, role=20, is_active=True + ) + + project = Project.objects.create( + name="Delete Me", identifier="DM", workspace=workspace + ) + + session_client.force_authenticate(user=workspace_admin) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Project.objects.filter(id=project.id).exists() + + @pytest.mark.django_db + def test_delete_project_forbidden_non_admin(self, session_client, workspace): + """Test that non-admin users cannot delete projects""" + # Create a member user (not admin) + member_user = User.objects.create_user( + email="member@example.com", username="member" + ) + WorkspaceMember.objects.create( + workspace=workspace, member=member_user, role=15, is_active=True + ) + + project = Project.objects.create( + name="Protected Project", identifier="PP", workspace=workspace + ) + + ProjectMember.objects.create( + project=project, member=member_user, role=15, is_active=True + ) + + session_client.force_authenticate(user=member_user) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = session_client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Project.objects.filter(id=project.id).exists() + + @pytest.mark.django_db + def test_delete_project_unauthenticated(self, client, workspace): + """Test unauthenticated project deletion""" + project = Project.objects.create( + name="Protected Project", identifier="PP", workspace=workspace + ) + + url = self.get_project_url(workspace.slug, pk=project.id) + response = client.delete(url) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert Project.objects.filter(id=project.id).exists() diff --git a/apiserver/plane/tests/contract/app/test_workspace_app.py b/apps/api/plane/tests/contract/app/test_workspace_app.py similarity index 87% rename from apiserver/plane/tests/contract/app/test_workspace_app.py rename to apps/api/plane/tests/contract/app/test_workspace_app.py index 71ad1d41243..9d4c560e514 100644 --- a/apiserver/plane/tests/contract/app/test_workspace_app.py +++ b/apps/api/plane/tests/contract/app/test_workspace_app.py @@ -21,7 +21,9 @@ def test_create_workspace_empty_data(self, session_client): @pytest.mark.django_db @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") - def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user): + def test_create_workspace_valid_data( + self, mock_workspace_seed, session_client, create_user + ): """Test creating a workspace with valid data""" url = reverse("workspace") user = create_user # Use the create_user fixture directly as it returns a user object @@ -30,7 +32,7 @@ def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, workspace_data = { "name": "Plane", "slug": "pla-ne-test", - "company_name": "Plane Inc." + "company_name": "Plane Inc.", } # Make the request @@ -57,15 +59,13 @@ def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, mock_workspace_seed.assert_called_once_with(response.data["id"]) @pytest.mark.django_db - @patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay') + @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") def test_create_duplicate_workspace(self, mock_workspace_seed, session_client): """Test creating a duplicate workspace""" url = reverse("workspace") # Create first workspace - session_client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) + session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json") # Try to create a workspace with the same slug response = session_client.post( @@ -76,4 +76,4 @@ def test_create_duplicate_workspace(self, mock_workspace_seed, session_client): assert response.status_code == status.HTTP_400_BAD_REQUEST # Optionally check the error message to confirm it's related to the duplicate slug - assert "slug" in response.data \ No newline at end of file + assert "slug" in response.data diff --git a/apps/api/plane/tests/factories.py b/apps/api/plane/tests/factories.py new file mode 100644 index 00000000000..b8cd78361ab --- /dev/null +++ b/apps/api/plane/tests/factories.py @@ -0,0 +1,81 @@ +import factory +from uuid import uuid4 +from django.utils import timezone + +from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember + + +class UserFactory(factory.django.DjangoModelFactory): + """Factory for creating User instances""" + + class Meta: + model = User + django_get_or_create = ("email",) + + id = factory.LazyFunction(uuid4) + email = factory.Sequence(lambda n: f"user{n}@plane.so") + password = factory.PostGenerationMethodCall("set_password", "password") + first_name = factory.Sequence(lambda n: f"First{n}") + last_name = factory.Sequence(lambda n: f"Last{n}") + is_active = True + is_superuser = False + is_staff = False + + +class WorkspaceFactory(factory.django.DjangoModelFactory): + """Factory for creating Workspace instances""" + + class Meta: + model = Workspace + django_get_or_create = ("slug",) + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f"Workspace {n}") + slug = factory.Sequence(lambda n: f"workspace-{n}") + owner = factory.SubFactory(UserFactory) + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class WorkspaceMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating WorkspaceMember instances""" + + class Meta: + model = WorkspaceMember + + id = factory.LazyFunction(uuid4) + workspace = factory.SubFactory(WorkspaceFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectFactory(factory.django.DjangoModelFactory): + """Factory for creating Project instances""" + + class Meta: + model = Project + django_get_or_create = ("name", "workspace") + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f"Project {n}") + workspace = factory.SubFactory(WorkspaceFactory) + created_by = factory.SelfAttribute("workspace.owner") + updated_by = factory.SelfAttribute("workspace.owner") + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating ProjectMember instances""" + + class Meta: + model = ProjectMember + + id = factory.LazyFunction(uuid4) + project = factory.SubFactory(ProjectFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) diff --git a/apiserver/plane/tests/unit/__init__.py b/apps/api/plane/tests/smoke/__init__.py similarity index 100% rename from apiserver/plane/tests/unit/__init__.py rename to apps/api/plane/tests/smoke/__init__.py diff --git a/apps/api/plane/tests/smoke/test_auth_smoke.py b/apps/api/plane/tests/smoke/test_auth_smoke.py new file mode 100644 index 00000000000..85ca476b4d3 --- /dev/null +++ b/apps/api/plane/tests/smoke/test_auth_smoke.py @@ -0,0 +1,107 @@ +import pytest +import requests +from django.urls import reverse + + +@pytest.mark.smoke +class TestAuthSmoke: + """Smoke tests for authentication endpoints""" + + @pytest.mark.django_db + def test_login_endpoint_available(self, plane_server, create_user, user_data): + """Test that the login endpoint is available and responds correctly""" + # Get the sign-in URL + relative_url = reverse("sign-in") + url = f"{plane_server.url}{relative_url}" + + # 1. Test bad login - test with wrong password + response = requests.post( + url, data={"email": user_data["email"], "password": "wrong-password"} + ) + + # For bad credentials, any of these status codes would be valid + # The test shouldn't be brittle to minor implementation changes + assert response.status_code != 500, ( + "Authentication should not cause server errors" + ) + assert response.status_code != 404, "Authentication endpoint should exist" + + if response.status_code == 200: + # If API returns 200 for failures, check the response body for error indication + if hasattr(response, "json"): + try: + data = response.json() + # JSON response might indicate error in its structure + assert ( + "error" in data + or "error_code" in data + or "detail" in data + or response.url.endswith("sign-in") + ), "Error response should contain error details" + except ValueError: + # It's ok if response isn't JSON format + pass + elif response.status_code in [302, 303]: + # If it's a redirect, it should redirect to a login page or error page + redirect_url = response.headers.get("Location", "") + assert "error" in redirect_url or "sign-in" in redirect_url, ( + "Failed login should redirect to login page or error page" + ) + + # 2. Test good login with correct credentials + response = requests.post( + url, + data={"email": user_data["email"], "password": user_data["password"]}, + allow_redirects=False, # Don't follow redirects + ) + + # Successful auth should not be a client error or server error + assert response.status_code not in range(400, 600), ( + f"Authentication with valid credentials failed with status {response.status_code}" + ) + + # Specific validation based on response type + if response.status_code in [302, 303]: + # Redirect-based auth: check that redirect URL doesn't contain error + redirect_url = response.headers.get("Location", "") + assert "error" not in redirect_url and "error_code" not in redirect_url, ( + "Successful login redirect should not contain error parameters" + ) + + elif response.status_code == 200: + # API token-based auth: check for tokens or user session + if hasattr(response, "json"): + try: + data = response.json() + # If it's a token response + if "access_token" in data: + assert "refresh_token" in data, ( + "JWT auth should return both access and refresh tokens" + ) + # If it's a user session response + elif "user" in data: + assert ( + "is_authenticated" in data and data["is_authenticated"] + ), "User session response should indicate authentication" + # Otherwise it should at least indicate success + else: + assert not any( + error_key in data + for error_key in ["error", "error_code", "detail"] + ), "Success response should not contain error keys" + except ValueError: + # Non-JSON is acceptable if it's a redirect or HTML response + pass + + +@pytest.mark.smoke +class TestHealthCheckSmoke: + """Smoke test for health check endpoint""" + + def test_healthcheck_endpoint(self, plane_server): + """Test that the health check endpoint is available and responds correctly""" + # Make a request to the health check endpoint + response = requests.get(f"{plane_server.url}/") + + # Should be OK + assert response.status_code == 200, "Health check endpoint should return 200 OK" diff --git a/apiserver/plane/tests/unit/models/__init__.py b/apps/api/plane/tests/unit/__init__.py similarity index 100% rename from apiserver/plane/tests/unit/models/__init__.py rename to apps/api/plane/tests/unit/__init__.py diff --git a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py new file mode 100644 index 00000000000..bbb98e6b1c4 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py @@ -0,0 +1,182 @@ +import pytest +from plane.db.models import Project, ProjectMember, Issue, FileAsset +from unittest.mock import patch, MagicMock +from plane.bgtasks.copy_s3_object import ( + copy_s3_objects_of_description_and_assets, + copy_assets, +) +import base64 + + +@pytest.mark.unit +class TestCopyS3Objects: + """Test the copy_s3_objects_of_description_and_assets function""" + + @pytest.fixture + def project(self, create_user, workspace): + project = Project.objects.create( + name="Test Project", identifier="test-project", workspace=workspace + ) + + ProjectMember.objects.create(project=project, member=create_user) + return project + + @pytest.fixture + def issue(self, workspace, project): + return Issue.objects.create( + name="Test Issue", + workspace=workspace, + project_id=project.id, + description_html=f'
', + ) + + @pytest.fixture + def file_asset(self, workspace, project, issue): + return FileAsset.objects.create( + issue=issue, + workspace=workspace, + project=project, + asset="workspace1/test-asset-1.jpg", + attributes={ + "name": "test-asset-1.jpg", + "size": 100, + "type": "image/jpeg", + }, + id="35e8b958-6ee5-43ce-ae56-fb0e776f421e", + entity_type="ISSUE_DESCRIPTION", + ) + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_s3_objects_of_description_and_assets( + self, mock_s3_storage, create_user, workspace, project, issue, file_asset + ): + FileAsset.objects.create( + issue=issue, + workspace=workspace, + project=project, + asset="workspace1/test-asset-2.pdf", + attributes={ + "name": "test-asset-2.pdf", + "size": 100, + "type": "application/pdf", + }, + id="97988198-274f-4dfe-aa7a-4c0ffc684214", + entity_type="ISSUE_DESCRIPTION", + ) + + issue.save() + + # Set up mock S3 storage + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + + # Mock the external service call to avoid actual HTTP requests + with patch( + "plane.bgtasks.copy_s3_object.sync_with_external_service" + ) as mock_sync: + mock_sync.return_value = { + "description": "test description", + "description_binary": base64.b64encode(b"test binary").decode(), + } + + # Call the actual function (not .delay()) + copy_s3_objects_of_description_and_assets( + "ISSUE", issue.id, project.id, "test-workspace", create_user.id + ) + + # Assert that copy_object was called for each asset + assert mock_storage_instance.copy_object.call_count == 2 + + # Get the updated issue and its new assets + updated_issue = Issue.objects.get(id=issue.id) + new_assets = FileAsset.objects.filter( + issue=updated_issue, + entity_type="ISSUE_DESCRIPTION", + ) + + # Verify new assets were created + assert new_assets.count() == 4 # 2 original + 2 copied + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_assets_successful( + self, mock_s3_storage, workspace, project, issue, file_asset + ): + """Test successful copying of assets""" + # Arrange + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + + # Act + result = copy_assets( + entity=issue, + entity_identifier=issue.id, + project_id=project.id, + asset_ids=[file_asset.id], + user_id=issue.created_by_id, + ) + + # Assert + # Verify S3 copy was called + mock_storage_instance.copy_object.assert_called_once() + + # Verify new asset was created + assert len(result) == 1 + new_asset_id = result[0]["new_asset_id"] + new_asset = FileAsset.objects.get(id=new_asset_id) + + # Verify asset properties were copied correctly + assert new_asset.workspace == workspace + assert new_asset.project_id == project.id + assert new_asset.entity_type == file_asset.entity_type + assert new_asset.attributes == file_asset.attributes + assert new_asset.size == file_asset.size + assert new_asset.is_uploaded is True + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_assets_empty_asset_ids( + self, mock_s3_storage, workspace, project, issue + ): + """Test copying with empty asset_ids list""" + # Arrange + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + + # Act + result = copy_assets( + entity=issue, + entity_identifier=issue.id, + project_id=project.id, + asset_ids=[], + user_id=issue.created_by_id, + ) + + # Assert + assert result == [] + mock_storage_instance.copy_object.assert_not_called() + + @pytest.mark.django_db + @patch("plane.bgtasks.copy_s3_object.S3Storage") + def test_copy_assets_nonexistent_asset( + self, mock_s3_storage, workspace, project, issue + ): + """Test copying with non-existent asset ID""" + # Arrange + mock_storage_instance = MagicMock() + mock_s3_storage.return_value = mock_storage_instance + non_existent_id = "00000000-0000-0000-0000-000000000000" + + # Act + result = copy_assets( + entity=issue, + entity_identifier=issue.id, + project_id=project.id, + asset_ids=[non_existent_id], + user_id=issue.created_by_id, + ) + + # Assert + assert result == [] + mock_storage_instance.copy_object.assert_not_called() diff --git a/apiserver/plane/tests/unit/serializers/__init__.py b/apps/api/plane/tests/unit/models/__init__.py similarity index 100% rename from apiserver/plane/tests/unit/serializers/__init__.py rename to apps/api/plane/tests/unit/models/__init__.py diff --git a/apiserver/plane/tests/unit/models/test_workspace_model.py b/apps/api/plane/tests/unit/models/test_workspace_model.py similarity index 78% rename from apiserver/plane/tests/unit/models/test_workspace_model.py rename to apps/api/plane/tests/unit/models/test_workspace_model.py index 40380fa0f47..aa3c1564541 100644 --- a/apiserver/plane/tests/unit/models/test_workspace_model.py +++ b/apps/api/plane/tests/unit/models/test_workspace_model.py @@ -13,10 +13,7 @@ def test_workspace_creation(self, create_user): """Test creating a workspace""" # Create a workspace workspace = Workspace.objects.create( - name="Test Workspace", - slug="test-workspace", - id=uuid4(), - owner=create_user + name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user ) # Verify it was created @@ -30,21 +27,18 @@ def test_workspace_member_creation(self, create_user): """Test creating a workspace member""" # Create a workspace workspace = Workspace.objects.create( - name="Test Workspace", - slug="test-workspace", - id=uuid4(), - owner=create_user + name="Test Workspace", slug="test-workspace", id=uuid4(), owner=create_user ) # Create a workspace member workspace_member = WorkspaceMember.objects.create( workspace=workspace, member=create_user, - role=20 # Admin role + role=20, # Admin role ) # Verify it was created assert workspace_member.id is not None assert workspace_member.workspace == workspace assert workspace_member.member == create_user - assert workspace_member.role == 20 \ No newline at end of file + assert workspace_member.role == 20 diff --git a/apiserver/plane/tests/unit/utils/__init__.py b/apps/api/plane/tests/unit/serializers/__init__.py similarity index 100% rename from apiserver/plane/tests/unit/utils/__init__.py rename to apps/api/plane/tests/unit/serializers/__init__.py diff --git a/apiserver/plane/tests/unit/serializers/test_issue_recent_visit.py b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py similarity index 100% rename from apiserver/plane/tests/unit/serializers/test_issue_recent_visit.py rename to apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py new file mode 100644 index 00000000000..28e6c8d755b --- /dev/null +++ b/apps/api/plane/tests/unit/serializers/test_workspace.py @@ -0,0 +1,60 @@ +import pytest +from uuid import uuid4 + +from plane.api.serializers import WorkspaceLiteSerializer +from plane.db.models import Workspace, User + + +@pytest.mark.unit +class TestWorkspaceLiteSerializer: + """Test the WorkspaceLiteSerializer""" + + def test_workspace_lite_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + # Create a user to be the owner + owner = User.objects.create( + email="test@example.com", first_name="Test", last_name="User" + ) + + # Create a workspace with explicit ID to test serialization + workspace_id = uuid4() + workspace = Workspace.objects.create( + name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner + ) + + # Serialize the workspace + serialized_data = WorkspaceLiteSerializer(workspace).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "slug" in serialized_data + assert "id" in serialized_data + + assert serialized_data["name"] == "Test Workspace" + assert serialized_data["slug"] == "test-workspace" + assert str(serialized_data["id"]) == str(workspace_id) + + def test_workspace_lite_serializer_read_only(self, db): + """Test that the serializer fields are read-only""" + # Create a user to be the owner + owner = User.objects.create( + email="test2@example.com", first_name="Test", last_name="User" + ) + + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner + ) + + # Try to update via serializer + serializer = WorkspaceLiteSerializer( + workspace, data={"name": "Updated Name", "slug": "updated-slug"} + ) + + # Serializer should be valid (since read-only fields are ignored) + assert serializer.is_valid() + + # Save should not update the read-only fields + updated_workspace = serializer.save() + assert updated_workspace.name == "Test Workspace" + assert updated_workspace.slug == "test-workspace" diff --git a/apiserver/plane/utils/__init__.py b/apps/api/plane/tests/unit/utils/__init__.py similarity index 100% rename from apiserver/plane/utils/__init__.py rename to apps/api/plane/tests/unit/utils/__init__.py diff --git a/apps/api/plane/tests/unit/utils/test_url.py b/apps/api/plane/tests/unit/utils/test_url.py new file mode 100644 index 00000000000..ec3ef7a7314 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_url.py @@ -0,0 +1,262 @@ +import pytest +from plane.utils.url import ( + contains_url, + is_valid_url, + get_url_components, + normalize_url_path, +) + + +@pytest.mark.unit +class TestContainsURL: + """Test the contains_url function""" + + def test_contains_url_with_http_protocol(self): + """Test contains_url with HTTP protocol URLs""" + assert contains_url("Check out http://example.com") is True + assert contains_url("Visit http://google.com/search") is True + assert contains_url("http://localhost:8000") is True + + def test_contains_url_with_https_protocol(self): + """Test contains_url with HTTPS protocol URLs""" + assert contains_url("Check out https://example.com") is True + assert contains_url("Visit https://google.com/search") is True + assert contains_url("https://secure.example.com") is True + + def test_contains_url_with_www_prefix(self): + """Test contains_url with www prefix""" + assert contains_url("Visit www.example.com") is True + assert contains_url("Check www.google.com") is True + assert contains_url("Go to www.test-site.org") is True + + def test_contains_url_with_domain_patterns(self): + """Test contains_url with domain patterns""" + assert contains_url("Visit example.com") is True + assert contains_url("Check google.org") is True + assert contains_url("Go to test-site.co.uk") is True + assert contains_url("Visit sub.domain.com") is True + + def test_contains_url_with_ip_addresses(self): + """Test contains_url with IP addresses""" + assert contains_url("Connect to 192.168.1.1") is True + assert contains_url("Visit 10.0.0.1") is True + assert contains_url("Check 127.0.0.1") is True + assert contains_url("Go to 8.8.8.8") is True + + def test_contains_url_case_insensitive(self): + """Test contains_url is case insensitive""" + assert contains_url("Check HTTP://EXAMPLE.COM") is True + assert contains_url("Visit WWW.GOOGLE.COM") is True + assert contains_url("Go to Https://Test.Com") is True + + def test_contains_url_with_no_urls(self): + """Test contains_url with text that doesn't contain URLs""" + assert contains_url("This is just plain text") is False + assert contains_url("No URLs here!") is False + assert contains_url("com org net") is False # Just TLD words + assert contains_url("192.168") is False # Incomplete IP + assert contains_url("") is False # Empty string + + def test_contains_url_edge_cases(self): + """Test contains_url with edge cases""" + assert contains_url("example.c") is False # TLD too short + assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255) + assert contains_url("just-a-hyphen") is False # No domain + assert ( + contains_url("www.") is False + ) # Incomplete www - needs at least one char after dot + + def test_contains_url_length_limit_under_1000(self): + """Test contains_url with input under 1000 characters containing URLs""" + # Create a string under 1000 characters with a URL + text_with_url = "a" * 970 + " https://example.com" # 970 + 1 + 19 = 990 chars + assert len(text_with_url) < 1000 + assert contains_url(text_with_url) is True + + # Test with exactly 1000 characters + text_exact_1000 = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars + assert len(text_exact_1000) == 1000 + assert contains_url(text_exact_1000) is True + + def test_contains_url_length_limit_over_1000(self): + """Test contains_url with input over 1000 characters returns False""" + # Create a string over 1000 characters with a URL + text_with_url = "a" * 982 + "https://example.com" # 982 + 19 = 1001 chars + assert len(text_with_url) > 1000 + assert contains_url(text_with_url) is False + + # Test with much longer input + long_text_with_url = "a" * 5000 + " https://example.com" + assert contains_url(long_text_with_url) is False + + def test_contains_url_length_limit_exactly_1000(self): + """Test contains_url with input exactly 1000 characters""" + # Test with exactly 1000 characters without URL + text_no_url = "a" * 1000 + assert len(text_no_url) == 1000 + assert contains_url(text_no_url) is False + + # Test with exactly 1000 characters with URL at the end + text_with_url = "a" * 981 + "https://example.com" # 981 + 19 = 1000 chars + assert len(text_with_url) == 1000 + assert contains_url(text_with_url) is True + + def test_contains_url_line_length_scenarios(self): + """Test contains_url with realistic line length scenarios""" + # Test with multiline input where total is under 1000 but we test line processing + # Short lines with URL + multiline_short = "Line 1\nLine 2 with https://example.com\nLine 3" + assert contains_url(multiline_short) is True + + # Multiple lines under total limit + multiline_text = ( + "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200 + ) + assert len(multiline_text) < 1000 + assert contains_url(multiline_text) is True + + def test_contains_url_total_length_vs_line_length(self): + """Test the interaction between total length limit and line processing""" + # Test that total length limit takes precedence + # Even if individual lines would be processed, total > 1000 means immediate False + over_limit_text = "a" * 1001 # No URL, but over total limit + assert contains_url(over_limit_text) is False + + # Test that under total limit, line processing works normally + under_limit_with_url = "a" * 900 + "https://example.com" # 919 chars total + assert len(under_limit_with_url) < 1000 + assert contains_url(under_limit_with_url) is True + + def test_contains_url_multiline_mixed_lengths(self): + """Test contains_url with multiple lines of different lengths""" + # Test realistic multiline scenario under 1000 chars total + multiline_text = ( + "Short line\n" + + "a" * 400 + + "https://example.com\n" # Line with URL + + "b" * 300 # Another line + ) + assert len(multiline_text) < 1000 + assert contains_url(multiline_text) is True + + # Test multiline without URLs + multiline_no_url = "Short line\n" + "a" * 400 + "\n" + "b" * 300 + assert len(multiline_no_url) < 1000 + assert contains_url(multiline_no_url) is False + + def test_contains_url_edge_cases_with_length_limits(self): + """Test contains_url edge cases related to length limits""" + # Empty string + assert contains_url("") is False + + # Very short string with URL + assert contains_url("http://a.co") is True + + # String with newlines and mixed content + mixed_content = "Line 1\nLine 2 with https://example.com\nLine 3" + assert contains_url(mixed_content) is True + + # String with many newlines under total limit + many_newlines = "\n" * 500 + "https://example.com" + assert len(many_newlines) < 1000 + assert contains_url(many_newlines) is True + + +@pytest.mark.unit +class TestIsValidURL: + """Test the is_valid_url function""" + + def test_is_valid_url_with_valid_urls(self): + """Test is_valid_url with valid URLs""" + assert is_valid_url("https://example.com") is True + assert is_valid_url("http://google.com") is True + assert is_valid_url("https://sub.domain.com/path") is True + assert is_valid_url("http://localhost:8000") is True + assert is_valid_url("https://example.com/path?query=1") is True + assert is_valid_url("ftp://files.example.com") is True + + def test_is_valid_url_with_invalid_urls(self): + """Test is_valid_url with invalid URLs""" + assert is_valid_url("not a url") is False + assert is_valid_url("example.com") is False # No scheme + assert is_valid_url("https://") is False # No netloc + assert is_valid_url("") is False # Empty string + assert is_valid_url("://example.com") is False # No scheme + assert is_valid_url("https:/example.com") is False # Malformed + + def test_is_valid_url_with_non_string_input(self): + """Test is_valid_url with non-string input""" + assert is_valid_url(None) is False + assert is_valid_url([]) is False + assert is_valid_url({}) is False + + def test_is_valid_url_with_special_schemes(self): + """Test is_valid_url with special URL schemes""" + assert is_valid_url("ftp://ftp.example.com") is True + assert is_valid_url("mailto:user@example.com") is False + assert is_valid_url("file:///path/to/file") is False + + +@pytest.mark.unit +class TestNormalizeURLPath: + """Test the normalize_url_path function""" + + def test_normalize_url_path_with_multiple_slashes(self): + """Test normalize_url_path with multiple consecutive slashes""" + result = normalize_url_path("https://example.com//foo///bar//baz") + assert result == "https://example.com/foo/bar/baz" + + def test_normalize_url_path_with_query_and_fragment(self): + """Test normalize_url_path preserves query and fragment""" + result = normalize_url_path( + "https://example.com//foo///bar//baz?x=1&y=2#fragment" + ) + assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment" + + def test_normalize_url_path_with_no_redundant_slashes(self): + """Test normalize_url_path with already normalized URL""" + url = "https://example.com/foo/bar/baz?x=1#fragment" + result = normalize_url_path(url) + assert result == url + + def test_normalize_url_path_with_root_path(self): + """Test normalize_url_path with root path""" + result = normalize_url_path("https://example.com//") + assert result == "https://example.com/" + + def test_normalize_url_path_with_empty_path(self): + """Test normalize_url_path with empty path""" + result = normalize_url_path("https://example.com") + assert result == "https://example.com" + + def test_normalize_url_path_with_complex_path(self): + """Test normalize_url_path with complex path structure""" + result = normalize_url_path( + "https://example.com///api//v1///users//123//profile" + ) + assert result == "https://example.com/api/v1/users/123/profile" + + def test_normalize_url_path_with_different_schemes(self): + """Test normalize_url_path with different URL schemes""" + # HTTP + result = normalize_url_path("http://example.com//path") + assert result == "http://example.com/path" + + # FTP + result = normalize_url_path("ftp://ftp.example.com//files//document.txt") + assert result == "ftp://ftp.example.com/files/document.txt" + + def test_normalize_url_path_with_port(self): + """Test normalize_url_path with port number""" + result = normalize_url_path("https://example.com:8080//api//v1") + assert result == "https://example.com:8080/api/v1" + + def test_normalize_url_path_edge_cases(self): + """Test normalize_url_path with edge cases""" + # Many consecutive slashes + result = normalize_url_path("https://example.com///////path") + assert result == "https://example.com/path" + + # Mixed single and multiple slashes + result = normalize_url_path("https://example.com/a//b/c///d") + assert result == "https://example.com/a/b/c/d" diff --git a/apiserver/plane/tests/unit/utils/test_uuid.py b/apps/api/plane/tests/unit/utils/test_uuid.py similarity index 90% rename from apiserver/plane/tests/unit/utils/test_uuid.py rename to apps/api/plane/tests/unit/utils/test_uuid.py index 81403c5bef2..5503f2bc37b 100644 --- a/apiserver/plane/tests/unit/utils/test_uuid.py +++ b/apps/api/plane/tests/unit/utils/test_uuid.py @@ -19,7 +19,9 @@ def test_is_valid_uuid_with_invalid_uuid(self): assert is_valid_uuid("not-a-uuid") is False assert is_valid_uuid("123456789") is False assert is_valid_uuid("") is False - assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1 + assert ( + is_valid_uuid("00000000-0000-0000-0000-000000000000") is False + ) # This is a valid UUID but version 1 def test_convert_uuid_to_integer(self): """Test convert_uuid_to_integer function""" @@ -46,4 +48,6 @@ def test_convert_uuid_to_integer_string_input(self): test_uuid = uuid.UUID(test_uuid_str) # Should get the same result whether passing UUID or string - assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str) \ No newline at end of file + assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer( + test_uuid_str + ) diff --git a/apiserver/plane/urls.py b/apps/api/plane/urls.py similarity index 100% rename from apiserver/plane/urls.py rename to apps/api/plane/urls.py diff --git a/apiserver/plane/web/__init__.py b/apps/api/plane/utils/__init__.py similarity index 100% rename from apiserver/plane/web/__init__.py rename to apps/api/plane/utils/__init__.py diff --git a/apiserver/plane/utils/analytics_plot.py b/apps/api/plane/utils/analytics_plot.py similarity index 100% rename from apiserver/plane/utils/analytics_plot.py rename to apps/api/plane/utils/analytics_plot.py diff --git a/apiserver/plane/utils/build_chart.py b/apps/api/plane/utils/build_chart.py similarity index 100% rename from apiserver/plane/utils/build_chart.py rename to apps/api/plane/utils/build_chart.py diff --git a/apiserver/plane/utils/cache.py b/apps/api/plane/utils/cache.py similarity index 100% rename from apiserver/plane/utils/cache.py rename to apps/api/plane/utils/cache.py diff --git a/apiserver/plane/utils/constants.py b/apps/api/plane/utils/constants.py similarity index 100% rename from apiserver/plane/utils/constants.py rename to apps/api/plane/utils/constants.py diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py new file mode 100644 index 00000000000..7b9932a35b9 --- /dev/null +++ b/apps/api/plane/utils/content_validator.py @@ -0,0 +1,357 @@ +# Python imports +import base64 +import json +import re + + +# Maximum allowed size for binary data (10MB) +MAX_SIZE = 10 * 1024 * 1024 + +# Maximum recursion depth to prevent stack overflow +MAX_RECURSION_DEPTH = 20 + +# Dangerous text patterns that could indicate XSS or script injection +DANGEROUS_TEXT_PATTERNS = [ + r"]*>.*?", + r"javascript\s*:", + r"data\s*:\s*text/html", + r"eval\s*\(", + r"document\s*\.", + r"window\s*\.", + r"location\s*\.", +] + +# Dangerous attribute patterns for HTML attributes +DANGEROUS_ATTR_PATTERNS = [ + r"javascript\s*:", + r"data\s*:\s*text/html", + r"eval\s*\(", + r"alert\s*\(", + r"document\s*\.", + r"window\s*\.", +] + +# Suspicious patterns for binary data content +SUSPICIOUS_BINARY_PATTERNS = [ + "]*>", + r"", + # JavaScript URLs in various attributes + r'(?:href|src|action)\s*=\s*["\']?\s*javascript:', + # Data URLs with text/html (potential XSS) + r'(?:href|src|action)\s*=\s*["\']?\s*data:text/html', + # Dangerous event handlers with JavaScript-like content + r'on(?:load|error|click|focus|blur|change|submit|reset|select|resize|scroll|unload|beforeunload|hashchange|popstate|storage|message|offline|online)\s*=\s*["\']?[^"\']*(?:javascript|alert|eval|document\.|window\.|location\.|history\.)[^"\']*["\']?', + # Object and embed tags that could load external content + r"<(?:object|embed)[^>]*(?:data|src)\s*=", + # Base tag that could change relative URL resolution + r"]*href\s*=", + # Dangerous iframe sources + r']*src\s*=\s*["\']?(?:javascript:|data:text/html)', + # Meta refresh redirects + r']*http-equiv\s*=\s*["\']?refresh["\']?', + # Link tags - simplified patterns + r']*rel\s*=\s*["\']?stylesheet["\']?', + r']*href\s*=\s*["\']?https?://', + r']*href\s*=\s*["\']?//', + r']*href\s*=\s*["\']?(?:data:|javascript:)', + # Style tags with external imports + r"]*>.*?@import.*?(?:https?://|//)", + # Link tags with dangerous rel types + r']*rel\s*=\s*["\']?(?:import|preload|prefetch|dns-prefetch|preconnect)["\']?', + # Forms with action attributes + r"]*action\s*=", +] + +# Dangerous JavaScript patterns for event handlers +DANGEROUS_JS_PATTERNS = [ + r"alert\s*\(", + r"eval\s*\(", + r"document\s*\.", + r"window\s*\.", + r"location\s*\.", + r"fetch\s*\(", + r"XMLHttpRequest", + r"innerHTML\s*=", + r"outerHTML\s*=", + r"document\.write", + r"script\s*>", +] + +# HTML self-closing tags that don't need closing tags +SELF_CLOSING_TAGS = { + "img", + "br", + "hr", + "input", + "meta", + "link", + "area", + "base", + "col", + "embed", + "source", + "track", + "wbr", +} + + +def validate_binary_data(data): + """ + Validate that binary data appears to be valid document format and doesn't contain malicious content. + + Args: + data (bytes or str): The binary data to validate, or base64-encoded string + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + if not data: + return True, None # Empty is OK + + # Handle base64-encoded strings by decoding them first + if isinstance(data, str): + try: + binary_data = base64.b64decode(data) + except Exception: + return False, "Invalid base64 encoding" + else: + binary_data = data + + # Size check - 10MB limit + if len(binary_data) > MAX_SIZE: + return False, "Binary data exceeds maximum size limit (10MB)" + + # Basic format validation + if len(binary_data) < 4: + return False, "Binary data too short to be valid document format" + + # Check for suspicious text patterns (HTML/JS) + try: + decoded_text = binary_data.decode("utf-8", errors="ignore")[:200] + if any( + pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS + ): + return False, "Binary data contains suspicious content patterns" + except Exception: + pass # Binary data might not be decodable as text, which is fine + + return True, None + + +def validate_html_content(html_content): + """ + Validate that HTML content is safe and doesn't contain malicious patterns. + + Args: + html_content (str): The HTML content to validate + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + if not html_content: + return True, None # Empty is OK + + # Size check - 10MB limit (consistent with binary validation) + if len(html_content.encode("utf-8")) > MAX_SIZE: + return False, "HTML content exceeds maximum size limit (10MB)" + + # Check for specific malicious patterns (simplified and more reliable) + for pattern in MALICIOUS_HTML_PATTERNS: + if re.search(pattern, html_content, re.IGNORECASE | re.DOTALL): + return ( + False, + f"HTML content contains potentially malicious patterns: {pattern}", + ) + + # Additional check for inline event handlers that contain suspicious content + # This is more permissive - only blocks if the event handler contains actual dangerous code + event_handler_pattern = r'on\w+\s*=\s*["\']([^"\']*)["\']' + event_matches = re.findall(event_handler_pattern, html_content, re.IGNORECASE) + + for handler_content in event_matches: + for js_pattern in DANGEROUS_JS_PATTERNS: + if re.search(js_pattern, handler_content, re.IGNORECASE): + return ( + False, + f"HTML content contains dangerous JavaScript in event handler: {handler_content[:100]}", + ) + + # Basic HTML structure validation - check for common malformed tags + try: + # Count opening and closing tags for basic structure validation + opening_tags = re.findall(r"<(\w+)[^>]*>", html_content) + closing_tags = re.findall(r"", html_content) + + # Filter out self-closing tags from opening tags + opening_tags_filtered = [ + tag for tag in opening_tags if tag.lower() not in SELF_CLOSING_TAGS + ] + + # Basic check - if we have significantly more opening than closing tags, it might be malformed + if len(opening_tags_filtered) > len(closing_tags) + 10: # Allow some tolerance + return False, "HTML content appears to be malformed (unmatched tags)" + + except Exception: + # If HTML parsing fails, we'll allow it + pass + + return True, None + + +def validate_json_content(json_content): + """ + Validate that JSON content is safe and doesn't contain malicious patterns. + + Args: + json_content (dict): The JSON content to validate + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + if not json_content: + return True, None # Empty is OK + + try: + # Size check - 10MB limit (consistent with other validations) + json_str = json.dumps(json_content) + if len(json_str.encode("utf-8")) > MAX_SIZE: + return False, "JSON content exceeds maximum size limit (10MB)" + + # Basic structure validation for page description JSON + if isinstance(json_content, dict): + # Check for expected page description structure + # This is based on ProseMirror/Tiptap JSON structure + if "type" in json_content and json_content.get("type") == "doc": + # Valid document structure + if "content" in json_content and isinstance( + json_content["content"], list + ): + # Recursively check content for suspicious patterns + is_valid, error_msg = _validate_json_content_array( + json_content["content"] + ) + if not is_valid: + return False, error_msg + elif "type" not in json_content and "content" not in json_content: + # Allow other JSON structures but validate for suspicious content + is_valid, error_msg = _validate_json_content_recursive(json_content) + if not is_valid: + return False, error_msg + else: + return False, "JSON description must be a valid object" + + except (TypeError, ValueError) as e: + return False, "Invalid JSON structure" + except Exception as e: + return False, "Failed to validate JSON content" + + return True, None + + +def _validate_json_content_array(content, depth=0): + """ + Validate JSON content array for suspicious patterns. + + Args: + content (list): Array of content nodes to validate + depth (int): Current recursion depth (default: 0) + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + # Check recursion depth to prevent stack overflow + if depth > MAX_RECURSION_DEPTH: + return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded" + + if not isinstance(content, list): + return True, None + + for node in content: + if isinstance(node, dict): + # Check text content for suspicious patterns (more targeted) + if node.get("type") == "text" and "text" in node: + text_content = node["text"] + for pattern in DANGEROUS_TEXT_PATTERNS: + if re.search(pattern, text_content, re.IGNORECASE): + return ( + False, + "JSON content contains suspicious script patterns in text", + ) + + # Check attributes for suspicious content (more targeted) + if "attrs" in node and isinstance(node["attrs"], dict): + for attr_name, attr_value in node["attrs"].items(): + if isinstance(attr_value, str): + # Only check specific attributes that could be dangerous + if attr_name.lower() in [ + "href", + "src", + "action", + "onclick", + "onload", + "onerror", + ]: + for pattern in DANGEROUS_ATTR_PATTERNS: + if re.search(pattern, attr_value, re.IGNORECASE): + return ( + False, + f"JSON content contains dangerous pattern in {attr_name} attribute", + ) + + # Recursively check nested content + if "content" in node and isinstance(node["content"], list): + is_valid, error_msg = _validate_json_content_array( + node["content"], depth + 1 + ) + if not is_valid: + return False, error_msg + + return True, None + + +def _validate_json_content_recursive(obj, depth=0): + """ + Recursively validate JSON object for suspicious content. + + Args: + obj: JSON object (dict, list, or primitive) to validate + depth (int): Current recursion depth (default: 0) + + Returns: + tuple: (is_valid: bool, error_message: str or None) + """ + # Check recursion depth to prevent stack overflow + if depth > MAX_RECURSION_DEPTH: + return False, f"Maximum recursion depth ({MAX_RECURSION_DEPTH}) exceeded" + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(value, str): + # Check for dangerous patterns using module constants + for pattern in DANGEROUS_TEXT_PATTERNS: + if re.search(pattern, value, re.IGNORECASE): + return ( + False, + "JSON content contains suspicious script patterns", + ) + elif isinstance(value, (dict, list)): + is_valid, error_msg = _validate_json_content_recursive(value, depth + 1) + if not is_valid: + return False, error_msg + elif isinstance(obj, list): + for item in obj: + is_valid, error_msg = _validate_json_content_recursive(item, depth + 1) + if not is_valid: + return False, error_msg + + return True, None diff --git a/apiserver/plane/utils/date_utils.py b/apps/api/plane/utils/date_utils.py similarity index 100% rename from apiserver/plane/utils/date_utils.py rename to apps/api/plane/utils/date_utils.py diff --git a/apiserver/plane/utils/error_codes.py b/apps/api/plane/utils/error_codes.py similarity index 100% rename from apiserver/plane/utils/error_codes.py rename to apps/api/plane/utils/error_codes.py diff --git a/apps/api/plane/utils/exception_logger.py b/apps/api/plane/utils/exception_logger.py new file mode 100644 index 00000000000..b0a6f8c38ba --- /dev/null +++ b/apps/api/plane/utils/exception_logger.py @@ -0,0 +1,20 @@ +# Python imports +import logging +import traceback + +# Django imports +from django.conf import settings + + +def log_exception(e, warning=False): + # Log the error + logger = logging.getLogger("plane.exception") + + if warning: + logger.warning(str(e)) + else: + logger.exception(e) + + if settings.DEBUG: + logger.debug(traceback.format_exc()) + return diff --git a/apiserver/plane/utils/global_paginator.py b/apps/api/plane/utils/global_paginator.py similarity index 94% rename from apiserver/plane/utils/global_paginator.py rename to apps/api/plane/utils/global_paginator.py index 338d86117f5..1b7f908c547 100644 --- a/apiserver/plane/utils/global_paginator.py +++ b/apps/api/plane/utils/global_paginator.py @@ -50,11 +50,11 @@ def paginate(base_queryset, queryset, cursor, on_result): paginated_data = queryset[start_index:end_index] # Create the pagination info object - prev_cursor = f"{page_size}:{cursor_object.current_page-1}:0" + prev_cursor = f"{page_size}:{cursor_object.current_page - 1}:0" cursor = f"{page_size}:{cursor_object.current_page}:0" next_cursor = None if end_index < total_results: - next_cursor = f"{page_size}:{cursor_object.current_page+1}:0" + next_cursor = f"{page_size}:{cursor_object.current_page + 1}:0" prev_page_results = False if cursor_object.current_page > 0: diff --git a/apiserver/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py similarity index 100% rename from apiserver/plane/utils/grouper.py rename to apps/api/plane/utils/grouper.py diff --git a/apiserver/plane/utils/host.py b/apps/api/plane/utils/host.py similarity index 100% rename from apiserver/plane/utils/host.py rename to apps/api/plane/utils/host.py diff --git a/apiserver/plane/utils/html_processor.py b/apps/api/plane/utils/html_processor.py similarity index 100% rename from apiserver/plane/utils/html_processor.py rename to apps/api/plane/utils/html_processor.py diff --git a/apiserver/plane/utils/imports.py b/apps/api/plane/utils/imports.py similarity index 100% rename from apiserver/plane/utils/imports.py rename to apps/api/plane/utils/imports.py diff --git a/apiserver/plane/utils/ip_address.py b/apps/api/plane/utils/ip_address.py similarity index 100% rename from apiserver/plane/utils/ip_address.py rename to apps/api/plane/utils/ip_address.py diff --git a/apiserver/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py similarity index 100% rename from apiserver/plane/utils/issue_filters.py rename to apps/api/plane/utils/issue_filters.py diff --git a/apiserver/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py similarity index 100% rename from apiserver/plane/utils/issue_relation_mapper.py rename to apps/api/plane/utils/issue_relation_mapper.py diff --git a/apiserver/plane/utils/issue_search.py b/apps/api/plane/utils/issue_search.py similarity index 100% rename from apiserver/plane/utils/issue_search.py rename to apps/api/plane/utils/issue_search.py diff --git a/apiserver/plane/utils/logging.py b/apps/api/plane/utils/logging.py similarity index 100% rename from apiserver/plane/utils/logging.py rename to apps/api/plane/utils/logging.py diff --git a/apiserver/plane/utils/markdown.py b/apps/api/plane/utils/markdown.py similarity index 100% rename from apiserver/plane/utils/markdown.py rename to apps/api/plane/utils/markdown.py diff --git a/apiserver/plane/utils/order_queryset.py b/apps/api/plane/utils/order_queryset.py similarity index 100% rename from apiserver/plane/utils/order_queryset.py rename to apps/api/plane/utils/order_queryset.py diff --git a/apiserver/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py similarity index 99% rename from apiserver/plane/utils/paginator.py rename to apps/api/plane/utils/paginator.py index 0793d2a30e8..ce9c65f644e 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -35,7 +35,7 @@ def __eq__(self, other): # Return the representation of the cursor def __repr__(self): - return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" + return f"{(type(self).__name__,)}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" # noqa: E501 # Return if the cursor is true def __bool__(self): diff --git a/apiserver/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py similarity index 100% rename from apiserver/plane/utils/path_validator.py rename to apps/api/plane/utils/path_validator.py diff --git a/apiserver/plane/utils/telemetry.py b/apps/api/plane/utils/telemetry.py similarity index 100% rename from apiserver/plane/utils/telemetry.py rename to apps/api/plane/utils/telemetry.py diff --git a/apiserver/plane/utils/timezone_converter.py b/apps/api/plane/utils/timezone_converter.py similarity index 100% rename from apiserver/plane/utils/timezone_converter.py rename to apps/api/plane/utils/timezone_converter.py diff --git a/apps/api/plane/utils/url.py b/apps/api/plane/utils/url.py new file mode 100644 index 00000000000..6c196c29885 --- /dev/null +++ b/apps/api/plane/utils/url.py @@ -0,0 +1,124 @@ +# Python imports +import re +from typing import Optional +from urllib.parse import urlparse, urlunparse + +# Compiled regex pattern for better performance and ReDoS protection +# Using atomic groups and length limits to prevent excessive backtracking +URL_PATTERN = re.compile( + r"(?i)" # Case insensitive + r"(?:" # Non-capturing group for alternatives + r"https?://[^\s]+" # http:// or https:// followed by non-whitespace + r"|" + r"www\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" # www.domain with proper length limits + r"|" + r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}" # domain.tld with length limits + r"|" + r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" # IP address with proper validation + r")" +) + + +def contains_url(value: str) -> bool: + """ + Check if the value contains a URL. + + This function is protected against ReDoS attacks by: + 1. Using a pre-compiled regex pattern + 2. Limiting input length to prevent excessive processing + 3. Using atomic groups and specific quantifiers to avoid backtracking + + Args: + value (str): The input string to check for URLs + + Returns: + bool: True if the string contains a URL, False otherwise + """ + # Prevent ReDoS by limiting input length + if len(value) > 1000: # Reasonable limit for URL detection + return False + + # Additional safety: truncate very long lines that might contain URLs + lines = value.split("\n") + for line in lines: + if len(line) > 500: # Process only reasonable length lines + line = line[:500] + if URL_PATTERN.search(line): + return True + + return False + + +def is_valid_url(url: str) -> bool: + """ + Validates whether the given string is a well-formed URL. + + Args: + url (str): The URL string to validate. + + Returns: + bool: True if the URL is valid, False otherwise. + + Example: + >>> is_valid_url("https://example.com") + True + >>> is_valid_url("not a url") + False + """ + try: + result = urlparse(url) + # A valid URL should have at least scheme and netloc + return all([result.scheme, result.netloc]) + except TypeError: + return False + + +def get_url_components(url: str) -> Optional[dict]: + """ + Parses the URL and returns its components if valid. + + Args: + url (str): The URL string to parse. + + Returns: + Optional[dict]: A dictionary with URL components if valid, None otherwise. + + Example: + >>> get_url_components("https://example.com/path?query=1") + {'scheme': 'https', 'netloc': 'example.com', 'path': '/path', 'params': '', 'query': 'query=1', 'fragment': ''} + """ + if not is_valid_url(url): + return None + result = urlparse(url) + return { + "scheme": result.scheme, + "netloc": result.netloc, + "path": result.path, + "params": result.params, + "query": result.query, + "fragment": result.fragment, + } + + +def normalize_url_path(url: str) -> str: + """ + Normalize the path component of a URL by replacing multiple consecutive slashes with a single slash. + + This function preserves the protocol, domain, query parameters, and fragments of the URL, + only modifying the path portion to ensure there are no duplicate slashes. + + Args: + url (str): The input URL string to normalize. + + Returns: + str: The normalized URL with redundant slashes in the path removed. + + Example: + >>> normalize_url_path('https://example.com//foo///bar//baz?x=1#frag') + 'https://example.com/foo/bar/baz?x=1#frag' + """ + parts = urlparse(url) + # Normalize the path + normalized_path = re.sub(r"/+", "/", parts.path) + # Reconstruct the URL + return urlunparse(parts._replace(path=normalized_path)) diff --git a/apiserver/plane/utils/uuid.py b/apps/api/plane/utils/uuid.py similarity index 100% rename from apiserver/plane/utils/uuid.py rename to apps/api/plane/utils/uuid.py diff --git a/packages/types/src/issues/issue_subscription.d.ts b/apps/api/plane/web/__init__.py similarity index 100% rename from packages/types/src/issues/issue_subscription.d.ts rename to apps/api/plane/web/__init__.py diff --git a/apiserver/plane/web/apps.py b/apps/api/plane/web/apps.py similarity index 100% rename from apiserver/plane/web/apps.py rename to apps/api/plane/web/apps.py diff --git a/apiserver/plane/web/urls.py b/apps/api/plane/web/urls.py similarity index 100% rename from apiserver/plane/web/urls.py rename to apps/api/plane/web/urls.py diff --git a/apiserver/plane/web/views.py b/apps/api/plane/web/views.py similarity index 100% rename from apiserver/plane/web/views.py rename to apps/api/plane/web/views.py diff --git a/apiserver/plane/wsgi.py b/apps/api/plane/wsgi.py similarity index 100% rename from apiserver/plane/wsgi.py rename to apps/api/plane/wsgi.py diff --git a/apiserver/pyproject.toml b/apps/api/pyproject.toml similarity index 100% rename from apiserver/pyproject.toml rename to apps/api/pyproject.toml diff --git a/apiserver/pytest.ini b/apps/api/pytest.ini similarity index 100% rename from apiserver/pytest.ini rename to apps/api/pytest.ini diff --git a/apiserver/requirements.txt b/apps/api/requirements.txt similarity index 100% rename from apiserver/requirements.txt rename to apps/api/requirements.txt diff --git a/apiserver/requirements/base.txt b/apps/api/requirements/base.txt similarity index 100% rename from apiserver/requirements/base.txt rename to apps/api/requirements/base.txt diff --git a/apiserver/requirements/local.txt b/apps/api/requirements/local.txt similarity index 100% rename from apiserver/requirements/local.txt rename to apps/api/requirements/local.txt diff --git a/apiserver/requirements/production.txt b/apps/api/requirements/production.txt similarity index 100% rename from apiserver/requirements/production.txt rename to apps/api/requirements/production.txt diff --git a/apiserver/requirements/test.txt b/apps/api/requirements/test.txt similarity index 100% rename from apiserver/requirements/test.txt rename to apps/api/requirements/test.txt diff --git a/apps/api/run_tests.py b/apps/api/run_tests.py new file mode 100755 index 00000000000..6f42229c9c0 --- /dev/null +++ b/apps/api/run_tests.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +import argparse +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description="Run Plane tests") + parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only") + parser.add_argument( + "-c", "--contract", action="store_true", help="Run contract tests only" + ) + parser.add_argument( + "-s", "--smoke", action="store_true", help="Run smoke tests only" + ) + parser.add_argument( + "-o", "--coverage", action="store_true", help="Generate coverage report" + ) + parser.add_argument( + "-p", "--parallel", action="store_true", help="Run tests in parallel" + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + args = parser.parse_args() + + # Build command + cmd = ["python", "-m", "pytest"] + markers = [] + + # Add test markers + if args.unit: + markers.append("unit") + if args.contract: + markers.append("contract") + if args.smoke: + markers.append("smoke") + + # Add markers filter + if markers: + cmd.extend(["-m", " or ".join(markers)]) + + # Add coverage + if args.coverage: + cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"]) + + # Add parallel + if args.parallel: + cmd.extend(["-n", "auto"]) + + # Add verbose + if args.verbose: + cmd.append("-v") + + # Add common flags + cmd.extend(["--reuse-db", "--nomigrations"]) + + # Print command + print(f"Running: {' '.join(cmd)}") + + # Execute command + result = subprocess.run(cmd) + + # Check coverage thresholds if coverage is enabled + if args.coverage: + print("Checking coverage thresholds...") + coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"] + coverage_result = subprocess.run(coverage_cmd) + if coverage_result.returncode != 0: + print("Coverage below threshold (90%)") + sys.exit(coverage_result.returncode) + + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/apiserver/run_tests.sh b/apps/api/run_tests.sh similarity index 100% rename from apiserver/run_tests.sh rename to apps/api/run_tests.sh diff --git a/apiserver/templates/admin/base_site.html b/apps/api/templates/admin/base_site.html similarity index 100% rename from apiserver/templates/admin/base_site.html rename to apps/api/templates/admin/base_site.html diff --git a/apiserver/templates/base.html b/apps/api/templates/base.html similarity index 100% rename from apiserver/templates/base.html rename to apps/api/templates/base.html diff --git a/apiserver/templates/csrf_failure.html b/apps/api/templates/csrf_failure.html similarity index 100% rename from apiserver/templates/csrf_failure.html rename to apps/api/templates/csrf_failure.html diff --git a/apiserver/templates/emails/auth/forgot_password.html b/apps/api/templates/emails/auth/forgot_password.html similarity index 100% rename from apiserver/templates/emails/auth/forgot_password.html rename to apps/api/templates/emails/auth/forgot_password.html diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apps/api/templates/emails/auth/magic_signin.html similarity index 100% rename from apiserver/templates/emails/auth/magic_signin.html rename to apps/api/templates/emails/auth/magic_signin.html diff --git a/apiserver/templates/emails/exports/analytics.html b/apps/api/templates/emails/exports/analytics.html similarity index 100% rename from apiserver/templates/emails/exports/analytics.html rename to apps/api/templates/emails/exports/analytics.html diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apps/api/templates/emails/invitations/project_invitation.html similarity index 100% rename from apiserver/templates/emails/invitations/project_invitation.html rename to apps/api/templates/emails/invitations/project_invitation.html diff --git a/apiserver/templates/emails/invitations/workspace_invitation.html b/apps/api/templates/emails/invitations/workspace_invitation.html similarity index 100% rename from apiserver/templates/emails/invitations/workspace_invitation.html rename to apps/api/templates/emails/invitations/workspace_invitation.html diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apps/api/templates/emails/notifications/issue-updates.html similarity index 100% rename from apiserver/templates/emails/notifications/issue-updates.html rename to apps/api/templates/emails/notifications/issue-updates.html diff --git a/apiserver/templates/emails/notifications/project_addition.html b/apps/api/templates/emails/notifications/project_addition.html similarity index 100% rename from apiserver/templates/emails/notifications/project_addition.html rename to apps/api/templates/emails/notifications/project_addition.html diff --git a/apiserver/templates/emails/notifications/webhook-deactivate.html b/apps/api/templates/emails/notifications/webhook-deactivate.html similarity index 100% rename from apiserver/templates/emails/notifications/webhook-deactivate.html rename to apps/api/templates/emails/notifications/webhook-deactivate.html diff --git a/apiserver/templates/emails/test_email.html b/apps/api/templates/emails/test_email.html similarity index 100% rename from apiserver/templates/emails/test_email.html rename to apps/api/templates/emails/test_email.html diff --git a/apiserver/templates/emails/user/user_activation.html b/apps/api/templates/emails/user/user_activation.html similarity index 100% rename from apiserver/templates/emails/user/user_activation.html rename to apps/api/templates/emails/user/user_activation.html diff --git a/apiserver/templates/emails/user/user_deactivation.html b/apps/api/templates/emails/user/user_deactivation.html similarity index 100% rename from apiserver/templates/emails/user/user_deactivation.html rename to apps/api/templates/emails/user/user_deactivation.html diff --git a/apps/live/.env.example b/apps/live/.env.example new file mode 100644 index 00000000000..5fc90d75fcf --- /dev/null +++ b/apps/live/.env.example @@ -0,0 +1,14 @@ +PORT=3100 +API_BASE_URL="http://localhost:8000" + +WEB_BASE_URL="http://localhost:3000" + +LIVE_BASE_URL="http://localhost:3100" +LIVE_BASE_PATH="/live" + +LIVE_SERVER_SECRET_KEY="secret-key" + +# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead. +REDIS_PORT=6379 +REDIS_HOST=localhost +REDIS_URL="redis://localhost:6379/" diff --git a/live/.eslintignore b/apps/live/.eslintignore similarity index 100% rename from live/.eslintignore rename to apps/live/.eslintignore diff --git a/apps/live/.eslintrc.json b/apps/live/.eslintrc.json new file mode 100644 index 00000000000..db20d9097ba --- /dev/null +++ b/apps/live/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "root": true, + "extends": ["@plane/eslint-config/server.js"], + "parser": "@typescript-eslint/parser" +} diff --git a/live/.prettierignore b/apps/live/.prettierignore similarity index 100% rename from live/.prettierignore rename to apps/live/.prettierignore diff --git a/live/.prettierrc b/apps/live/.prettierrc similarity index 100% rename from live/.prettierrc rename to apps/live/.prettierrc diff --git a/apps/live/Dockerfile.dev b/apps/live/Dockerfile.dev new file mode 100644 index 00000000000..4d251ee9aed --- /dev/null +++ b/apps/live/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY . . +RUN yarn global add turbo +RUN yarn install +EXPOSE 3003 + +ENV TURBO_TELEMETRY_DISABLED=1 + +VOLUME [ "/app/node_modules", "/app/live/node_modules"] + +CMD ["yarn","dev", "--filter=live"] diff --git a/apps/live/Dockerfile.live b/apps/live/Dockerfile.live new file mode 100644 index 00000000000..c3455fdcecc --- /dev/null +++ b/apps/live/Dockerfile.live @@ -0,0 +1,54 @@ +FROM node:22-alpine AS base + +# ***************************************************************************** +# STAGE 1: Prune the project +# ***************************************************************************** +FROM base AS builder +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk update +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app +RUN yarn global add turbo +COPY . . +RUN turbo prune live --docker + +# ***************************************************************************** +# STAGE 2: Install dependencies & build the project +# ***************************************************************************** +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# Build the project and its dependencies +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN yarn turbo build --filter=live + +# ***************************************************************************** +# STAGE 3: Run the project +# ***************************************************************************** + +FROM base AS runner +WORKDIR /app + +COPY --from=installer /app/packages ./packages +COPY --from=installer /app/apps/live/dist ./live +COPY --from=installer /app/node_modules ./node_modules + +ENV TURBO_TELEMETRY_DISABLED=1 + +EXPOSE 3000 + +CMD ["node", "live/server.js"] \ No newline at end of file diff --git a/apps/live/package.json b/apps/live/package.json new file mode 100644 index 00000000000..7593ed1184d --- /dev/null +++ b/apps/live/package.json @@ -0,0 +1,64 @@ +{ + "name": "live", + "version": "0.28.0", + "license": "AGPL-3.0", + "description": "A realtime collaborative server powers Plane's rich text editor", + "main": "./src/server.ts", + "private": true, + "type": "module", + "scripts": { + "dev": "tsup --watch --onSuccess 'node --env-file=.env dist/server.js'", + "build": "tsc --noEmit && tsup", + "start": "node --env-file=.env dist/server.js", + "check:lint": "eslint . --max-warnings 0", + "check:types": "tsc --noEmit", + "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "fix:lint": "eslint . --fix", + "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" + }, + "keywords": [], + "author": "", + "dependencies": { + "@hocuspocus/extension-database": "^2.15.0", + "@hocuspocus/extension-logger": "^2.15.0", + "@hocuspocus/extension-redis": "^2.15.0", + "@hocuspocus/server": "^2.15.0", + "@plane/editor": "*", + "@plane/types": "*", + "@tiptap/core": "^2.22.3", + "@tiptap/html": "^2.22.3", + "axios": "1.11.0", + "compression": "1.8.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "express-ws": "^5.0.2", + "helmet": "^7.1.0", + "ioredis": "^5.4.1", + "lodash": "^4.17.21", + "morgan": "1.10.1", + "pino-http": "^10.3.0", + "pino-pretty": "^11.2.2", + "uuid": "^10.0.0", + "y-prosemirror": "^1.2.15", + "y-protocols": "^1.0.6", + "yjs": "^13.6.20" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", + "@types/compression": "1.8.1", + "@types/cors": "^2.8.17", + "@types/dotenv": "^8.2.0", + "@types/express": "^4.17.21", + "@types/express-ws": "^3.0.4", + "@types/node": "^20.14.9", + "@types/pino-http": "^5.8.4", + "concurrently": "^9.0.1", + "nodemon": "^3.1.7", + "ts-node": "^10.9.2", + "tsup": "8.4.0", + "typescript": "5.8.3" + } +} diff --git a/apps/live/src/ce/lib/fetch-document.ts b/apps/live/src/ce/lib/fetch-document.ts new file mode 100644 index 00000000000..f7b4d8ea676 --- /dev/null +++ b/apps/live/src/ce/lib/fetch-document.ts @@ -0,0 +1,14 @@ +// types +import { TDocumentTypes } from "@/core/types/common.js"; + +type TArgs = { + cookie: string | undefined; + documentType: TDocumentTypes | undefined; + pageId: string; + params: URLSearchParams; +}; + +export const fetchDocument = async (args: TArgs): Promise => { + const { documentType } = args; + throw Error(`Fetch failed: Invalid document type ${documentType} provided.`); +}; diff --git a/apps/live/src/ce/lib/update-document.ts b/apps/live/src/ce/lib/update-document.ts new file mode 100644 index 00000000000..cbef54e7466 --- /dev/null +++ b/apps/live/src/ce/lib/update-document.ts @@ -0,0 +1,15 @@ +// types +import { TDocumentTypes } from "@/core/types/common.js"; + +type TArgs = { + cookie: string | undefined; + documentType: TDocumentTypes | undefined; + pageId: string; + params: URLSearchParams; + updatedDescription: Uint8Array; +}; + +export const updateDocument = async (args: TArgs): Promise => { + const { documentType } = args; + throw Error(`Update failed: Invalid document type ${documentType} provided.`); +}; diff --git a/live/src/ce/types/common.d.ts b/apps/live/src/ce/types/common.d.ts similarity index 100% rename from live/src/ce/types/common.d.ts rename to apps/live/src/ce/types/common.d.ts diff --git a/apps/live/src/core/extensions/index.ts b/apps/live/src/core/extensions/index.ts new file mode 100644 index 00000000000..7364169a461 --- /dev/null +++ b/apps/live/src/core/extensions/index.ts @@ -0,0 +1,120 @@ +// Third-party libraries +import { Redis } from "ioredis"; +// Hocuspocus extensions and core +import { Database } from "@hocuspocus/extension-database"; +import { Extension } from "@hocuspocus/server"; +import { Logger } from "@hocuspocus/extension-logger"; +import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis"; +// core helpers and utilities +import { manualLogger } from "@/core/helpers/logger.js"; +import { getRedisUrl } from "@/core/lib/utils/redis-url.js"; +// core libraries +import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js"; +// plane live libraries +import { fetchDocument } from "@/plane-live/lib/fetch-document.js"; +import { updateDocument } from "@/plane-live/lib/update-document.js"; +// types +import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js"; + +export const getExtensions: () => Promise = async () => { + const extensions: Extension[] = [ + new Logger({ + onChange: false, + log: (message) => { + manualLogger.info(message); + }, + }), + new Database({ + fetch: async ({ context, documentName: pageId, requestParameters }) => { + const cookie = (context as HocusPocusServerContext).cookie; + // query params + const params = requestParameters; + const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined; + // TODO: Fix this lint error. + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + try { + let fetchedData = null; + if (documentType === "project_page") { + fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie); + } else { + fetchedData = await fetchDocument({ + cookie, + documentType, + pageId, + params, + }); + } + resolve(fetchedData); + } catch (error) { + manualLogger.error("Error in fetching document", error); + } + }); + }, + store: async ({ context, state, documentName: pageId, requestParameters }) => { + const cookie = (context as HocusPocusServerContext).cookie; + // query params + const params = requestParameters; + const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined; + + // TODO: Fix this lint error. + // eslint-disable-next-line no-async-promise-executor + return new Promise(async () => { + try { + if (documentType === "project_page") { + await updatePageDescription(params, pageId, state, cookie); + } else { + await updateDocument({ + cookie, + documentType, + pageId, + params, + updatedDescription: state, + }); + } + } catch (error) { + manualLogger.error("Error in updating document:", error); + } + }); + }, + }), + ]; + + const redisUrl = getRedisUrl(); + + if (redisUrl) { + try { + const redisClient = new Redis(redisUrl); + + await new Promise((resolve, reject) => { + redisClient.on("error", (error: any) => { + if (error?.code === "ENOTFOUND" || error.message.includes("WRONGPASS") || error.message.includes("NOAUTH")) { + redisClient.disconnect(); + } + manualLogger.warn( + `Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`, + error + ); + reject(error); + }); + + redisClient.on("ready", () => { + extensions.push(new HocusPocusRedis({ redis: redisClient })); + manualLogger.info("Redis Client connected ✅"); + resolve(); + }); + }); + } catch (error) { + manualLogger.warn( + `Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`, + error + ); + } + } else { + manualLogger.warn( + "Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)" + ); + } + + return extensions; +}; diff --git a/live/src/core/helpers/convert-document.ts b/apps/live/src/core/helpers/convert-document.ts similarity index 100% rename from live/src/core/helpers/convert-document.ts rename to apps/live/src/core/helpers/convert-document.ts diff --git a/live/src/core/helpers/error-handler.ts b/apps/live/src/core/helpers/error-handler.ts similarity index 76% rename from live/src/core/helpers/error-handler.ts rename to apps/live/src/core/helpers/error-handler.ts index 6cc46b6dd7d..fac75f92f22 100644 --- a/live/src/core/helpers/error-handler.ts +++ b/apps/live/src/core/helpers/error-handler.ts @@ -11,10 +11,7 @@ export const errorHandler: ErrorRequestHandler = (err, _req, res) => { // Send the response res.json({ error: { - message: - process.env.NODE_ENV === "production" - ? "An unexpected error occurred" - : err.message, + message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message, ...(process.env.NODE_ENV !== "production" && { stack: err.stack }), }, }); diff --git a/live/src/core/helpers/logger.ts b/apps/live/src/core/helpers/logger.ts similarity index 90% rename from live/src/core/helpers/logger.ts rename to apps/live/src/core/helpers/logger.ts index cba3f7cd153..07efaea6e25 100644 --- a/live/src/core/helpers/logger.ts +++ b/apps/live/src/core/helpers/logger.ts @@ -1,4 +1,5 @@ import { pinoHttp } from "pino-http"; +import { Logger } from "pino"; const transport = { target: "pino-pretty", @@ -35,4 +36,4 @@ export const logger = pinoHttp({ }, }); -export const manualLogger = logger.logger; +export const manualLogger: Logger = logger.logger; diff --git a/apps/live/src/core/helpers/page.ts b/apps/live/src/core/helpers/page.ts new file mode 100644 index 00000000000..d4322d1ad89 --- /dev/null +++ b/apps/live/src/core/helpers/page.ts @@ -0,0 +1,50 @@ +import { getSchema } from "@tiptap/core"; +import { generateHTML, generateJSON } from "@tiptap/html"; +import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; +import * as Y from "yjs"; +// plane editor +import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib"; + +const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps]; +const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS); + +export const getAllDocumentFormatsFromBinaryData = ( + description: Uint8Array +): { + contentBinaryEncoded: string; + contentJSON: object; + contentHTML: string; +} => { + // encode binary description data + const base64Data = Buffer.from(description).toString("base64"); + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, description); + // convert to JSON + const type = yDoc.getXmlFragment("default"); + const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON(); + // convert to HTML + const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS); + + return { + contentBinaryEncoded: base64Data, + contentJSON, + contentHTML, + }; +}; + +export const getBinaryDataFromHTMLString = ( + descriptionHTML: string +): { + contentBinary: Uint8Array; +} => { + // convert HTML to JSON + const contentJSON = generateJSON(descriptionHTML ?? "

", DOCUMENT_EDITOR_EXTENSIONS); + // convert JSON to Y.Doc format + const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default"); + // convert Y.Doc to Uint8Array format + const encodedData = Y.encodeStateAsUpdate(transformedData); + + return { + contentBinary: encodedData, + }; +}; diff --git a/live/src/core/hocuspocus-server.ts b/apps/live/src/core/hocuspocus-server.ts similarity index 91% rename from live/src/core/hocuspocus-server.ts rename to apps/live/src/core/hocuspocus-server.ts index 51896c23bce..072d45cbc27 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/apps/live/src/core/hocuspocus-server.ts @@ -4,10 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import { handleAuthentication } from "@/core/lib/authentication.js"; // extensions import { getExtensions } from "@/core/extensions/index.js"; -import { - DocumentCollaborativeEvents, - TDocumentEventsServer, -} from "@plane/editor/lib"; +import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib"; // editor types import { TUserDetails } from "@plane/editor"; // types @@ -61,8 +58,7 @@ export const getHocusPocusServer = async () => { }, async onStateless({ payload, document }) { // broadcast the client event (derived from the server event) to all the clients so that they can update their state - const response = - DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; + const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client; if (response) { document.broadcastStateless(response); } diff --git a/live/src/core/lib/authentication.ts b/apps/live/src/core/lib/authentication.ts similarity index 100% rename from live/src/core/lib/authentication.ts rename to apps/live/src/core/lib/authentication.ts diff --git a/apps/live/src/core/lib/page.ts b/apps/live/src/core/lib/page.ts new file mode 100644 index 00000000000..7d23d8b195f --- /dev/null +++ b/apps/live/src/core/lib/page.ts @@ -0,0 +1,80 @@ +// helpers +import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js"; +// services +import { PageService } from "@/core/services/page.service.js"; +import { manualLogger } from "../helpers/logger.js"; +const pageService = new PageService(); + +export const updatePageDescription = async ( + params: URLSearchParams, + pageId: string, + updatedDescription: Uint8Array, + cookie: string | undefined +) => { + if (!(updatedDescription instanceof Uint8Array)) { + throw new Error("Invalid updatedDescription: must be an instance of Uint8Array"); + } + + const workspaceSlug = params.get("workspaceSlug")?.toString(); + const projectId = params.get("projectId")?.toString(); + if (!workspaceSlug || !projectId || !cookie) return; + + const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription); + try { + const payload = { + description_binary: contentBinaryEncoded, + description_html: contentHTML, + description: contentJSON, + }; + + await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie); + } catch (error) { + manualLogger.error("Update error:", error); + throw error; + } +}; + +const fetchDescriptionHTMLAndTransform = async ( + workspaceSlug: string, + projectId: string, + pageId: string, + cookie: string +) => { + if (!workspaceSlug || !projectId || !cookie) return; + + try { + const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie); + const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "

"); + return contentBinary; + } catch (error) { + manualLogger.error("Error while transforming from HTML to Uint8Array", error); + throw error; + } +}; + +export const fetchPageDescriptionBinary = async ( + params: URLSearchParams, + pageId: string, + cookie: string | undefined +) => { + const workspaceSlug = params.get("workspaceSlug")?.toString(); + const projectId = params.get("projectId")?.toString(); + if (!workspaceSlug || !projectId || !cookie) return null; + + try { + const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie); + const binaryData = new Uint8Array(response); + + if (binaryData.byteLength === 0) { + const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie); + if (binary) { + return binary; + } + } + + return binaryData; + } catch (error) { + manualLogger.error("Fetch error:", error); + throw error; + } +}; diff --git a/live/src/core/lib/utils/redis-url.ts b/apps/live/src/core/lib/utils/redis-url.ts similarity index 100% rename from live/src/core/lib/utils/redis-url.ts rename to apps/live/src/core/lib/utils/redis-url.ts diff --git a/live/src/core/services/api.service.ts b/apps/live/src/core/services/api.service.ts similarity index 100% rename from live/src/core/services/api.service.ts rename to apps/live/src/core/services/api.service.ts diff --git a/apps/live/src/core/services/page.service.ts b/apps/live/src/core/services/page.service.ts new file mode 100644 index 00000000000..9c1ed823777 --- /dev/null +++ b/apps/live/src/core/services/page.service.ts @@ -0,0 +1,58 @@ +// types +import { TPage } from "@plane/types"; +// services +import { API_BASE_URL, APIService } from "@/core/services/api.service.js"; + +export class PageService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, { + headers: { + Cookie: cookie, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, { + headers: { + "Content-Type": "application/octet-stream", + Cookie: cookie, + }, + responseType: "arraybuffer", + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async updateDescription( + workspaceSlug: string, + projectId: string, + pageId: string, + data: { + description_binary: string; + description_html: string; + description: object; + }, + cookie: string + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, { + headers: { + Cookie: cookie, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/live/src/core/services/user.service.ts b/apps/live/src/core/services/user.service.ts similarity index 100% rename from live/src/core/services/user.service.ts rename to apps/live/src/core/services/user.service.ts diff --git a/live/src/core/types/common.d.ts b/apps/live/src/core/types/common.d.ts similarity index 100% rename from live/src/core/types/common.d.ts rename to apps/live/src/core/types/common.d.ts diff --git a/apps/live/src/ee/lib/fetch-document.ts b/apps/live/src/ee/lib/fetch-document.ts new file mode 100644 index 00000000000..33aa90bba37 --- /dev/null +++ b/apps/live/src/ee/lib/fetch-document.ts @@ -0,0 +1 @@ +export * from "../../ce/lib/fetch-document.js"; diff --git a/apps/live/src/ee/lib/update-document.ts b/apps/live/src/ee/lib/update-document.ts new file mode 100644 index 00000000000..0f9c964e778 --- /dev/null +++ b/apps/live/src/ee/lib/update-document.ts @@ -0,0 +1 @@ +export * from "../../ce/lib/update-document.js"; diff --git a/apps/live/src/ee/types/common.d.ts b/apps/live/src/ee/types/common.d.ts new file mode 100644 index 00000000000..4f11c54d02a --- /dev/null +++ b/apps/live/src/ee/types/common.d.ts @@ -0,0 +1 @@ +export * from "../../ce/types/common.js"; diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts new file mode 100644 index 00000000000..c4a35353891 --- /dev/null +++ b/apps/live/src/server.ts @@ -0,0 +1,131 @@ +import compression from "compression"; +import cors from "cors"; +import expressWs from "express-ws"; +import express, { Request, Response } from "express"; +import helmet from "helmet"; +// hocuspocus server +import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; +// helpers +import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js"; +import { logger, manualLogger } from "@/core/helpers/logger.js"; +// types +import { TConvertDocumentRequestBody } from "@/core/types/common.js"; + +export class Server { + private app: any; + private router: any; + private hocuspocusServer: any; + private serverInstance: any; + + constructor() { + this.app = express(); + this.router = express.Router(); + expressWs(this.app); + this.app.set("port", process.env.PORT || 3000); + this.setupMiddleware(); + this.setupHocusPocus(); + this.setupRoutes(); + } + + private setupMiddleware() { + // Security middleware + this.app.use(helmet()); + // Middleware for response compression + this.app.use(compression({ level: 6, threshold: 5 * 1000 })); + // Logging middleware + this.app.use(logger); + // Body parsing middleware + this.app.use(express.json()); + this.app.use(express.urlencoded({ extended: true })); + // cors middleware + this.app.use(cors()); + this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router); + } + + private async setupHocusPocus() { + this.hocuspocusServer = await getHocusPocusServer().catch((err) => { + manualLogger.error("Failed to initialize HocusPocusServer:", err); + process.exit(1); + }); + } + + private setupRoutes() { + this.router.get("/health", (_req: Request, res: Response) => { + res.status(200).json({ status: "OK" }); + }); + + this.router.ws("/collaboration", (ws: any, req: Request) => { + try { + this.hocuspocusServer.handleConnection(ws, req); + } catch (err) { + manualLogger.error("WebSocket connection error:", err); + ws.close(); + } + }); + + this.router.post("/convert-document", (req: Request, res: Response) => { + const { description_html, variant } = req.body as TConvertDocumentRequestBody; + try { + if (description_html === undefined || variant === undefined) { + res.status(400).send({ + message: "Missing required fields", + }); + return; + } + const { description, description_binary } = convertHTMLDocumentToAllFormats({ + document_html: description_html, + variant, + }); + res.status(200).json({ + description, + description_binary, + }); + } catch (error) { + manualLogger.error("Error in /convert-document endpoint:", error); + res.status(500).json({ + message: `Internal server error.`, + }); + } + }); + + this.app.use((_req: Request, res: Response) => { + res.status(404).json({ + message: "Not Found", + }); + }); + } + + public listen() { + this.serverInstance = this.app.listen(this.app.get("port"), () => { + manualLogger.info(`Plane Live server has started at port ${this.app.get("port")}`); + }); + } + + public async destroy() { + // Close the HocusPocus server WebSocket connections + await this.hocuspocusServer.destroy(); + manualLogger.info("HocusPocus server WebSocket connections closed gracefully."); + // Close the Express server + this.serverInstance.close(() => { + manualLogger.info("Express server closed gracefully."); + process.exit(1); + }); + } +} + +const server = new Server(); +server.listen(); + +// Graceful shutdown on unhandled rejection +process.on("unhandledRejection", async (err: any) => { + manualLogger.info("Unhandled Rejection: ", err); + manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`); + await server.destroy(); +}); + +// Graceful shutdown on uncaught exception +process.on("uncaughtException", async (err: any) => { + manualLogger.info("Uncaught Exception: ", err); + manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`); + await server.destroy(); +}); diff --git a/apps/live/tsconfig.json b/apps/live/tsconfig.json new file mode 100644 index 00000000000..810a68a5cbf --- /dev/null +++ b/apps/live/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "module": "ES2015", + "moduleResolution": "Bundler", + "lib": ["ES2015"], + "target": "ES2015", + "outDir": "./dist", + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@/plane-live/*": ["./src/ce/*"] + }, + "removeComments": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "sourceRoot": "/" + }, + "include": ["src/**/*.ts", "tsup.config.ts"], + "exclude": ["./dist", "./build", "./node_modules"] +} diff --git a/apps/live/tsup.config.ts b/apps/live/tsup.config.ts new file mode 100644 index 00000000000..05fbe7e86c0 --- /dev/null +++ b/apps/live/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/server.ts"], + format: ["esm", "cjs"], + dts: true, + splitting: false, + sourcemap: true, + minify: false, + target: "node18", + outDir: "dist", + env: { + NODE_ENV: process.env.NODE_ENV || "development", + }, +}); diff --git a/apps/proxy/Caddyfile.ce b/apps/proxy/Caddyfile.ce new file mode 100644 index 00000000000..7f8fc79f758 --- /dev/null +++ b/apps/proxy/Caddyfile.ce @@ -0,0 +1,34 @@ +(plane_proxy) { + request_body { + max_size {$FILE_SIZE_LIMIT} + } + + reverse_proxy /spaces/* space:3000 + + reverse_proxy /god-mode/* admin:3000 + + reverse_proxy /live/* live:3000 + + reverse_proxy /api/* api:8000 + + reverse_proxy /auth/* api:8000 + + reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000 + + reverse_proxy /* web:3000 +} + +{ + {$CERT_EMAIL} + acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory} + {$CERT_ACME_DNS} + servers { + max_header_size 25MB + client_ip_headers X-Forwarded-For X-Real-IP + trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0} + } +} + +{$SITE_ADDRESS} { + import plane_proxy +} \ No newline at end of file diff --git a/apps/proxy/Dockerfile.ce b/apps/proxy/Dockerfile.ce new file mode 100644 index 00000000000..4d2f6dd0af7 --- /dev/null +++ b/apps/proxy/Dockerfile.ce @@ -0,0 +1,14 @@ +FROM caddy:2.10.0-builder-alpine AS caddy-builder + +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare@v0.2.1 \ + --with github.com/caddy-dns/digitalocean@04bde2867106aa1b44c2f9da41a285fa02e629c5 \ + --with github.com/mholt/caddy-l4@4d3c80e89c5f80438a3e048a410d5543ff5fb9f4 + +FROM caddy:2.10.0-builder-alpine + +RUN apk add --no-cache nss-tools bash curl + +COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy + +COPY Caddyfile.ce /etc/caddy/Caddyfile \ No newline at end of file diff --git a/space/.env.example b/apps/space/.env.example similarity index 100% rename from space/.env.example rename to apps/space/.env.example diff --git a/space/.eslintrc.js b/apps/space/.eslintrc.js similarity index 100% rename from space/.eslintrc.js rename to apps/space/.eslintrc.js diff --git a/space/.gitignore b/apps/space/.gitignore similarity index 100% rename from space/.gitignore rename to apps/space/.gitignore diff --git a/space/.prettierignore b/apps/space/.prettierignore similarity index 100% rename from space/.prettierignore rename to apps/space/.prettierignore diff --git a/space/.prettierrc.json b/apps/space/.prettierrc.json similarity index 100% rename from space/.prettierrc.json rename to apps/space/.prettierrc.json diff --git a/apps/space/Dockerfile.dev b/apps/space/Dockerfile.dev new file mode 100644 index 00000000000..f735e0d84d8 --- /dev/null +++ b/apps/space/Dockerfile.dev @@ -0,0 +1,16 @@ +FROM node:22-alpine +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . + +RUN yarn global add turbo +RUN yarn install + +EXPOSE 4000 + +ENV NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +VOLUME [ "/app/node_modules", "/app/space/node_modules"] +CMD ["yarn","dev", "--filter=space"] diff --git a/space/Dockerfile.space b/apps/space/Dockerfile.space similarity index 78% rename from space/Dockerfile.space rename to apps/space/Dockerfile.space index ecb3fbec78d..525d72f7b0d 100644 --- a/space/Dockerfile.space +++ b/apps/space/Dockerfile.space @@ -1,4 +1,5 @@ -FROM node:20-alpine as base +FROM node:22-alpine AS base + # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** @@ -45,6 +46,9 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + RUN yarn turbo run build --filter=space # ***************************************************************************** @@ -53,13 +57,16 @@ RUN yarn turbo run build --filter=space FROM base AS runner WORKDIR /app -COPY --from=installer /app/space/next.config.js . -COPY --from=installer /app/space/package.json . +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs -COPY --from=installer /app/space/.next/standalone ./ - -COPY --from=installer /app/space/.next ./space/.next -COPY --from=installer /app/space/public ./space/public +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=installer /app/apps/space/.next/standalone ./ +COPY --from=installer /app/apps/space/.next/static ./apps/space/.next/static +COPY --from=installer /app/apps/space/public ./apps/space/public ARG NEXT_PUBLIC_API_BASE_URL="" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL @@ -79,7 +86,9 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 EXPOSE 3000 + +CMD ["node", "apps/space/server.js"] \ No newline at end of file diff --git a/space/README.md b/apps/space/README.md similarity index 100% rename from space/README.md rename to apps/space/README.md diff --git a/space/additional.d.ts b/apps/space/additional.d.ts similarity index 100% rename from space/additional.d.ts rename to apps/space/additional.d.ts diff --git a/space/app/[workspaceSlug]/[projectId]/page.ts b/apps/space/app/[workspaceSlug]/[projectId]/page.ts similarity index 100% rename from space/app/[workspaceSlug]/[projectId]/page.ts rename to apps/space/app/[workspaceSlug]/[projectId]/page.ts diff --git a/space/app/error.tsx b/apps/space/app/error.tsx similarity index 100% rename from space/app/error.tsx rename to apps/space/app/error.tsx diff --git a/space/app/issues/[anchor]/client-layout.tsx b/apps/space/app/issues/[anchor]/client-layout.tsx similarity index 100% rename from space/app/issues/[anchor]/client-layout.tsx rename to apps/space/app/issues/[anchor]/client-layout.tsx diff --git a/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx similarity index 100% rename from space/app/issues/[anchor]/layout.tsx rename to apps/space/app/issues/[anchor]/layout.tsx diff --git a/space/app/issues/[anchor]/page.tsx b/apps/space/app/issues/[anchor]/page.tsx similarity index 100% rename from space/app/issues/[anchor]/page.tsx rename to apps/space/app/issues/[anchor]/page.tsx diff --git a/apps/space/app/layout.tsx b/apps/space/app/layout.tsx new file mode 100644 index 00000000000..d0c7435da93 --- /dev/null +++ b/apps/space/app/layout.tsx @@ -0,0 +1,43 @@ +import { Metadata } from "next"; +// helpers +import { SPACE_BASE_PATH } from "@plane/constants"; +// styles +import "@/styles/globals.css"; +// components +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane Publish | Make your Plane boards public with one-click", + description: "Plane Publish is a customer feedback management tool built on top of plane.so", + openGraph: { + title: "Plane Publish | Make your Plane boards public with one-click", + description: "Plane Publish is a customer feedback management tool built on top of plane.so", + url: "https://sites.plane.so/", + }, + keywords: + "software development, customer feedback, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + + +
+ + <>{children} + + + + ); +} diff --git a/space/app/not-found.tsx b/apps/space/app/not-found.tsx similarity index 100% rename from space/app/not-found.tsx rename to apps/space/app/not-found.tsx diff --git a/space/app/page.tsx b/apps/space/app/page.tsx similarity index 100% rename from space/app/page.tsx rename to apps/space/app/page.tsx diff --git a/space/app/provider.tsx b/apps/space/app/provider.tsx similarity index 100% rename from space/app/provider.tsx rename to apps/space/app/provider.tsx diff --git a/space/app/views/[anchor]/layout.tsx b/apps/space/app/views/[anchor]/layout.tsx similarity index 100% rename from space/app/views/[anchor]/layout.tsx rename to apps/space/app/views/[anchor]/layout.tsx diff --git a/space/app/views/[anchor]/page.tsx b/apps/space/app/views/[anchor]/page.tsx similarity index 100% rename from space/app/views/[anchor]/page.tsx rename to apps/space/app/views/[anchor]/page.tsx diff --git a/space/ce/components/editor/embeds/index.ts b/apps/space/ce/components/editor/embeds/index.ts similarity index 100% rename from space/ce/components/editor/embeds/index.ts rename to apps/space/ce/components/editor/embeds/index.ts diff --git a/space/ce/components/editor/embeds/mentions/index.ts b/apps/space/ce/components/editor/embeds/mentions/index.ts similarity index 100% rename from space/ce/components/editor/embeds/mentions/index.ts rename to apps/space/ce/components/editor/embeds/mentions/index.ts diff --git a/space/ce/components/editor/embeds/mentions/root.tsx b/apps/space/ce/components/editor/embeds/mentions/root.tsx similarity index 100% rename from space/ce/components/editor/embeds/mentions/root.tsx rename to apps/space/ce/components/editor/embeds/mentions/root.tsx diff --git a/space/ce/components/editor/index.ts b/apps/space/ce/components/editor/index.ts similarity index 100% rename from space/ce/components/editor/index.ts rename to apps/space/ce/components/editor/index.ts diff --git a/space/ce/components/issue-layouts/root.tsx b/apps/space/ce/components/issue-layouts/root.tsx similarity index 100% rename from space/ce/components/issue-layouts/root.tsx rename to apps/space/ce/components/issue-layouts/root.tsx diff --git a/space/ce/components/navbar/index.tsx b/apps/space/ce/components/navbar/index.tsx similarity index 100% rename from space/ce/components/navbar/index.tsx rename to apps/space/ce/components/navbar/index.tsx diff --git a/space/ce/hooks/store/index.ts b/apps/space/ce/hooks/store/index.ts similarity index 100% rename from space/ce/hooks/store/index.ts rename to apps/space/ce/hooks/store/index.ts diff --git a/space/ce/hooks/store/use-published-view.ts b/apps/space/ce/hooks/store/use-published-view.ts similarity index 100% rename from space/ce/hooks/store/use-published-view.ts rename to apps/space/ce/hooks/store/use-published-view.ts diff --git a/space/ce/store/root.store.ts b/apps/space/ce/store/root.store.ts similarity index 100% rename from space/ce/store/root.store.ts rename to apps/space/ce/store/root.store.ts diff --git a/space/core/components/account/auth-forms/auth-banner.tsx b/apps/space/core/components/account/auth-forms/auth-banner.tsx similarity index 100% rename from space/core/components/account/auth-forms/auth-banner.tsx rename to apps/space/core/components/account/auth-forms/auth-banner.tsx diff --git a/space/core/components/account/auth-forms/auth-header.tsx b/apps/space/core/components/account/auth-forms/auth-header.tsx similarity index 100% rename from space/core/components/account/auth-forms/auth-header.tsx rename to apps/space/core/components/account/auth-forms/auth-header.tsx diff --git a/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx similarity index 100% rename from space/core/components/account/auth-forms/auth-root.tsx rename to apps/space/core/components/account/auth-forms/auth-root.tsx diff --git a/space/core/components/account/auth-forms/email.tsx b/apps/space/core/components/account/auth-forms/email.tsx similarity index 100% rename from space/core/components/account/auth-forms/email.tsx rename to apps/space/core/components/account/auth-forms/email.tsx diff --git a/space/core/components/account/auth-forms/index.ts b/apps/space/core/components/account/auth-forms/index.ts similarity index 100% rename from space/core/components/account/auth-forms/index.ts rename to apps/space/core/components/account/auth-forms/index.ts diff --git a/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx similarity index 100% rename from space/core/components/account/auth-forms/password.tsx rename to apps/space/core/components/account/auth-forms/password.tsx diff --git a/apps/space/core/components/account/auth-forms/unique-code.tsx b/apps/space/core/components/account/auth-forms/unique-code.tsx new file mode 100644 index 00000000000..abf233a297d --- /dev/null +++ b/apps/space/core/components/account/auth-forms/unique-code.tsx @@ -0,0 +1,152 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { CircleCheck, XCircle } from "lucide-react"; +// plane imports +import { API_BASE_URL } from "@plane/constants"; +import { AuthService } from "@plane/services"; +import { Button, Input, Spinner } from "@plane/ui"; +// hooks +import useTimer from "@/hooks/use-timer"; +// types +import { EAuthModes } from "@/types/auth"; + +// services +const authService = new AuthService(); + +type TAuthUniqueCodeForm = { + mode: EAuthModes; + email: string; + nextPath: string | undefined; + handleEmailClear: () => void; + generateEmailUniqueCode: (email: string) => Promise<{ code: string } | undefined>; +}; + +type TUniqueCodeFormValues = { + email: string; + code: string; +}; + +const defaultValues: TUniqueCodeFormValues = { + email: "", + code: "", +}; + +export const AuthUniqueCodeForm: React.FC = (props) => { + const { mode, email, nextPath, handleEmailClear, generateEmailUniqueCode } = props; + // derived values + const defaultResetTimerValue = 5; + // states + const [uniqueCodeFormData, setUniqueCodeFormData] = useState({ ...defaultValues, email }); + const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + const handleFormChange = (key: keyof TUniqueCodeFormValues, value: string) => + setUniqueCodeFormData((prev) => ({ ...prev, [key]: value })); + + const generateNewCode = async (email: string) => { + try { + setIsRequestingNewCode(true); + const uniqueCode = await generateEmailUniqueCode(email); + setResendCodeTimer(defaultResetTimerValue); + handleFormChange("code", uniqueCode?.code || ""); + setIsRequestingNewCode(false); + } catch { + setResendCodeTimer(0); + console.error("Error while requesting new code"); + setIsRequestingNewCode(false); + } + }; + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isRequestNewCodeDisabled = isRequestingNewCode || resendTimerCode > 0; + const isButtonDisabled = isRequestingNewCode || !uniqueCodeFormData.code || isSubmitting; + + return ( +
setIsSubmitting(true)} + onError={() => setIsSubmitting(false)} + > + + + +
+ +
+ handleFormChange("email", e.target.value)} + placeholder="name@company.com" + className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} + disabled + /> + {uniqueCodeFormData.email.length > 0 && ( + + )} +
+
+ +
+ + handleFormChange("code", e.target.value)} + placeholder="gets-sets-flys" + className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus + /> +
+

+ + Paste the code sent to your email +

+ +
+
+ +
+ +
+
+ ); +}; diff --git a/space/core/components/account/helpers/index.ts b/apps/space/core/components/account/helpers/index.ts similarity index 100% rename from space/core/components/account/helpers/index.ts rename to apps/space/core/components/account/helpers/index.ts diff --git a/space/core/components/account/helpers/password-strength-meter.tsx b/apps/space/core/components/account/helpers/password-strength-meter.tsx similarity index 100% rename from space/core/components/account/helpers/password-strength-meter.tsx rename to apps/space/core/components/account/helpers/password-strength-meter.tsx diff --git a/space/core/components/account/index.ts b/apps/space/core/components/account/index.ts similarity index 100% rename from space/core/components/account/index.ts rename to apps/space/core/components/account/index.ts diff --git a/space/core/components/account/oauth/github-button.tsx b/apps/space/core/components/account/oauth/github-button.tsx similarity index 100% rename from space/core/components/account/oauth/github-button.tsx rename to apps/space/core/components/account/oauth/github-button.tsx diff --git a/space/core/components/account/oauth/gitlab-button.tsx b/apps/space/core/components/account/oauth/gitlab-button.tsx similarity index 100% rename from space/core/components/account/oauth/gitlab-button.tsx rename to apps/space/core/components/account/oauth/gitlab-button.tsx diff --git a/space/core/components/account/oauth/google-button.tsx b/apps/space/core/components/account/oauth/google-button.tsx similarity index 100% rename from space/core/components/account/oauth/google-button.tsx rename to apps/space/core/components/account/oauth/google-button.tsx diff --git a/space/core/components/account/oauth/index.ts b/apps/space/core/components/account/oauth/index.ts similarity index 100% rename from space/core/components/account/oauth/index.ts rename to apps/space/core/components/account/oauth/index.ts diff --git a/space/core/components/account/oauth/oauth-options.tsx b/apps/space/core/components/account/oauth/oauth-options.tsx similarity index 100% rename from space/core/components/account/oauth/oauth-options.tsx rename to apps/space/core/components/account/oauth/oauth-options.tsx diff --git a/space/core/components/account/terms-and-conditions.tsx b/apps/space/core/components/account/terms-and-conditions.tsx similarity index 100% rename from space/core/components/account/terms-and-conditions.tsx rename to apps/space/core/components/account/terms-and-conditions.tsx diff --git a/space/core/components/account/user-logged-in.tsx b/apps/space/core/components/account/user-logged-in.tsx similarity index 100% rename from space/core/components/account/user-logged-in.tsx rename to apps/space/core/components/account/user-logged-in.tsx diff --git a/space/core/components/common/index.ts b/apps/space/core/components/common/index.ts similarity index 100% rename from space/core/components/common/index.ts rename to apps/space/core/components/common/index.ts diff --git a/space/core/components/common/logo-spinner.tsx b/apps/space/core/components/common/logo-spinner.tsx similarity index 100% rename from space/core/components/common/logo-spinner.tsx rename to apps/space/core/components/common/logo-spinner.tsx diff --git a/space/core/components/common/powered-by.tsx b/apps/space/core/components/common/powered-by.tsx similarity index 100% rename from space/core/components/common/powered-by.tsx rename to apps/space/core/components/common/powered-by.tsx diff --git a/space/core/components/common/project-logo.tsx b/apps/space/core/components/common/project-logo.tsx similarity index 100% rename from space/core/components/common/project-logo.tsx rename to apps/space/core/components/common/project-logo.tsx diff --git a/space/core/components/editor/embeds/index.ts b/apps/space/core/components/editor/embeds/index.ts similarity index 100% rename from space/core/components/editor/embeds/index.ts rename to apps/space/core/components/editor/embeds/index.ts diff --git a/space/core/components/editor/embeds/mentions/index.ts b/apps/space/core/components/editor/embeds/mentions/index.ts similarity index 100% rename from space/core/components/editor/embeds/mentions/index.ts rename to apps/space/core/components/editor/embeds/mentions/index.ts diff --git a/space/core/components/editor/embeds/mentions/root.tsx b/apps/space/core/components/editor/embeds/mentions/root.tsx similarity index 100% rename from space/core/components/editor/embeds/mentions/root.tsx rename to apps/space/core/components/editor/embeds/mentions/root.tsx diff --git a/space/core/components/editor/embeds/mentions/user.tsx b/apps/space/core/components/editor/embeds/mentions/user.tsx similarity index 100% rename from space/core/components/editor/embeds/mentions/user.tsx rename to apps/space/core/components/editor/embeds/mentions/user.tsx diff --git a/apps/space/core/components/editor/index.ts b/apps/space/core/components/editor/index.ts new file mode 100644 index 00000000000..de164c8376d --- /dev/null +++ b/apps/space/core/components/editor/index.ts @@ -0,0 +1,5 @@ +export * from "./embeds"; +export * from "./lite-text-editor"; +export * from "./lite-text-read-only-editor"; +export * from "./rich-text-editor"; +export * from "./toolbar"; diff --git a/space/core/components/editor/lite-text-editor.tsx b/apps/space/core/components/editor/lite-text-editor.tsx similarity index 100% rename from space/core/components/editor/lite-text-editor.tsx rename to apps/space/core/components/editor/lite-text-editor.tsx diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/apps/space/core/components/editor/lite-text-read-only-editor.tsx similarity index 100% rename from space/core/components/editor/lite-text-read-only-editor.tsx rename to apps/space/core/components/editor/lite-text-read-only-editor.tsx diff --git a/apps/space/core/components/editor/rich-text-editor.tsx b/apps/space/core/components/editor/rich-text-editor.tsx new file mode 100644 index 00000000000..7a9178f65b6 --- /dev/null +++ b/apps/space/core/components/editor/rich-text-editor.tsx @@ -0,0 +1,56 @@ +import React, { forwardRef } from "react"; +// plane imports +import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from "@plane/editor"; +import { MakeOptional } from "@plane/types"; +// components +import { EditorMentionsRoot } from "@/components/editor"; +// helpers +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// store hooks +import { useMember } from "@/hooks/store"; + +type RichTextEditorWrapperProps = MakeOptional< + Omit, + "disabledExtensions" | "flaggedExtensions" +> & { + anchor: string; + workspaceId: string; +} & ( + | { + editable: false; + } + | { + editable: true; + uploadFile: TFileHandler["upload"]; + } + ); + +export const RichTextEditor = forwardRef((props, ref) => { + const { anchor, containerClassName, editable, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props; + const { getMemberById } = useMember(); + return ( + , + getMentionedEntityDetails: (id: string) => ({ + display_name: getMemberById(id)?.member__display_name ?? "", + }), + }} + ref={ref} + disabledExtensions={disabledExtensions ?? []} + editable={editable} + fileHandler={getEditorFileHandlers({ + anchor, + uploadFile: editable ? props.uploadFile : async () => "", + workspaceId, + })} + flaggedExtensions={flaggedExtensions ?? []} + {...rest} + containerClassName={containerClassName} + editorClassName="min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden" + displayConfig={{ fontSize: "large-font" }} + /> + ); +}); + +RichTextEditor.displayName = "RichTextEditor"; diff --git a/space/core/components/editor/toolbar.tsx b/apps/space/core/components/editor/toolbar.tsx similarity index 100% rename from space/core/components/editor/toolbar.tsx rename to apps/space/core/components/editor/toolbar.tsx diff --git a/space/core/components/instance/index.ts b/apps/space/core/components/instance/index.ts similarity index 100% rename from space/core/components/instance/index.ts rename to apps/space/core/components/instance/index.ts diff --git a/space/core/components/instance/instance-failure-view.tsx b/apps/space/core/components/instance/instance-failure-view.tsx similarity index 100% rename from space/core/components/instance/instance-failure-view.tsx rename to apps/space/core/components/instance/instance-failure-view.tsx diff --git a/space/core/components/issues/filters/applied-filters/filters-list.tsx b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx similarity index 100% rename from space/core/components/issues/filters/applied-filters/filters-list.tsx rename to apps/space/core/components/issues/filters/applied-filters/filters-list.tsx diff --git a/space/core/components/issues/filters/applied-filters/label.tsx b/apps/space/core/components/issues/filters/applied-filters/label.tsx similarity index 100% rename from space/core/components/issues/filters/applied-filters/label.tsx rename to apps/space/core/components/issues/filters/applied-filters/label.tsx diff --git a/space/core/components/issues/filters/applied-filters/priority.tsx b/apps/space/core/components/issues/filters/applied-filters/priority.tsx similarity index 100% rename from space/core/components/issues/filters/applied-filters/priority.tsx rename to apps/space/core/components/issues/filters/applied-filters/priority.tsx diff --git a/apps/space/core/components/issues/filters/applied-filters/root.tsx b/apps/space/core/components/issues/filters/applied-filters/root.tsx new file mode 100644 index 00000000000..af7be085d4c --- /dev/null +++ b/apps/space/core/components/issues/filters/applied-filters/root.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, useCallback } from "react"; +import cloneDeep from "lodash/cloneDeep"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// hooks +import { useIssueFilter } from "@/hooks/store"; +// store +import { TIssueQueryFilters } from "@/types/issue"; +// components +import { AppliedFiltersList } from "./filters-list"; + +type TIssueAppliedFilters = { + anchor: string; +}; + +export const IssueAppliedFilters: FC = observer((props) => { + const { anchor } = props; + // router + const router = useRouter(); + // store hooks + const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); + // derived values + const issueFilters = getIssueFilters(anchor); + const activeLayout = issueFilters?.display_filters?.layout || undefined; + const userFilters = issueFilters?.filters || {}; + + const appliedFilters: any = {}; + Object.entries(userFilters).forEach(([key, value]) => { + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + appliedFilters[key] = value; + }); + + const updateRouteParams = useCallback( + (key: keyof TIssueQueryFilters, value: string[]) => { + const state = key === "state" ? value : (issueFilters?.filters?.state ?? []); + const priority = key === "priority" ? value : (issueFilters?.filters?.priority ?? []); + const labels = key === "labels" ? value : (issueFilters?.filters?.labels ?? []); + + let params: any = { board: activeLayout || "list" }; + if (priority.length > 0) params = { ...params, priority: priority.join(",") }; + if (state.length > 0) params = { ...params, states: state.join(",") }; + if (labels.length > 0) params = { ...params, labels: labels.join(",") }; + params = new URLSearchParams(params).toString(); + + router.push(`/issues/${anchor}?${params}`); + }, + [activeLayout, anchor, issueFilters, router] + ); + + const handleFilters = useCallback( + (key: keyof TIssueQueryFilters, value: string | null) => { + let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; + + if (value === null) newValues = []; + else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); + + updateIssueFilters(anchor, "filters", key, newValues); + updateRouteParams(key, newValues); + }, + [anchor, issueFilters, updateIssueFilters, updateRouteParams] + ); + + const handleRemoveAllFilters = () => { + initIssueFilters( + anchor, + { + display_filters: { layout: activeLayout || "list" }, + filters: { + state: [], + priority: [], + labels: [], + }, + }, + true + ); + + router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`); + }; + + if (Object.keys(appliedFilters).length === 0) return null; + + return ( +
+ +
+ ); +}); diff --git a/space/core/components/issues/filters/applied-filters/state.tsx b/apps/space/core/components/issues/filters/applied-filters/state.tsx similarity index 100% rename from space/core/components/issues/filters/applied-filters/state.tsx rename to apps/space/core/components/issues/filters/applied-filters/state.tsx diff --git a/space/core/components/issues/filters/helpers/dropdown.tsx b/apps/space/core/components/issues/filters/helpers/dropdown.tsx similarity index 100% rename from space/core/components/issues/filters/helpers/dropdown.tsx rename to apps/space/core/components/issues/filters/helpers/dropdown.tsx diff --git a/space/core/components/issues/filters/helpers/filter-header.tsx b/apps/space/core/components/issues/filters/helpers/filter-header.tsx similarity index 100% rename from space/core/components/issues/filters/helpers/filter-header.tsx rename to apps/space/core/components/issues/filters/helpers/filter-header.tsx diff --git a/space/core/components/issues/filters/helpers/filter-option.tsx b/apps/space/core/components/issues/filters/helpers/filter-option.tsx similarity index 100% rename from space/core/components/issues/filters/helpers/filter-option.tsx rename to apps/space/core/components/issues/filters/helpers/filter-option.tsx diff --git a/space/core/components/issues/filters/helpers/index.ts b/apps/space/core/components/issues/filters/helpers/index.ts similarity index 100% rename from space/core/components/issues/filters/helpers/index.ts rename to apps/space/core/components/issues/filters/helpers/index.ts diff --git a/space/core/components/issues/filters/index.ts b/apps/space/core/components/issues/filters/index.ts similarity index 100% rename from space/core/components/issues/filters/index.ts rename to apps/space/core/components/issues/filters/index.ts diff --git a/space/core/components/issues/filters/labels.tsx b/apps/space/core/components/issues/filters/labels.tsx similarity index 100% rename from space/core/components/issues/filters/labels.tsx rename to apps/space/core/components/issues/filters/labels.tsx diff --git a/space/core/components/issues/filters/priority.tsx b/apps/space/core/components/issues/filters/priority.tsx similarity index 100% rename from space/core/components/issues/filters/priority.tsx rename to apps/space/core/components/issues/filters/priority.tsx diff --git a/space/core/components/issues/filters/root.tsx b/apps/space/core/components/issues/filters/root.tsx similarity index 100% rename from space/core/components/issues/filters/root.tsx rename to apps/space/core/components/issues/filters/root.tsx diff --git a/space/core/components/issues/filters/selection.tsx b/apps/space/core/components/issues/filters/selection.tsx similarity index 100% rename from space/core/components/issues/filters/selection.tsx rename to apps/space/core/components/issues/filters/selection.tsx diff --git a/space/core/components/issues/filters/state.tsx b/apps/space/core/components/issues/filters/state.tsx similarity index 100% rename from space/core/components/issues/filters/state.tsx rename to apps/space/core/components/issues/filters/state.tsx diff --git a/space/core/components/issues/index.ts b/apps/space/core/components/issues/index.ts similarity index 100% rename from space/core/components/issues/index.ts rename to apps/space/core/components/issues/index.ts diff --git a/space/core/components/issues/issue-layouts/error.tsx b/apps/space/core/components/issues/issue-layouts/error.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/error.tsx rename to apps/space/core/components/issues/issue-layouts/error.tsx diff --git a/space/core/components/issues/issue-layouts/index.ts b/apps/space/core/components/issues/issue-layouts/index.ts similarity index 100% rename from space/core/components/issues/issue-layouts/index.ts rename to apps/space/core/components/issues/issue-layouts/index.ts diff --git a/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx b/apps/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/issue-layout-HOC.tsx rename to apps/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/block-reactions.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/block.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/block.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/block.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/blocks-list.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/default.tsx b/apps/space/core/components/issues/issue-layouts/kanban/default.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/default.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/default.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx diff --git a/space/core/components/issues/issue-layouts/kanban/index.ts b/apps/space/core/components/issues/issue-layouts/kanban/index.ts similarity index 100% rename from space/core/components/issues/issue-layouts/kanban/index.ts rename to apps/space/core/components/issues/issue-layouts/kanban/index.ts diff --git a/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx b/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx new file mode 100644 index 00000000000..e6090a938e5 --- /dev/null +++ b/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +//types +import { + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +import { cn } from "@plane/utils"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// +import { KanbanIssueBlocksList } from "."; + +interface IKanbanGroup { + groupId: string; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + subGroupId: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; +} + +// Loader components +const KanbanIssueBlockLoader = forwardRef((props, ref) => ( + +)); +KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader"; + +export const KanbanGroup = observer((props: IKanbanGroup) => { + const { + groupId, + subGroupId, + subGroupBy, + displayProperties, + groupedIssueIds, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + } = props; + + // hooks + const [intersectionElement, setIntersectionElement] = useState(null); + const columnRef = useRef(null); + + const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef; + + const loadMoreIssuesInThisGroup = useCallback(() => { + loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId); + }, [loadMoreIssues, groupId, subGroupId]); + + const isPaginating = !!getIssueLoader(groupId, subGroupId); + + useIntersectionObserver( + containerRef, + isPaginating ? null : intersectionElement, + loadMoreIssuesInThisGroup, + `0% 100% 100% 100%` + ); + + const isSubGroup = !!subGroupId && subGroupId !== "null"; + + const issueIds = isSubGroup + ? ((groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []) + : ((groupedIssueIds as TGroupedIssues)?.[groupId] ?? []); + + const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0; + const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
+ {" "} + Load More ↓ +
+ ); + + const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults; + + return ( +
+ + + {shouldLoadMore && (isSubGroup ? <>{loadMore} : )} +
+ ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx b/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx similarity index 98% rename from space/core/components/issues/issue-layouts/kanban/swimlanes.tsx rename to apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx index 48dd4047c34..8860e739585 100644 --- a/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -133,12 +133,7 @@ const SubGroupSwimlaneHeader: React.FC = observer( if (subGroupByVisibilityToggle === false) return <>; return (
- +
); })} diff --git a/space/core/components/issues/issue-layouts/list/base-list-root.tsx b/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/list/base-list-root.tsx rename to apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx diff --git a/space/core/components/issues/issue-layouts/list/block.tsx b/apps/space/core/components/issues/issue-layouts/list/block.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/list/block.tsx rename to apps/space/core/components/issues/issue-layouts/list/block.tsx diff --git a/space/core/components/issues/issue-layouts/list/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/list/blocks-list.tsx rename to apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx diff --git a/space/core/components/issues/issue-layouts/list/default.tsx b/apps/space/core/components/issues/issue-layouts/list/default.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/list/default.tsx rename to apps/space/core/components/issues/issue-layouts/list/default.tsx diff --git a/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx rename to apps/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx diff --git a/space/core/components/issues/issue-layouts/list/index.ts b/apps/space/core/components/issues/issue-layouts/list/index.ts similarity index 100% rename from space/core/components/issues/issue-layouts/list/index.ts rename to apps/space/core/components/issues/issue-layouts/list/index.ts diff --git a/space/core/components/issues/issue-layouts/list/list-group.tsx b/apps/space/core/components/issues/issue-layouts/list/list-group.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/list/list-group.tsx rename to apps/space/core/components/issues/issue-layouts/list/list-group.tsx diff --git a/space/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/all-properties.tsx rename to apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx diff --git a/space/core/components/issues/issue-layouts/properties/cycle.tsx b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/cycle.tsx rename to apps/space/core/components/issues/issue-layouts/properties/cycle.tsx diff --git a/space/core/components/issues/issue-layouts/properties/due-date.tsx b/apps/space/core/components/issues/issue-layouts/properties/due-date.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/due-date.tsx rename to apps/space/core/components/issues/issue-layouts/properties/due-date.tsx diff --git a/space/core/components/issues/issue-layouts/properties/index.ts b/apps/space/core/components/issues/issue-layouts/properties/index.ts similarity index 100% rename from space/core/components/issues/issue-layouts/properties/index.ts rename to apps/space/core/components/issues/issue-layouts/properties/index.ts diff --git a/space/core/components/issues/issue-layouts/properties/labels.tsx b/apps/space/core/components/issues/issue-layouts/properties/labels.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/labels.tsx rename to apps/space/core/components/issues/issue-layouts/properties/labels.tsx diff --git a/space/core/components/issues/issue-layouts/properties/member.tsx b/apps/space/core/components/issues/issue-layouts/properties/member.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/member.tsx rename to apps/space/core/components/issues/issue-layouts/properties/member.tsx diff --git a/space/core/components/issues/issue-layouts/properties/modules.tsx b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/modules.tsx rename to apps/space/core/components/issues/issue-layouts/properties/modules.tsx diff --git a/space/core/components/issues/issue-layouts/properties/priority.tsx b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/priority.tsx rename to apps/space/core/components/issues/issue-layouts/properties/priority.tsx diff --git a/space/core/components/issues/issue-layouts/properties/state.tsx b/apps/space/core/components/issues/issue-layouts/properties/state.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/properties/state.tsx rename to apps/space/core/components/issues/issue-layouts/properties/state.tsx diff --git a/space/core/components/issues/issue-layouts/root.tsx b/apps/space/core/components/issues/issue-layouts/root.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/root.tsx rename to apps/space/core/components/issues/issue-layouts/root.tsx diff --git a/space/core/components/issues/issue-layouts/utils.tsx b/apps/space/core/components/issues/issue-layouts/utils.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/utils.tsx rename to apps/space/core/components/issues/issue-layouts/utils.tsx diff --git a/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx similarity index 100% rename from space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx rename to apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx diff --git a/space/core/components/issues/navbar/controls.tsx b/apps/space/core/components/issues/navbar/controls.tsx similarity index 100% rename from space/core/components/issues/navbar/controls.tsx rename to apps/space/core/components/issues/navbar/controls.tsx diff --git a/space/core/components/issues/navbar/index.ts b/apps/space/core/components/issues/navbar/index.ts similarity index 100% rename from space/core/components/issues/navbar/index.ts rename to apps/space/core/components/issues/navbar/index.ts diff --git a/space/core/components/issues/navbar/layout-icon.tsx b/apps/space/core/components/issues/navbar/layout-icon.tsx similarity index 100% rename from space/core/components/issues/navbar/layout-icon.tsx rename to apps/space/core/components/issues/navbar/layout-icon.tsx diff --git a/space/core/components/issues/navbar/layout-selection.tsx b/apps/space/core/components/issues/navbar/layout-selection.tsx similarity index 100% rename from space/core/components/issues/navbar/layout-selection.tsx rename to apps/space/core/components/issues/navbar/layout-selection.tsx diff --git a/space/core/components/issues/navbar/root.tsx b/apps/space/core/components/issues/navbar/root.tsx similarity index 100% rename from space/core/components/issues/navbar/root.tsx rename to apps/space/core/components/issues/navbar/root.tsx diff --git a/space/core/components/issues/navbar/theme.tsx b/apps/space/core/components/issues/navbar/theme.tsx similarity index 100% rename from space/core/components/issues/navbar/theme.tsx rename to apps/space/core/components/issues/navbar/theme.tsx diff --git a/space/core/components/issues/navbar/user-avatar.tsx b/apps/space/core/components/issues/navbar/user-avatar.tsx similarity index 100% rename from space/core/components/issues/navbar/user-avatar.tsx rename to apps/space/core/components/issues/navbar/user-avatar.tsx diff --git a/space/core/components/issues/peek-overview/comment/add-comment.tsx b/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx similarity index 100% rename from space/core/components/issues/peek-overview/comment/add-comment.tsx rename to apps/space/core/components/issues/peek-overview/comment/add-comment.tsx diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx similarity index 100% rename from space/core/components/issues/peek-overview/comment/comment-detail-card.tsx rename to apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx diff --git a/space/core/components/issues/peek-overview/comment/comment-reactions.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx similarity index 100% rename from space/core/components/issues/peek-overview/comment/comment-reactions.tsx rename to apps/space/core/components/issues/peek-overview/comment/comment-reactions.tsx diff --git a/space/core/components/issues/peek-overview/comment/index.ts b/apps/space/core/components/issues/peek-overview/comment/index.ts similarity index 100% rename from space/core/components/issues/peek-overview/comment/index.ts rename to apps/space/core/components/issues/peek-overview/comment/index.ts diff --git a/space/core/components/issues/peek-overview/full-screen-peek-view.tsx b/apps/space/core/components/issues/peek-overview/full-screen-peek-view.tsx similarity index 100% rename from space/core/components/issues/peek-overview/full-screen-peek-view.tsx rename to apps/space/core/components/issues/peek-overview/full-screen-peek-view.tsx diff --git a/space/core/components/issues/peek-overview/header.tsx b/apps/space/core/components/issues/peek-overview/header.tsx similarity index 100% rename from space/core/components/issues/peek-overview/header.tsx rename to apps/space/core/components/issues/peek-overview/header.tsx diff --git a/space/core/components/issues/peek-overview/index.ts b/apps/space/core/components/issues/peek-overview/index.ts similarity index 100% rename from space/core/components/issues/peek-overview/index.ts rename to apps/space/core/components/issues/peek-overview/index.ts diff --git a/space/core/components/issues/peek-overview/issue-activity.tsx b/apps/space/core/components/issues/peek-overview/issue-activity.tsx similarity index 100% rename from space/core/components/issues/peek-overview/issue-activity.tsx rename to apps/space/core/components/issues/peek-overview/issue-activity.tsx diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/apps/space/core/components/issues/peek-overview/issue-details.tsx similarity index 92% rename from space/core/components/issues/peek-overview/issue-details.tsx rename to apps/space/core/components/issues/peek-overview/issue-details.tsx index 32bcbc9bdd1..ad79dfde6e2 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-details.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; // components -import { RichTextReadOnlyEditor } from "@/components/editor"; +import { RichTextEditor } from "@/components/editor"; import { IssueReactions } from "@/components/issues/peek-overview"; import { usePublish } from "@/hooks/store"; // types @@ -25,7 +25,8 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => {

{issueDetails.name}

{description !== "" && description !== "

" && ( - { + const { fetchInstanceInfo, instance, error } = useInstance(); + const { fetchCurrentUser } = useUser(); + const { resolvedTheme } = useTheme(); + + const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; + + useSWR("INSTANCE_INFO", () => fetchInstanceInfo(), { + revalidateOnFocus: false, + revalidateIfStale: false, + errorRetryCount: 0, + }); + useSWR("CURRENT_USER", () => fetchCurrentUser(), { + shouldRetryOnError: false, + revalidateOnFocus: true, + revalidateIfStale: true, + }); + + if (!instance && !error) + return ( +
+ +
+ ); + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + if (error) { + return ( +
+
+
+
+ + Plane logo + +
+
+
+ Plane background pattern +
+
+
+ +
+
+
+
+ ); + } + + return children; +}); diff --git a/space/core/lib/store-provider.tsx b/apps/space/core/lib/store-provider.tsx similarity index 100% rename from space/core/lib/store-provider.tsx rename to apps/space/core/lib/store-provider.tsx diff --git a/space/core/lib/toast-provider.tsx b/apps/space/core/lib/toast-provider.tsx similarity index 100% rename from space/core/lib/toast-provider.tsx rename to apps/space/core/lib/toast-provider.tsx diff --git a/space/core/store/cycle.store.ts b/apps/space/core/store/cycle.store.ts similarity index 100% rename from space/core/store/cycle.store.ts rename to apps/space/core/store/cycle.store.ts diff --git a/space/core/store/helpers/base-issues.store.ts b/apps/space/core/store/helpers/base-issues.store.ts similarity index 100% rename from space/core/store/helpers/base-issues.store.ts rename to apps/space/core/store/helpers/base-issues.store.ts diff --git a/space/core/store/helpers/filter.helpers.ts b/apps/space/core/store/helpers/filter.helpers.ts similarity index 100% rename from space/core/store/helpers/filter.helpers.ts rename to apps/space/core/store/helpers/filter.helpers.ts diff --git a/space/core/store/instance.store.ts b/apps/space/core/store/instance.store.ts similarity index 100% rename from space/core/store/instance.store.ts rename to apps/space/core/store/instance.store.ts diff --git a/space/core/store/issue-detail.store.ts b/apps/space/core/store/issue-detail.store.ts similarity index 99% rename from space/core/store/issue-detail.store.ts rename to apps/space/core/store/issue-detail.store.ts index 71583694600..8eee2f8b55d 100644 --- a/space/core/store/issue-detail.store.ts +++ b/apps/space/core/store/issue-detail.store.ts @@ -5,8 +5,7 @@ import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; // plane imports import { SitesFileService, SitesIssueService } from "@plane/services"; -import { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; -import { EFileAssetType } from "@plane/types/src/enums"; +import { EFileAssetType, TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; // store import { CoreRootStore } from "@/store/root.store"; // types diff --git a/space/core/store/issue-filters.store.ts b/apps/space/core/store/issue-filters.store.ts similarity index 100% rename from space/core/store/issue-filters.store.ts rename to apps/space/core/store/issue-filters.store.ts diff --git a/space/core/store/issue.store.ts b/apps/space/core/store/issue.store.ts similarity index 100% rename from space/core/store/issue.store.ts rename to apps/space/core/store/issue.store.ts diff --git a/space/core/store/label.store.ts b/apps/space/core/store/label.store.ts similarity index 100% rename from space/core/store/label.store.ts rename to apps/space/core/store/label.store.ts diff --git a/space/core/store/members.store.ts b/apps/space/core/store/members.store.ts similarity index 100% rename from space/core/store/members.store.ts rename to apps/space/core/store/members.store.ts diff --git a/space/core/store/module.store.ts b/apps/space/core/store/module.store.ts similarity index 100% rename from space/core/store/module.store.ts rename to apps/space/core/store/module.store.ts diff --git a/apps/space/core/store/profile.store.ts b/apps/space/core/store/profile.store.ts new file mode 100644 index 00000000000..59feb088b29 --- /dev/null +++ b/apps/space/core/store/profile.store.ts @@ -0,0 +1,136 @@ +import set from "lodash/set"; +import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports +import { UserService } from "@plane/services"; +import { EStartOfTheWeek, TUserProfile } from "@plane/types"; +// store +import { CoreRootStore } from "@/store/root.store"; + +type TError = { + status: string; + message: string; +}; + +export interface IProfileStore { + // observables + isLoading: boolean; + error: TError | undefined; + data: TUserProfile; + // actions + fetchUserProfile: () => Promise; + updateUserProfile: (data: Partial) => Promise; +} + +export class ProfileStore implements IProfileStore { + isLoading: boolean = false; + error: TError | undefined = undefined; + data: TUserProfile = { + id: undefined, + user: undefined, + role: undefined, + last_workspace_id: undefined, + theme: { + theme: undefined, + text: undefined, + palette: undefined, + primary: undefined, + background: undefined, + darkPalette: undefined, + sidebarText: undefined, + sidebarBackground: undefined, + }, + onboarding_step: { + workspace_join: false, + profile_complete: false, + workspace_create: false, + workspace_invite: false, + }, + is_onboarded: false, + is_tour_completed: false, + use_case: undefined, + billing_address_country: undefined, + billing_address: undefined, + has_billing_address: false, + created_at: "", + updated_at: "", + language: "", + start_of_the_week: EStartOfTheWeek.SUNDAY, + }; + + // services + userService: UserService; + + constructor(public store: CoreRootStore) { + makeObservable(this, { + // observables + isLoading: observable.ref, + error: observable, + data: observable, + // actions + fetchUserProfile: action, + updateUserProfile: action, + }); + // services + this.userService = new UserService(); + } + + // actions + /** + * @description fetches user profile information + * @returns {Promise} + */ + fetchUserProfile = async () => { + try { + runInAction(() => { + this.isLoading = true; + this.error = undefined; + }); + const userProfile = await this.userService.profile(); + runInAction(() => { + this.isLoading = false; + this.data = userProfile; + }); + return userProfile; + } catch (error) { + runInAction(() => { + this.isLoading = false; + this.error = { + status: "user-profile-fetch-error", + message: "Failed to fetch user profile", + }; + }); + } + }; + + /** + * @description updated the user profile information + * @param {Partial} data + * @returns {Promise} + */ + updateUserProfile = async (data: Partial) => { + const currentUserProfileData = this.data; + try { + if (currentUserProfileData) { + Object.keys(data).forEach((key: string) => { + const userKey: keyof TUserProfile = key as keyof TUserProfile; + if (this.data) set(this.data, userKey, data[userKey]); + }); + } + const userProfile = await this.userService.updateProfile(data); + return userProfile; + } catch (error) { + if (currentUserProfileData) { + Object.keys(currentUserProfileData).forEach((key: string) => { + const userKey: keyof TUserProfile = key as keyof TUserProfile; + if (this.data) set(this.data, userKey, currentUserProfileData[userKey]); + }); + } + runInAction(() => { + this.error = { + status: "user-profile-update-error", + message: "Failed to update user profile", + }; + }); + } + }; +} diff --git a/space/core/store/publish/publish.store.ts b/apps/space/core/store/publish/publish.store.ts similarity index 100% rename from space/core/store/publish/publish.store.ts rename to apps/space/core/store/publish/publish.store.ts diff --git a/space/core/store/publish/publish_list.store.ts b/apps/space/core/store/publish/publish_list.store.ts similarity index 100% rename from space/core/store/publish/publish_list.store.ts rename to apps/space/core/store/publish/publish_list.store.ts diff --git a/space/core/store/root.store.ts b/apps/space/core/store/root.store.ts similarity index 100% rename from space/core/store/root.store.ts rename to apps/space/core/store/root.store.ts diff --git a/space/core/store/state.store.ts b/apps/space/core/store/state.store.ts similarity index 100% rename from space/core/store/state.store.ts rename to apps/space/core/store/state.store.ts diff --git a/space/core/store/user.store.ts b/apps/space/core/store/user.store.ts similarity index 95% rename from space/core/store/user.store.ts rename to apps/space/core/store/user.store.ts index 6765961f54c..438b67aeba6 100644 --- a/space/core/store/user.store.ts +++ b/apps/space/core/store/user.store.ts @@ -1,14 +1,13 @@ +import { AxiosError } from "axios"; import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // plane imports import { UserService } from "@plane/services"; -import { IUser } from "@plane/types"; +import { ActorDetail, IUser } from "@plane/types"; // store types import { ProfileStore, IProfileStore } from "@/store/profile.store"; // store import { CoreRootStore } from "@/store/root.store"; -// types -import { ActorDetail } from "@/types/issue"; type TUserErrorStatus = { status: string; @@ -114,6 +113,9 @@ export class UserStore implements IUserStore { status: "user-fetch-error", message: "Failed to fetch current user", }; + if (error instanceof AxiosError && error.status === 401) { + this.data = undefined; + } }); throw error; } diff --git a/space/core/types/auth.ts b/apps/space/core/types/auth.ts similarity index 100% rename from space/core/types/auth.ts rename to apps/space/core/types/auth.ts diff --git a/space/core/types/cycle.d.ts b/apps/space/core/types/cycle.d.ts similarity index 100% rename from space/core/types/cycle.d.ts rename to apps/space/core/types/cycle.d.ts diff --git a/space/core/types/intake.d.ts b/apps/space/core/types/intake.d.ts similarity index 100% rename from space/core/types/intake.d.ts rename to apps/space/core/types/intake.d.ts diff --git a/apps/space/core/types/issue.d.ts b/apps/space/core/types/issue.d.ts new file mode 100644 index 00000000000..ac7549a8ae7 --- /dev/null +++ b/apps/space/core/types/issue.d.ts @@ -0,0 +1,118 @@ +import { ActorDetail, TIssue, TIssuePriorities, TStateGroups, TIssuePublicComment } from "@plane/types"; + +export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; +export type TIssueLayoutOptions = { + [key in TIssueLayout]: boolean; +}; + +export type TIssueFilterPriorityObject = { + key: TIssuePriorities; + title: string; + className: string; + icon: string; +}; + +export type TIssueFilterKeys = "priority" | "state" | "labels"; + +export type TDisplayFilters = { + layout: TIssueLayout; +}; + +export type TFilters = { + state: TStateGroups[]; + priority: TIssuePriorities[]; + labels: string[]; +}; + +export type TIssueFilters = { + display_filters: TDisplayFilters; + filters: TFilters; +}; + +export type TIssueQueryFilters = Partial; + +export type TIssueQueryFiltersParams = Partial>; + +export interface IIssue + extends Pick< + TIssue, + | "description_html" + | "created_at" + | "updated_at" + | "created_by" + | "id" + | "name" + | "priority" + | "state_id" + | "project_id" + | "sequence_id" + | "sort_order" + | "start_date" + | "target_date" + | "cycle_id" + | "module_ids" + | "label_ids" + | "assignee_ids" + | "attachment_count" + | "sub_issues_count" + | "link_count" + | "estimate_point" + > { + comments: TIssuePublicComment[]; + reaction_items: IIssueReaction[]; + vote_items: IVote[]; +} + +export type IPeekMode = "side" | "modal" | "full"; + +type TIssueResponseResults = + | IIssue[] + | { + [key: string]: { + results: + | IIssue[] + | { + [key: string]: { + results: IIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +}; + +export interface IIssueLabel { + id: string; + name: string; + color: string; + parent: string | null; +} + +export interface IVote { + vote: -1 | 1; + actor_details: ActorDetail; +} + +export interface IIssueReaction { + actor_details: ActorDetail; + reaction: string; +} + +export interface IIssueFilterOptions { + state?: string[] | null; + labels?: string[] | null; + priority?: string[] | null; +} diff --git a/space/core/types/member.d.ts b/apps/space/core/types/member.d.ts similarity index 100% rename from space/core/types/member.d.ts rename to apps/space/core/types/member.d.ts diff --git a/space/core/types/modules.d.ts b/apps/space/core/types/modules.d.ts similarity index 100% rename from space/core/types/modules.d.ts rename to apps/space/core/types/modules.d.ts diff --git a/space/ee/components/editor/index.ts b/apps/space/ee/components/editor/index.ts similarity index 100% rename from space/ee/components/editor/index.ts rename to apps/space/ee/components/editor/index.ts diff --git a/space/ee/components/issue-layouts/root.tsx b/apps/space/ee/components/issue-layouts/root.tsx similarity index 100% rename from space/ee/components/issue-layouts/root.tsx rename to apps/space/ee/components/issue-layouts/root.tsx diff --git a/space/ee/components/navbar/index.tsx b/apps/space/ee/components/navbar/index.tsx similarity index 100% rename from space/ee/components/navbar/index.tsx rename to apps/space/ee/components/navbar/index.tsx diff --git a/space/ee/hooks/store/index.ts b/apps/space/ee/hooks/store/index.ts similarity index 100% rename from space/ee/hooks/store/index.ts rename to apps/space/ee/hooks/store/index.ts diff --git a/space/ee/store/root.store.ts b/apps/space/ee/store/root.store.ts similarity index 100% rename from space/ee/store/root.store.ts rename to apps/space/ee/store/root.store.ts diff --git a/space/helpers/authentication.helper.tsx b/apps/space/helpers/authentication.helper.tsx similarity index 100% rename from space/helpers/authentication.helper.tsx rename to apps/space/helpers/authentication.helper.tsx diff --git a/space/helpers/common.helper.ts b/apps/space/helpers/common.helper.ts similarity index 100% rename from space/helpers/common.helper.ts rename to apps/space/helpers/common.helper.ts diff --git a/space/helpers/date-time.helper.ts b/apps/space/helpers/date-time.helper.ts similarity index 100% rename from space/helpers/date-time.helper.ts rename to apps/space/helpers/date-time.helper.ts diff --git a/space/helpers/editor.helper.ts b/apps/space/helpers/editor.helper.ts similarity index 87% rename from space/helpers/editor.helper.ts rename to apps/space/helpers/editor.helper.ts index e63ba8834c4..1b114ea581f 100644 --- a/space/helpers/editor.helper.ts +++ b/apps/space/helpers/editor.helper.ts @@ -27,16 +27,19 @@ type TArgs = { export const getReadOnlyEditorFileHandlers = (args: Pick): TReadOnlyFileHandler => { const { anchor, workspaceId } = args; + const getAssetSrc = async (path: string) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }; + return { checkIfAssetExists: async () => true, - getAssetSrc: async (path) => { - if (!path) return ""; - if (path?.startsWith("http")) { - return path; - } else { - return getEditorAssetSrc(anchor, path) ?? ""; - } - }, + getAssetDownloadSrc: getAssetSrc, + getAssetSrc: getAssetSrc, restore: async (src: string) => { if (src?.startsWith("http")) { await sitesFileService.restoreOldEditorAsset(workspaceId, src); diff --git a/space/helpers/emoji.helper.tsx b/apps/space/helpers/emoji.helper.tsx similarity index 100% rename from space/helpers/emoji.helper.tsx rename to apps/space/helpers/emoji.helper.tsx diff --git a/space/helpers/file.helper.ts b/apps/space/helpers/file.helper.ts similarity index 100% rename from space/helpers/file.helper.ts rename to apps/space/helpers/file.helper.ts diff --git a/space/helpers/issue.helper.ts b/apps/space/helpers/issue.helper.ts similarity index 100% rename from space/helpers/issue.helper.ts rename to apps/space/helpers/issue.helper.ts diff --git a/space/helpers/password.helper.ts b/apps/space/helpers/password.helper.ts similarity index 100% rename from space/helpers/password.helper.ts rename to apps/space/helpers/password.helper.ts diff --git a/space/helpers/query-param-generator.ts b/apps/space/helpers/query-param-generator.ts similarity index 100% rename from space/helpers/query-param-generator.ts rename to apps/space/helpers/query-param-generator.ts diff --git a/space/helpers/state.helper.ts b/apps/space/helpers/state.helper.ts similarity index 100% rename from space/helpers/state.helper.ts rename to apps/space/helpers/state.helper.ts diff --git a/space/helpers/string.helper.ts b/apps/space/helpers/string.helper.ts similarity index 100% rename from space/helpers/string.helper.ts rename to apps/space/helpers/string.helper.ts diff --git a/web/next-env.d.ts b/apps/space/next-env.d.ts similarity index 100% rename from web/next-env.d.ts rename to apps/space/next-env.d.ts diff --git a/apps/space/next.config.js b/apps/space/next.config.js new file mode 100644 index 00000000000..a736f4f6452 --- /dev/null +++ b/apps/space/next.config.js @@ -0,0 +1,43 @@ +/** @type {import('next').NextConfig} */ + +const nextConfig = { + trailingSlash: true, + output: "standalone", + basePath: process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "", + reactStrictMode: false, + swcMinify: true, + async headers() { + return [ + { + source: "/", + headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }], // clickjacking protection + }, + ]; + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**", + }, + ], + unoptimized: true, + }, + experimental: { + optimizePackageImports: [ + "@plane/constants", + "@plane/editor", + "@plane/hooks", + "@plane/i18n", + "@plane/logger", + "@plane/propel", + "@plane/services", + "@plane/shared-state", + "@plane/types", + "@plane/ui", + "@plane/utils", + ], + }, +}; + +module.exports = nextConfig; diff --git a/apps/space/package.json b/apps/space/package.json new file mode 100644 index 00000000000..848b0e2b9f0 --- /dev/null +++ b/apps/space/package.json @@ -0,0 +1,70 @@ +{ + "name": "space", + "version": "0.28.0", + "private": true, + "license": "AGPL-3.0", + "scripts": { + "dev": "next dev -p 3002", + "build": "next build", + "start": "next start", + "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist", + "check:lint": "eslint . --max-warnings 0", + "check:types": "tsc --noEmit", + "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", + "fix:lint": "eslint . --fix", + "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"" + }, + "dependencies": { + "@blueprintjs/core": "^4.16.3", + "@blueprintjs/popover2": "^1.13.3", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@headlessui/react": "^1.7.13", + "@mui/material": "^5.14.1", + "@plane/constants": "*", + "@plane/editor": "*", + "@plane/i18n": "*", + "@plane/propel": "*", + "@plane/services": "*", + "@plane/types": "*", + "@plane/ui": "*", + "axios": "1.11.0", + "clsx": "^2.0.0", + "date-fns": "^4.1.0", + "dompurify": "^3.0.11", + "dotenv": "^16.3.1", + "lodash": "^4.17.21", + "lowlight": "^2.9.0", + "lucide-react": "^0.469.0", + "mobx": "^6.10.0", + "mobx-react": "^9.1.1", + "mobx-utils": "^6.0.8", + "next": "14.2.30", + "next-themes": "^0.2.1", + "nprogress": "^0.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.3", + "react-hook-form": "7.51.5", + "react-popper": "^2.3.0", + "swr": "^2.2.2", + "tailwind-merge": "^2.0.0", + "uuid": "^9.0.0", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/tailwind-config": "*", + "@plane/typescript-config": "*", + "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.17.1", + "@types/node": "18.14.1", + "@types/nprogress": "^0.2.0", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.2.18", + "@types/uuid": "^9.0.1", + "@types/zxcvbn": "^4.4.4", + "@typescript-eslint/eslint-plugin": "^5.48.2", + "typescript": "5.8.3" + } +} diff --git a/space/postcss.config.js b/apps/space/postcss.config.js similarity index 100% rename from space/postcss.config.js rename to apps/space/postcss.config.js diff --git a/space/public/404.svg b/apps/space/public/404.svg similarity index 100% rename from space/public/404.svg rename to apps/space/public/404.svg diff --git a/space/public/auth/background-pattern-dark.svg b/apps/space/public/auth/background-pattern-dark.svg similarity index 100% rename from space/public/auth/background-pattern-dark.svg rename to apps/space/public/auth/background-pattern-dark.svg diff --git a/space/public/auth/background-pattern.svg b/apps/space/public/auth/background-pattern.svg similarity index 100% rename from space/public/auth/background-pattern.svg rename to apps/space/public/auth/background-pattern.svg diff --git a/space/public/favicon/android-chrome-192x192.png b/apps/space/public/favicon/android-chrome-192x192.png similarity index 100% rename from space/public/favicon/android-chrome-192x192.png rename to apps/space/public/favicon/android-chrome-192x192.png diff --git a/space/public/favicon/android-chrome-512x512.png b/apps/space/public/favicon/android-chrome-512x512.png similarity index 100% rename from space/public/favicon/android-chrome-512x512.png rename to apps/space/public/favicon/android-chrome-512x512.png diff --git a/space/public/favicon/apple-touch-icon.png b/apps/space/public/favicon/apple-touch-icon.png similarity index 100% rename from space/public/favicon/apple-touch-icon.png rename to apps/space/public/favicon/apple-touch-icon.png diff --git a/space/public/favicon/favicon-16x16.png b/apps/space/public/favicon/favicon-16x16.png similarity index 100% rename from space/public/favicon/favicon-16x16.png rename to apps/space/public/favicon/favicon-16x16.png diff --git a/space/public/favicon/favicon-32x32.png b/apps/space/public/favicon/favicon-32x32.png similarity index 100% rename from space/public/favicon/favicon-32x32.png rename to apps/space/public/favicon/favicon-32x32.png diff --git a/space/public/favicon/favicon.ico b/apps/space/public/favicon/favicon.ico similarity index 100% rename from space/public/favicon/favicon.ico rename to apps/space/public/favicon/favicon.ico diff --git a/space/public/favicon/site.webmanifest b/apps/space/public/favicon/site.webmanifest similarity index 100% rename from space/public/favicon/site.webmanifest rename to apps/space/public/favicon/site.webmanifest diff --git a/space/public/images/logo-spinner-dark.gif b/apps/space/public/images/logo-spinner-dark.gif similarity index 100% rename from space/public/images/logo-spinner-dark.gif rename to apps/space/public/images/logo-spinner-dark.gif diff --git a/space/public/images/logo-spinner-light.gif b/apps/space/public/images/logo-spinner-light.gif similarity index 100% rename from space/public/images/logo-spinner-light.gif rename to apps/space/public/images/logo-spinner-light.gif diff --git a/space/public/instance/instance-failure-dark.svg b/apps/space/public/instance/instance-failure-dark.svg similarity index 100% rename from space/public/instance/instance-failure-dark.svg rename to apps/space/public/instance/instance-failure-dark.svg diff --git a/space/public/instance/instance-failure.svg b/apps/space/public/instance/instance-failure.svg similarity index 100% rename from space/public/instance/instance-failure.svg rename to apps/space/public/instance/instance-failure.svg diff --git a/space/public/instance/intake-sent-dark.png b/apps/space/public/instance/intake-sent-dark.png similarity index 100% rename from space/public/instance/intake-sent-dark.png rename to apps/space/public/instance/intake-sent-dark.png diff --git a/space/public/instance/intake-sent-light.png b/apps/space/public/instance/intake-sent-light.png similarity index 100% rename from space/public/instance/intake-sent-light.png rename to apps/space/public/instance/intake-sent-light.png diff --git a/space/public/instance/plane-instance-not-ready.webp b/apps/space/public/instance/plane-instance-not-ready.webp similarity index 100% rename from space/public/instance/plane-instance-not-ready.webp rename to apps/space/public/instance/plane-instance-not-ready.webp diff --git a/space/public/instance/plane-takeoff.png b/apps/space/public/instance/plane-takeoff.png similarity index 100% rename from space/public/instance/plane-takeoff.png rename to apps/space/public/instance/plane-takeoff.png diff --git a/space/public/logos/github-black.png b/apps/space/public/logos/github-black.png similarity index 100% rename from space/public/logos/github-black.png rename to apps/space/public/logos/github-black.png diff --git a/space/public/logos/github-dark.svg b/apps/space/public/logos/github-dark.svg similarity index 100% rename from space/public/logos/github-dark.svg rename to apps/space/public/logos/github-dark.svg diff --git a/space/public/logos/github-square.svg b/apps/space/public/logos/github-square.svg similarity index 100% rename from space/public/logos/github-square.svg rename to apps/space/public/logos/github-square.svg diff --git a/space/public/logos/github-white.svg b/apps/space/public/logos/github-white.svg similarity index 100% rename from space/public/logos/github-white.svg rename to apps/space/public/logos/github-white.svg diff --git a/space/public/logos/gitlab-logo.svg b/apps/space/public/logos/gitlab-logo.svg similarity index 100% rename from space/public/logos/gitlab-logo.svg rename to apps/space/public/logos/gitlab-logo.svg diff --git a/space/public/logos/google-logo.svg b/apps/space/public/logos/google-logo.svg similarity index 100% rename from space/public/logos/google-logo.svg rename to apps/space/public/logos/google-logo.svg diff --git a/space/public/plane-logo.svg b/apps/space/public/plane-logo.svg similarity index 100% rename from space/public/plane-logo.svg rename to apps/space/public/plane-logo.svg diff --git a/space/public/plane-logos/black-horizontal-with-blue-logo.png b/apps/space/public/plane-logos/black-horizontal-with-blue-logo.png similarity index 100% rename from space/public/plane-logos/black-horizontal-with-blue-logo.png rename to apps/space/public/plane-logos/black-horizontal-with-blue-logo.png diff --git a/space/public/plane-logos/blue-without-text-new.png b/apps/space/public/plane-logos/blue-without-text-new.png similarity index 100% rename from space/public/plane-logos/blue-without-text-new.png rename to apps/space/public/plane-logos/blue-without-text-new.png diff --git a/space/public/plane-logos/blue-without-text.png b/apps/space/public/plane-logos/blue-without-text.png similarity index 100% rename from space/public/plane-logos/blue-without-text.png rename to apps/space/public/plane-logos/blue-without-text.png diff --git a/space/public/plane-logos/white-horizontal-with-blue-logo.png b/apps/space/public/plane-logos/white-horizontal-with-blue-logo.png similarity index 100% rename from space/public/plane-logos/white-horizontal-with-blue-logo.png rename to apps/space/public/plane-logos/white-horizontal-with-blue-logo.png diff --git a/space/public/plane-logos/white-horizontal.svg b/apps/space/public/plane-logos/white-horizontal.svg similarity index 100% rename from space/public/plane-logos/white-horizontal.svg rename to apps/space/public/plane-logos/white-horizontal.svg diff --git a/space/public/project-not-published.svg b/apps/space/public/project-not-published.svg similarity index 100% rename from space/public/project-not-published.svg rename to apps/space/public/project-not-published.svg diff --git a/space/public/robots.txt b/apps/space/public/robots.txt similarity index 100% rename from space/public/robots.txt rename to apps/space/public/robots.txt diff --git a/space/public/site.webmanifest.json b/apps/space/public/site.webmanifest.json similarity index 100% rename from space/public/site.webmanifest.json rename to apps/space/public/site.webmanifest.json diff --git a/space/public/something-went-wrong.svg b/apps/space/public/something-went-wrong.svg similarity index 100% rename from space/public/something-went-wrong.svg rename to apps/space/public/something-went-wrong.svg diff --git a/space/public/user-logged-in.svg b/apps/space/public/user-logged-in.svg similarity index 100% rename from space/public/user-logged-in.svg rename to apps/space/public/user-logged-in.svg diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css new file mode 100644 index 00000000000..60cfd7f06fa --- /dev/null +++ b/apps/space/styles/globals.css @@ -0,0 +1,496 @@ +@import "@plane/propel/styles/fonts"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: "Inter", sans-serif; + } + + :root { + color-scheme: light !important; + + --color-primary-10: 236, 241, 255; + --color-primary-20: 217, 228, 255; + --color-primary-30: 197, 214, 255; + --color-primary-40: 178, 200, 255; + --color-primary-50: 159, 187, 255; + --color-primary-60: 140, 173, 255; + --color-primary-70: 121, 159, 255; + --color-primary-80: 101, 145, 255; + --color-primary-90: 82, 132, 255; + --color-primary-100: 63, 118, 255; + --color-primary-200: 57, 106, 230; + --color-primary-300: 50, 94, 204; + --color-primary-400: 44, 83, 179; + --color-primary-500: 38, 71, 153; + --color-primary-600: 32, 59, 128; + --color-primary-700: 25, 47, 102; + --color-primary-800: 19, 35, 76; + --color-primary-900: 13, 24, 51; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ + + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + --color-shadow-2xs: + 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06), + 0px 1px 2px 0px rgba(23, 23, 23, 0.14); + --color-shadow-xs: + 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12), + 0px 1px 8px -1px rgba(16, 24, 40, 0.1); + --color-shadow-sm: + 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12); + --color-shadow-rg: + 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08), + 0px 1px 12px 0px rgba(16, 24, 40, 0.04); + --color-shadow-md: + 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), + 0px 1px 16px 0px rgba(16, 24, 40, 0.12); + --color-shadow-lg: + 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12), + 0px 1px 24px 0px rgba(16, 24, 40, 0.12); + --color-shadow-xl: + 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16), + 0px 0px 52px 0px rgba(16, 24, 40, 0.16); + --color-shadow-2xl: + 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12), + 0px 1px 32px 0px rgba(16, 24, 40, 0.12); + --color-shadow-3xl: + 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12), + 0px 1px 48px 0px rgba(16, 24, 40, 0.12); + --color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05); + + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ + --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ + --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ + + --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var(--color-border-100); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-100); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-100); /* strong sidebar border- 2 */ + + --color-sidebar-shadow-2xs: var(--color-shadow-2xs); + --color-sidebar-shadow-xs: var(--color-shadow-xs); + --color-sidebar-shadow-sm: var(--color-shadow-sm); + --color-sidebar-shadow-rg: var(--color-shadow-rg); + --color-sidebar-shadow-md: var(--color-shadow-md); + --color-sidebar-shadow-lg: var(--color-shadow-lg); + --color-sidebar-shadow-xl: var(--color-shadow-xl); + --color-sidebar-shadow-2xl: var(--color-shadow-2xl); + --color-sidebar-shadow-3xl: var(--color-shadow-3xl); + --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + } + + [data-theme="light"], + [data-theme="light-contrast"] { + color-scheme: light !important; + + --color-background-100: 255, 255, 255; /* primary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ + } + + [data-theme="light"] { + --color-text-100: 23, 23, 23; /* primary text */ + --color-text-200: 58, 58, 58; /* secondary text */ + --color-text-300: 82, 82, 82; /* tertiary text */ + --color-text-400: 163, 163, 163; /* placeholder text */ + + --color-scrollbar: 163, 163, 163; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%); + --gradient-onboarding-200: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%); + --gradient-onboarding-300: linear-gradient(164deg, #fff 4.25%, rgba(255, 255, 255, 0.06) 93.5%); + --gradient-onboarding-400: linear-gradient(129deg, rgba(255, 255, 255, 0) -22.23%, rgba(255, 255, 255, 0.8) 62.98%); + + --color-onboarding-text-100: 23, 23, 23; + --color-onboarding-text-200: 58, 58, 58; + --color-onboarding-text-300: 82, 82, 82; + --color-onboarding-text-400: 163, 163, 163; + + --color-onboarding-background-100: 236, 241, 255; + --color-onboarding-background-200: 255, 255, 255; + --color-onboarding-background-300: 236, 241, 255; + --color-onboarding-background-400: 177, 206, 250; + + --color-onboarding-border-100: 229, 229, 229; + --color-onboarding-border-200: 217, 228, 255; + --color-onboarding-border-300: 229, 229, 229, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(126, 139, 171, 0.1); + + /* toast theme */ + --color-toast-success-text: 62, 155, 79; + --color-toast-error-text: 220, 62, 66; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 51, 88, 212; + --color-toast-loading-text: 28, 32, 36; + --color-toast-secondary-text: 128, 131, 141; + --color-toast-tertiary-text: 96, 100, 108; + + --color-toast-success-background: 253, 253, 254; + --color-toast-error-background: 255, 252, 252; + --color-toast-warning-background: 254, 253, 251; + --color-toast-info-background: 253, 253, 254; + --color-toast-loading-background: 253, 253, 254; + + --color-toast-success-border: 218, 241, 219; + --color-toast-error-border: 255, 219, 220; + --color-toast-warning-border: 255, 247, 194; + --color-toast-info-border: 210, 222, 255; + --color-toast-loading-border: 224, 225, 230; + } + + [data-theme="light-contrast"] { + --color-text-100: 11, 11, 11; /* primary text */ + --color-text-200: 38, 38, 38; /* secondary text */ + --color-text-300: 58, 58, 58; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + } + + [data-theme="dark"], + [data-theme="dark-contrast"] { + color-scheme: dark !important; + + --color-background-100: 25, 25, 25; /* primary bg */ + --color-background-90: 32, 32, 32; /* secondary bg */ + --color-background-80: 44, 44, 44; /* tertiary bg */ + + --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); + --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); + --color-shadow-sm: 0px 0px 4px 0px rgba(0, 0, 0, 0.2), 0px 2px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-rg: 0px 0px 6px 0px rgba(0, 0, 0, 0.2), 0px 4px 6px 0px rgba(0, 0, 0, 0.5); + --color-shadow-md: 0px 2px 8px 0px rgba(0, 0, 0, 0.2), 0px 4px 8px 0px rgba(0, 0, 0, 0.5); + --color-shadow-lg: 0px 4px 12px 0px rgba(0, 0, 0, 0.25), 0px 4px 10px 0px rgba(0, 0, 0, 0.55); + --color-shadow-xl: 0px 0px 14px 0px rgba(0, 0, 0, 0.25), 0px 6px 10px 0px rgba(0, 0, 0, 0.55); + --color-shadow-2xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.25), 0px 8px 12px 0px rgba(0, 0, 0, 0.6); + --color-shadow-3xl: 0px 4px 24px 0px rgba(0, 0, 0, 0.3), 0px 12px 40px 0px rgba(0, 0, 0, 0.65); + } + + [data-theme="dark"] { + --color-text-100: 229, 229, 229; /* primary text */ + --color-text-200: 163, 163, 163; /* secondary text */ + --color-text-300: 115, 115, 115; /* tertiary text */ + --color-text-400: 82, 82, 82; /* placeholder text */ + + --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ + + --color-border-100: 34, 34, 34; /* subtle border= 1 */ + --color-border-200: 38, 38, 38; /* subtle border- 2 */ + --color-border-300: 46, 46, 46; /* strong border- 1 */ + --color-border-400: 58, 58, 58; /* strong border- 2 */ + + /* onboarding colors */ + --gradient-onboarding-100: linear-gradient(106deg, #18191b 25.17%, #18191b 99.34%); + --gradient-onboarding-200: linear-gradient(129deg, rgba(47, 49, 53, 0.8) -22.23%, rgba(33, 34, 37, 0.8) 62.98%); + --gradient-onboarding-300: linear-gradient(167deg, rgba(47, 49, 53, 0.45) 19.22%, #212225 98.48%); + + --color-onboarding-text-100: 237, 238, 240; + --color-onboarding-text-200: 176, 180, 187; + --color-onboarding-text-300: 118, 123, 132; + --color-onboarding-text-400: 105, 110, 119; + + --color-onboarding-background-100: 54, 58, 64; + --color-onboarding-background-200: 40, 42, 45; + --color-onboarding-background-300: 40, 42, 45; + --color-onboarding-background-400: 67, 72, 79; + + --color-onboarding-border-100: 54, 58, 64; + --color-onboarding-border-200: 54, 58, 64; + --color-onboarding-border-300: 34, 35, 38, 0.5; + + --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1); + + /* toast theme */ + --color-toast-success-text: 178, 221, 181; + --color-toast-error-text: 206, 44, 49; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 141, 164, 239; + --color-toast-loading-text: 255, 255, 255; + --color-toast-secondary-text: 185, 187, 198; + --color-toast-tertiary-text: 139, 141, 152; + + --color-toast-success-background: 46, 46, 46; + --color-toast-error-background: 46, 46, 46; + --color-toast-warning-background: 46, 46, 46; + --color-toast-info-background: 46, 46, 46; + --color-toast-loading-background: 46, 46, 46; + + --color-toast-success-border: 42, 126, 59; + --color-toast-error-border: 100, 23, 35; + --color-toast-warning-border: 79, 52, 34; + --color-toast-info-border: 58, 91, 199; + --color-toast-loading-border: 96, 100, 108; + } + + [data-theme="dark-contrast"] { + --color-text-100: 250, 250, 250; /* primary text */ + --color-text-200: 241, 241, 241; /* secondary text */ + --color-text-300: 212, 212, 212; /* tertiary text */ + --color-text-400: 115, 115, 115; /* placeholder text */ + + --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ + + --color-border-100: 245, 245, 245; /* subtle border= 1 */ + --color-border-200: 229, 229, 229; /* subtle border- 2 */ + --color-border-300: 212, 212, 212; /* strong border- 1 */ + --color-border-400: 185, 185, 185; /* strong border- 2 */ + } + + [data-theme="light"], + [data-theme="dark"], + [data-theme="light-contrast"], + [data-theme="dark-contrast"] { + --color-primary-10: 236, 241, 255; + --color-primary-20: 217, 228, 255; + --color-primary-30: 197, 214, 255; + --color-primary-40: 178, 200, 255; + --color-primary-50: 159, 187, 255; + --color-primary-60: 140, 173, 255; + --color-primary-70: 121, 159, 255; + --color-primary-80: 101, 145, 255; + --color-primary-90: 82, 132, 255; + --color-primary-100: 63, 118, 255; + --color-primary-200: 57, 106, 230; + --color-primary-300: 50, 94, 204; + --color-primary-400: 44, 83, 179; + --color-primary-500: 38, 71, 153; + --color-primary-600: 32, 59, 128; + --color-primary-700: 25, 47, 102; + --color-primary-800: 19, 35, 76; + --color-primary-900: 13, 24, 51; + + --color-sidebar-background-100: var(--color-background-100); /* primary sidebar bg */ + --color-sidebar-background-90: var(--color-background-90); /* secondary sidebar bg */ + --color-sidebar-background-80: var(--color-background-80); /* tertiary sidebar bg */ + + --color-sidebar-text-100: var(--color-text-100); /* primary sidebar text */ + --color-sidebar-text-200: var(--color-text-200); /* secondary sidebar text */ + --color-sidebar-text-300: var(--color-text-300); /* tertiary sidebar text */ + --color-sidebar-text-400: var(--color-text-400); /* sidebar placeholder text */ + + --color-sidebar-border-100: var(--color-border-100); /* subtle sidebar border= 1 */ + --color-sidebar-border-200: var(--color-border-200); /* subtle sidebar border- 2 */ + --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ + --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ + } + + /* stickies and editor colors */ + :root { + /* text colors */ + --editor-colors-gray-text: #5c5e63; + --editor-colors-peach-text: #ff5b59; + --editor-colors-pink-text: #f65385; + --editor-colors-orange-text: #fd9038; + --editor-colors-green-text: #0fc27b; + --editor-colors-light-blue-text: #17bee9; + --editor-colors-dark-blue-text: #266df0; + --editor-colors-purple-text: #9162f9; + /* end text colors */ + + /* background colors */ + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; + /* end background colors */ + } + /* background colors */ + [data-theme*="light"] { + --editor-colors-gray-background: #d6d6d8; + --editor-colors-peach-background: #ffd5d7; + --editor-colors-pink-background: #fdd4e3; + --editor-colors-orange-background: #ffe3cd; + --editor-colors-green-background: #c3f0de; + --editor-colors-light-blue-background: #c5eff9; + --editor-colors-dark-blue-background: #c9dafb; + --editor-colors-purple-background: #e3d8fd; + } + [data-theme*="dark"] { + --editor-colors-gray-background: #404144; + --editor-colors-peach-background: #593032; + --editor-colors-pink-background: #562e3d; + --editor-colors-orange-background: #583e2a; + --editor-colors-green-background: #1d4a3b; + --editor-colors-light-blue-background: #1f495c; + --editor-colors-dark-blue-background: #223558; + --editor-colors-purple-background: #3d325a; + } + /* end background colors */ +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-variant-ligatures: none; + -webkit-font-variant-ligatures: none; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body { + color: rgba(var(--color-text-100)); +} + +::-webkit-scrollbar { + width: 5px; + height: 5px; + border-radius: 5px; +} + +::-webkit-scrollbar-track { + background-color: rgba(var(--color-background-100)); +} + +::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: rgba(var(--color-background-80)); +} + +.hide-vertical-scrollbar::-webkit-scrollbar { + width: 0 !important; +} + +.hide-horizontal-scrollbar::-webkit-scrollbar { + height: 0 !important; +} + +.hide-both-scrollbars::-webkit-scrollbar { + height: 0 !important; + width: 0 !important; +} + +/* By applying below class, the autofilled text in form fields will not have the default autofill background color and styles applied by WebKit browsers */ +.disable-autofill-style:-webkit-autofill, +.disable-autofill-style:-webkit-autofill:hover, +.disable-autofill-style:-webkit-autofill:focus, +.disable-autofill-style:-webkit-autofill:active { + -webkit-background-clip: text; +} + +@-moz-document url-prefix() { + * { + scrollbar-width: none; + } + .vertical-scrollbar, + .horizontal-scrollbar { + scrollbar-width: initial; + scrollbar-color: rgba(96, 100, 108, 0.1) transparent; + } + .vertical-scrollbar:hover, + .horizontal-scrollbar:hover { + scrollbar-color: rgba(96, 100, 108, 0.25) transparent; + } + .vertical-scrollbar:active, + .horizontal-scrollbar:active { + scrollbar-color: rgba(96, 100, 108, 0.7) transparent; + } +} + +.vertical-scrollbar { + overflow-y: auto; +} +.horizontal-scrollbar { + overflow-x: auto; +} +.vertical-scrollbar::-webkit-scrollbar, +.horizontal-scrollbar::-webkit-scrollbar { + display: block; +} +.vertical-scrollbar::-webkit-scrollbar-track, +.horizontal-scrollbar::-webkit-scrollbar-track { + background-color: transparent; + border-radius: 9999px; +} +.vertical-scrollbar::-webkit-scrollbar-thumb, +.horizontal-scrollbar::-webkit-scrollbar-thumb { + background-clip: padding-box; + background-color: rgba(96, 100, 108, 0.1); + border-radius: 9999px; +} +.vertical-scrollbar:hover::-webkit-scrollbar-thumb, +.horizontal-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: rgba(96, 100, 108, 0.25); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:hover, +.horizontal-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(96, 100, 108, 0.5); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:active, +.horizontal-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(96, 100, 108, 0.7); +} +.vertical-scrollbar::-webkit-scrollbar-corner, +.horizontal-scrollbar::-webkit-scrollbar-corner { + background-color: transparent; +} +.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { + margin-top: 44px; +} + +/* scrollbar sm size */ +.scrollbar-sm::-webkit-scrollbar { + height: 12px; + width: 12px; +} +.scrollbar-sm::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar md size */ +.scrollbar-md::-webkit-scrollbar { + height: 14px; + width: 14px; +} +.scrollbar-md::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar lg size */ + +.scrollbar-lg::-webkit-scrollbar { + height: 16px; + width: 16px; +} +.scrollbar-lg::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); +} diff --git a/space/tailwind.config.js b/apps/space/tailwind.config.js similarity index 100% rename from space/tailwind.config.js rename to apps/space/tailwind.config.js diff --git a/space/tsconfig.json b/apps/space/tsconfig.json similarity index 100% rename from space/tsconfig.json rename to apps/space/tsconfig.json diff --git a/web/.env.example b/apps/web/.env.example similarity index 100% rename from web/.env.example rename to apps/web/.env.example diff --git a/web/.eslintignore b/apps/web/.eslintignore similarity index 100% rename from web/.eslintignore rename to apps/web/.eslintignore diff --git a/web/.eslintrc.js b/apps/web/.eslintrc.js similarity index 100% rename from web/.eslintrc.js rename to apps/web/.eslintrc.js diff --git a/web/.gitignore b/apps/web/.gitignore similarity index 100% rename from web/.gitignore rename to apps/web/.gitignore diff --git a/web/.prettierignore b/apps/web/.prettierignore similarity index 100% rename from web/.prettierignore rename to apps/web/.prettierignore diff --git a/web/.prettierrc b/apps/web/.prettierrc similarity index 100% rename from web/.prettierrc rename to apps/web/.prettierrc diff --git a/apps/web/Dockerfile.dev b/apps/web/Dockerfile.dev new file mode 100644 index 00000000000..8ca87920753 --- /dev/null +++ b/apps/web/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:22-alpine + +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . +RUN yarn global add turbo +RUN yarn install + +EXPOSE 3000 +VOLUME [ "/app/node_modules", "/app/web/node_modules" ] +CMD ["yarn", "dev", "--filter=web"] diff --git a/web/Dockerfile.web b/apps/web/Dockerfile.web similarity index 85% rename from web/Dockerfile.web rename to apps/web/Dockerfile.web index 56f931adc17..ac1da79af8c 100644 --- a/web/Dockerfile.web +++ b/apps/web/Dockerfile.web @@ -1,4 +1,4 @@ -FROM node:20-alpine as base +FROM node:22-alpine AS base # ***************************************************************************** # STAGE 1: Build the project @@ -56,8 +56,8 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 RUN yarn turbo run build --filter=web @@ -67,14 +67,17 @@ RUN yarn turbo run build --filter=web FROM base AS runner WORKDIR /app -COPY --from=installer /app/web/next.config.js . -COPY --from=installer /app/web/package.json . +# Don't run production as root +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +USER nextjs + # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=installer /app/web/.next/standalone ./ -COPY --from=installer /app/web/.next ./web/.next -COPY --from=installer /app/web/public ./web/public +COPY --from=installer /app/apps/web/.next/standalone ./ +COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=installer /app/apps/web/public ./apps/web/public ARG NEXT_PUBLIC_API_BASE_URL="" ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL @@ -100,7 +103,9 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 EXPOSE 3000 + +CMD ["node", "apps/web/server.js"] \ No newline at end of file diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx new file mode 100644 index 00000000000..cd91d1c28d3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -0,0 +1,63 @@ +"use client"; +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { SIDEBAR_WIDTH } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +// hooks +import { ResizableSidebar } from "@/components/sidebar"; +import { useAppTheme } from "@/hooks/store"; +import { useAppRail } from "@/hooks/use-app-rail"; +// local imports +import { ExtendedAppSidebar } from "./extended-sidebar"; +import { AppSidebar } from "./sidebar"; + +export const ProjectAppSidebar: FC = observer(() => { + // store hooks + const { + sidebarCollapsed, + toggleSidebar, + sidebarPeek, + toggleSidebarPeek, + isExtendedSidebarOpened, + isAnySidebarDropdownOpen, + } = useAppTheme(); + const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + // states + const [sidebarWidth, setSidebarWidth] = useState(storedValue ?? SIDEBAR_WIDTH); + // hooks + const { shouldRenderAppRail } = useAppRail(); + // derived values + const isAnyExtendedSidebarOpen = isExtendedSidebarOpened; + + // handlers + const handleWidthChange = (width: number) => setValue(width); + + return ( + <> + + + + } + isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen} + isAnySidebarDropdownOpen={isAnySidebarDropdownOpen} + disablePeekTrigger={shouldRenderAppRail} + > + + + + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx new file mode 100644 index 00000000000..829ebdec7f6 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import { useRouter } from "next/navigation"; +// plane package imports +import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { type TabItem, Tabs } from "@plane/ui"; +// components +import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions"; +import { PageHead } from "@/components/core"; +import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; +// hooks +import { captureClick } from "@/helpers/event-tracker.helper"; +import { useCommandPalette, useProject, useUserPermissions, useWorkspace } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs"; + +type Props = { + params: { + tabId: string; + workspaceSlug: string; + }; +}; + +const AnalyticsPage = observer((props: Props) => { + // props + const { params } = props; + const { tabId } = params; + + // hooks + const router = useRouter(); + + // plane imports + const { t } = useTranslation(); + + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { workspaceProjectIds, loader } = useProject(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // helper hooks + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" }); + + // permissions + const canPerformEmptyStateActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + // derived values + const pageTitle = currentWorkspace?.name + ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) + : undefined; + const ANALYTICS_TABS = useMemo(() => getAnalyticsTabs(t), [t]); + const tabs: TabItem[] = useMemo( + () => + ANALYTICS_TABS.map((tab) => ({ + key: tab.key, + label: tab.label, + content: , + onClick: () => { + router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`); + }, + disabled: tab.isDisabled, + })), + [ANALYTICS_TABS, router, currentWorkspace?.slug] + ); + const defaultTab = tabId || ANALYTICS_TABS[0].key; + + return ( + <> + + {workspaceProjectIds && ( + <> + {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( +
+ } + /> +
+ ) : ( + { + toggleCreateProjectModal(true); + captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON }); + }} + disabled={!canPerformEmptyStateActions} + /> + } + /> + )} + + )} + + ); +}); + +export default AnalyticsPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx new file mode 100644 index 00000000000..63c6aa2b760 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -0,0 +1,125 @@ +"use client"; + +import React, { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import useSWR from "swr"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { EIssueServiceType } from "@plane/types"; +import { Loader } from "@plane/ui"; +// components +import { EmptyState } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { IssueDetailRoot } from "@/components/issues"; +// hooks +import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; +// assets +import { useAppRouter } from "@/hooks/use-app-router"; +import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties"; +import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper"; +import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; +import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; + +const IssueDetailsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, workItem } = useParams(); + // hooks + const { resolvedTheme } = useTheme(); + // store hooks + const { t } = useTranslation(); + const { + fetchIssueWithIdentifier, + issue: { getIssueById }, + } = useIssueDetail(); + const { getProjectById } = useProject(); + const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); + + const projectIdentifier = workItem?.toString().split("-")[0]; + const sequence_id = workItem?.toString().split("-")[1]; + + // fetching issue details + const { data, isLoading, error } = useSWR( + workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null, + workspaceSlug && workItem + ? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id) + : null + ); + const issueId = data?.id; + const projectId = data?.project_id; + // derived values + const issue = getIssueById(issueId?.toString() || "") || undefined; + const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; + const issueLoader = !issue || isLoading; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + useWorkItemProperties( + projectId, + workspaceSlug.toString(), + issueId, + issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES + ); + + useEffect(() => { + const handleToggleIssueDetailSidebar = () => { + if (window && window.innerWidth < 768) { + toggleIssueDetailSidebar(true); + } + if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) { + toggleIssueDetailSidebar(false); + } + }; + window.addEventListener("resize", handleToggleIssueDetailSidebar); + handleToggleIssueDetailSidebar(); + return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar); + }, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]); + + return ( + <> + + {error ? ( + router.push(`/${workspaceSlug}/workspace-views/all-issues/`), + }} + /> + ) : issueLoader ? ( + +
+ + + + +
+
+ + + + +
+
+ ) : ( + workspaceSlug && + projectId && + issueId && ( + + + + ) + )} + + ); +}); + +export default IssueDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 00000000000..84f60e9fc8d --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { PenSquare } from "lucide-react"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; +// components +import { BreadcrumbLink, CountChip } from "@/components/common"; +import { CreateUpdateIssueModal } from "@/components/issues"; + +// hooks +import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store"; + +export const WorkspaceDraftHeader = observer(() => { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { paginationInfo } = useWorkspaceDraftIssues(); + const { joinedProjectIds } = useProject(); + + const { t } = useTranslation(); + // check if user is authorized to create draft work item + const isAuthorizedUser = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + return ( + <> + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ +
+ + } /> + } + /> + + {paginationInfo?.total_count && paginationInfo?.total_count > 0 ? ( + + ) : ( + <> + )} +
+
+ + + {joinedProjectIds && joinedProjectIds.length > 0 && ( + + )} + +
+ + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx similarity index 81% rename from web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index 0cd87200c9f..ac3e3262ad8 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -5,17 +5,17 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import { Plus, Search } from "lucide-react"; -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; -import { cn, copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; +import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; // components import { CreateProjectModal } from "@/components/project"; import { SidebarProjectsListItem } from "@/components/workspace"; // hooks import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store"; -import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; import { TProject } from "@/plane-web/types"; +import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; export const ExtendedProjectSidebar = observer(() => { // refs @@ -27,7 +27,7 @@ export const ExtendedProjectSidebar = observer(() => { const { workspaceSlug } = useParams(); // store hooks const { t } = useTranslation(); - const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme(); + const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme(); const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); const { allowPermissions } = useUserPermissions(); @@ -74,15 +74,7 @@ export const ExtendedProjectSidebar = observer(() => { EUserPermissionsLevel.WORKSPACE ); - useExtendedSidebarOutsideClickDetector( - extendedProjectSidebarRef, - () => { - if (!isProjectModalOpen) { - toggleExtendedProjectSidebar(false); - } - }, - "extended-project-sidebar-toggle" - ); + const handleClose = () => toggleExtendedProjectSidebar(false); const handleCopyText = (projectId: string) => { copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => { @@ -103,17 +95,11 @@ export const ExtendedProjectSidebar = observer(() => { workspaceSlug={workspaceSlug.toString()} /> )} -
@@ -122,6 +108,7 @@ export const ExtendedProjectSidebar = observer(() => {
-
+ ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx new file mode 100644 index 00000000000..bf5fdb4e205 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants"; +import { useLocalStorage } from "@plane/hooks"; +import { cn } from "@plane/utils"; +// hooks +import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; + +type Props = { + children: React.ReactNode; + extendedSidebarRef: React.RefObject; + isExtendedSidebarOpened: boolean; + handleClose: () => void; + excludedElementId: string; +}; + +export const ExtendedSidebarWrapper: FC = observer((props) => { + const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props; + // store hooks + const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + + useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId); + + return ( +
+ {children} +
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx similarity index 77% rename from web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index baa41eb9fb9..6af0e0b244c 100644 --- a/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -4,13 +4,13 @@ import React, { useMemo, useRef } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { EUserWorkspaceRoles, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants"; -import { cn } from "@plane/utils"; +import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants"; +import { EUserWorkspaceRoles } from "@plane/types"; // hooks import { useAppTheme, useWorkspace } from "@/hooks/store"; -import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click"; // plane-web imports import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar"; +import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; export const ExtendedAppSidebar = observer(() => { // refs @@ -18,7 +18,7 @@ export const ExtendedAppSidebar = observer(() => { // routers const { workspaceSlug } = useParams(); // store hooks - const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme(); + const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); const { updateSidebarPreference, getNavigationPreferences } = useWorkspace(); // derived values @@ -94,24 +94,14 @@ export const ExtendedAppSidebar = observer(() => { }); }; - useExtendedSidebarOutsideClickDetector( - extendedSidebarRef, - () => toggleExtendedSidebar(true), - "extended-sidebar-toggle" - ); + const handleClose = () => toggleExtendedSidebar(false); return ( -
{sortedNavigationItems.map((item, index) => ( { handleOnNavigationItemDrop={handleOnNavigationItemDrop} /> ))} -
+ ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx new file mode 100644 index 00000000000..0664a945c11 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Home, Shapes } from "lucide-react"; +// images +import githubBlackImage from "/public/logos/github-black.png"; +import githubWhiteImage from "/public/logos/github-white.png"; +// ui +import { GITHUB_REDIRECTED_TRACKER_EVENT, HEADER_GITHUB_ICON } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +// constants +// hooks +import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; +import { useHome } from "@/hooks/store/use-home"; + +export const WorkspaceDashboardHeader = observer(() => { + // hooks + const { resolvedTheme } = useTheme(); + const { toggleWidgetSettings } = useHome(); + const { t } = useTranslation(); + + return ( + <> +
+ +
+ + } /> + } + /> + +
+
+ + + + captureElementAndEvent({ + element: { + elementName: HEADER_GITHUB_ICON, + }, + event: { + eventName: GITHUB_REDIRECTED_TRACKER_EVENT, + state: "SUCCESS", + }, + }) + } + className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" + href="https://github.com/makeplane/plane" + target="_blank" + rel="noopener noreferrer" + > + GitHub Logo + {t("home.star_us_on_github")} + + +
+ + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx new file mode 100644 index 00000000000..35408c4914c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web components +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; +import { ProjectAppSidebar } from "./_sidebar"; + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+
+
+ +
+ {children} +
+
+
+ + + ); +} diff --git a/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx new file mode 100644 index 00000000000..ac7e04303c8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChevronDown } from "lucide-react"; +// plane constants +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +// plane i18n +import { useTranslation } from "@plane/i18n"; +// types +import { + EIssuesStoreType, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + TIssueLayouts, + EIssueLayoutTypes, +} from "@plane/types"; +// ui +import { CustomMenu } from "@plane/ui"; +// components +import { isIssueFilterActive } from "@plane/utils"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues"; + +// helpers +// hooks +import { useIssues, useLabel } from "@/hooks/store"; + +export const ProfileIssuesMobileHeader = observer(() => { + // plane i18n + const { t } = useTranslation(); + // router + const { workspaceSlug, userId } = useParams(); + // store hook + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROFILE); + + const { workspaceLabels } = useLabel(); + // derived values + const states = undefined; + // const members = undefined; + // const activeLayout = issueFilters?.displayFilters?.layout; + // const states = undefined; + const members = undefined; + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout as EIssueLayoutTypes | undefined }, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !userId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + { [key]: newValues }, + userId.toString() + ); + }, + [workspaceSlug, issueFilters, updateFilters, userId] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !userId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_PROPERTIES, + property, + userId.toString() + ); + }, + [workspaceSlug, updateFilters, userId] + ); + + return ( +
+ + {t("common.layout")} + +
+ } + customButtonClassName="flex flex-center text-custom-text-200 text-sm" + closeOnSelect + > + {ISSUE_LAYOUTS.map((layout, index) => { + if (layout.key === "spreadsheet" || layout.key === "gantt_chart" || layout.key === "calendar") return; + return ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{t(layout.i18n_title)}
+
+ ); + })} + +
+ + {t("common.filters")} + +
+ } + isFiltersApplied={isIssueFilterActive(issueFilters)} + > + + +
+
+ + {t("common.display")} + +
+ } + > + + +
+
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx new file mode 100644 index 00000000000..b7f59441f4b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles"; +// hooks +import { useProject } from "@/hooks/store"; + +const ProjectArchivedCyclesPage = observer(() => { + // router + const { projectId } = useParams(); + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived cycles`; + + return ( + <> + +
+ + +
+ + ); +}); + +export default ProjectArchivedCyclesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx new file mode 100644 index 00000000000..40f407d6a88 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { EIssuesStoreType } from "@plane/types"; +// ui +import { ArchiveIcon, Breadcrumbs, Tooltip, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +// hooks +import { useIssues, useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; + +type TProps = { + activeTab: "issues" | "cycles" | "modules"; +}; + +const PROJECT_ARCHIVES_BREADCRUMB_LIST: { + [key: string]: { + label: string; + href: string; + icon: React.FC & { className?: string }>; + }; +} = { + issues: { + label: "Work items", + href: "/issues", + icon: LayersIcon, + }, + cycles: { + label: "Cycles", + href: "/cycles", + icon: ContrastIcon, + }, + modules: { + label: "Modules", + href: "/modules", + icon: DiceIcon, + }, +}; + +export const ProjectArchivesHeader: FC = observer((props: TProps) => { + const { activeTab } = props; + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // store hooks + const { + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.ARCHIVED); + const { loader } = useProject(); + // hooks + const { isMobile } = usePlatformOS(); + + const issueCount = getGroupIssueCount(undefined, undefined, false); + + const activeTabBreadcrumbDetail = + PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST]; + + return ( +
+ +
+ + + } + /> + } + /> + {activeTabBreadcrumbDetail && ( + } + /> + } + /> + )} + + {activeTab === "issues" && issueCount && issueCount > 0 ? ( + 1 ? "work items" : "work item"} in project's archived`} + position="bottom" + > + + {issueCount} + + + ) : null} +
+
+
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx new file mode 100644 index 00000000000..a52b3596f30 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues"; +// hooks +import { useProject } from "@/hooks/store"; + +const ProjectArchivedIssuesPage = observer(() => { + // router + const { projectId } = useParams(); + // store hooks + const { getProjectById } = useProject(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name && `${project?.name} - Archived work items`; + + return ( + <> + +
+ + +
+ + ); +}); + +export default ProjectArchivedIssuesPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx new file mode 100644 index 00000000000..8cadaea8295 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + EUserPermissions, + EUserPermissionsLevel, + EProjectFeatureKey, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { usePlatformOS } from "@plane/hooks"; +import { useTranslation } from "@plane/i18n"; +import { + EIssuesStoreType, + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header, Tooltip } from "@plane/ui"; +import { cn, isIssueFilterActive } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { SwitcherLabel } from "@/components/common"; +import { CycleQuickActions } from "@/components/cycles"; +import { + DisplayFiltersSelection, + FiltersDropdown, + FilterSelection, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues"; +// hooks +import { + useCommandPalette, + useCycle, + useIssues, + useLabel, + useMember, + useProject, + useProjectState, + useUserPermissions, +} from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import useLocalStorage from "@/hooks/use-local-storage"; +// plane web imports +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; + +export const CycleIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams() as { + workspaceSlug: string; + projectId: string; + cycleId: string; + }; + // i18n + const { t } = useTranslation(); + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCycleIds, getCycleById } = useCycle(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { currentProjectDetails, loader } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + const { isMobile } = usePlatformOS(); + const { allowPermissions } = useUserPermissions(); + + const activeLayout = issueFilters?.displayFilters?.layout; + + const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false); + + const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false; + const toggleSidebar = () => { + setValue(!isSidebarCollapsed); + }; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + // derived values + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const isCompletedCycle = cycleDetails?.status?.toLocaleLowerCase() === "completed"; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + const switcherOptions = currentProjectCycleIds + ?.map((id) => { + const _cycle = id === cycleId ? cycleDetails : getCycleById(id); + if (!_cycle) return; + return { + value: _cycle.id, + query: _cycle.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
+ +
+ + + { + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`); + }} + title={cycleDetails?.name} + icon={ + + + + } + isLast + /> + } + isLast + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this cycle`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
+
+ +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
+
+ handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
+ } + > + + + } + > + + + + {canUserCreateIssue && ( + <> + + {!isCompletedCycle && ( + + )} + + )} + + +
+
+
+ + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx new file mode 100644 index 00000000000..2d07bccd948 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useParams } from "next/navigation"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { + EIssuesStoreType, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues"; +// helpers +// hooks +import { useIssues, useCycle, useProjectState, useLabel, useMember, useProject } from "@/hooks/store"; + +export const CycleIssuesMobileHeader = () => { + // i18n + const { t } = useTranslation(); + + const [analyticsModal, setAnalyticsModal] = useState(false); + const { getCycleById } = useCycle(); + const layouts = [ + { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, + ]; + + const { workspaceSlug, projectId, cycleId } = useParams(); + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + + // store hooks + const { currentProjectDetails } = useProject(); + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.CYCLE); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId || !cycleId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId || !cycleId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + cycleId.toString() + ); + }, + [workspaceSlug, projectId, cycleId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + cycleDetails={cycleDetails ?? undefined} + /> +
+ {t("common.layout")} + } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{t(layout.titleTranslationKey)}
+
+ ))} +
+
+ + {t("common.filters")} + + + } + isFiltersApplied={isIssueFilterActive(issueFilters)} + > + + +
+
+ + {t("common.display")} + + + } + > + + +
+ + setAnalyticsModal(true)} + className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200" + > + {t("common.analytics")} + +
+ + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx new file mode 100644 index 00000000000..fe950378765 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; +// components +import { CyclesViewHeader } from "@/components/cycles"; +// hooks +import { useCommandPalette, useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +// constants + +export const CyclesListHeader: FC = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug } = useParams(); + + // store hooks + const { toggleCreateCycleModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + const { t } = useTranslation(); + + const canUserCreateCycle = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
+ + + + + + {canUserCreateCycle && currentProjectDetails ? ( + + + + + ) : ( + <> + )} +
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx new file mode 100644 index 00000000000..81a10e63f30 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, TCycleFilters } from "@plane/types"; +// components +import { Header, EHeaderVariant } from "@plane/ui"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles"; +import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; +import { CycleModuleListLayout } from "@/components/ui"; +// helpers +// hooks +import { useCycle, useProject, useCycleFilter, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectCyclesPage = observer(() => { + // states + const [createModal, setCreateModal] = useState(false); + // store hooks + const { currentProjectCycleIds, loader } = useCycle(); + const { getProjectById, currentProjectDetails } = useProject(); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // cycle filters hook + const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); + const { allowPermissions } = useUserPermissions(); + // derived values + const totalCycles = currentProjectCycleIds?.length ?? 0; + const project = projectId ? getProjectById(projectId?.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - ${t("common.cycles", { count: 2 })}` : undefined; + const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const hasMemberLevelPermission = allowPermissions( + [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/cycles" }); + + const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }; + + if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.cycle_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !hasAdminLevelPermission, + }} + /> +
+ ); + + if (loader) return ; + + return ( + <> + +
+ setCreateModal(false)} + /> + {totalCycles === 0 ? ( +
+ { + setCreateModal(true); + }} + disabled={!hasMemberLevelPermission} + /> + } + /> +
+ ) : ( + <> + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId.toString())} + handleRemoveFilter={handleRemoveFilter} + /> +
+ )} + + + + )} +
+ + ); +}); + +export default ProjectCyclesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx new file mode 100644 index 00000000000..d58c19ad736 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { FC, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane constants +import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +// i18n +import { useTranslation } from "@plane/i18n"; +// types +import { + EIssuesStoreType, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +// ui +import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; +// components +import { isIssueFilterActive } from "@plane/utils"; +import { BreadcrumbLink } from "@/components/common"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; +// helpers +// hooks +import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; + +// FIXME: Deprecated. Remove it +export const ProjectDraftIssueHeader: FC = observer(() => { + // i18n + const { t } = useTranslation(); + // router + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.DRAFT); + const { currentProjectDetails, loader } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + const { isMobile } = usePlatformOS(); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); + }, + [workspaceSlug, projectId, issueFilters, updateFilters] + ); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const issueCount = undefined; + + return ( +
+
+
+ + + + } + /> + } + /> + + {issueCount && issueCount > 0 ? ( + 1 ? "work items" : "work item"} in project's draft`} + position="bottom" + > + + {issueCount} + + + ) : null} +
+ +
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + + + + + +
+
+
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx new file mode 100644 index 00000000000..1e6e2c54297 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx @@ -0,0 +1,85 @@ +"use client"; +import { observer } from "mobx-react"; +import { useParams, useSearchParams } from "next/navigation"; +import { EUserPermissionsLevel } from "@plane/constants"; +// components +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, EInboxIssueCurrentTab } from "@plane/types"; +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { InboxIssueRoot } from "@/components/inbox"; +// helpers +// hooks +import { useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectInboxPage = observer(() => { + /// router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + const searchParams = useSearchParams(); + const navigationTab = searchParams.get("currentTab"); + const inboxIssueId = searchParams.get("inboxIssueId"); + // plane hooks + const { t } = useTranslation(); + // hooks + const { currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/intake" }); + + // No access to inbox + if (currentProjectDetails?.inbox_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + + // derived values + const pageTitle = currentProjectDetails?.name + ? t("inbox_issue.page_label", { + workspace: currentProjectDetails?.name, + }) + : t("inbox_issue.page_label", { + workspace: "Plane", + }); + + const currentNavigationTab = navigationTab + ? navigationTab === "open" + ? EInboxIssueCurrentTab.OPEN + : EInboxIssueCurrentTab.CLOSED + : undefined; + + if (!workspaceSlug || !projectId) return <>; + + return ( +
+ +
+ +
+
+ ); +}); + +export default ProjectInboxPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx new file mode 100644 index 00000000000..01e35f0dd8f --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { + EIssuesStoreType, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { + DisplayFiltersSelection, + FilterSelection, + FiltersDropdown, + IssueLayoutIcon, + MobileLayoutSelection, +} from "@/components/issues/issue-layouts"; +// helpers +// hooks +import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; + +export const ProjectIssuesMobileHeader = observer(() => { + // i18n + const { t } = useTranslation(); + const layouts = [ + { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, + { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, + ]; + const [analyticsModal, setAnalyticsModal] = useState(false); + const { workspaceSlug, projectId } = useParams() as { + workspaceSlug: string; + projectId: string; + }; + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + project: { projectMemberIds }, + } = useMember(); + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); + }, + [workspaceSlug, projectId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + projectDetails={currentProjectDetails ?? undefined} + /> +
+ +
+ + {t("common.filters")} + + + } + isFiltersApplied={isIssueFilterActive(issueFilters)} + > + + +
+
+ + {t("common.display")} + + + } + > + + +
+ + +
+ + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx new file mode 100644 index 00000000000..67dc8801cc1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, + EProjectFeatureKey, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { + EIssuesStoreType, + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown, Tooltip } from "@plane/ui"; +import { cn, isIssueFilterActive } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { SwitcherLabel } from "@/components/common"; +import { + DisplayFiltersSelection, + FiltersDropdown, + FilterSelection, + LayoutSelection, + MobileLayoutSelection, +} from "@/components/issues"; +// helpers +import { ModuleQuickActions } from "@/components/modules"; +// hooks +import { + useLabel, + useMember, + useModule, + useProject, + useProjectState, + useIssues, + useCommandPalette, + useUserPermissions, +} from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; +import useLocalStorage from "@/hooks/use-local-storage"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; + +export const ModuleIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, projectId, moduleId } = useParams(); + // hooks + const { isMobile } = usePlatformOS(); + // store hooks + const { + issuesFilter: { issueFilters }, + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.MODULE); + const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); + const { projectModuleIds, getModuleById } = useModule(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + const { projectLabels } = useLabel(); + const { projectStates } = useProjectState(); + const { + project: { projectMemberIds }, + } = useMember(); + + const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); + + const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + const toggleSidebar = () => { + setValue(`${!isSidebarCollapsed}`); + }; + + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [projectId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }); + }, + [projectId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!projectId) return; + updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [projectId, updateFilters] + ); + + // derived values + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + + const switcherOptions = projectModuleIds + ?.map((id) => { + const _module = id === moduleId ? moduleDetails : getModuleById(id); + if (!_module) return; + return { + value: _module.id, + query: _module.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + return ( + <> + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} + /> +
+ +
+ + + { + router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); + }} + title={moduleDetails?.name} + icon={} + isLast + /> + } + /> + + {workItemsCount && workItemsCount > 0 ? ( + 1 ? "work items" : "work item" + } in this module`} + position="bottom" + > + + {workItemsCount} + + + ) : null} +
+
+ +
+
+ handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> +
+
+ handleLayoutChange(layout)} + activeLayout={activeLayout} + /> +
+ } + > + + + } + > + + +
+ + {canUserCreateIssue ? ( + <> + + + + ) : ( + <> + )} + + +
+
+ + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx new file mode 100644 index 00000000000..66d89dab072 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// plane imports +import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { + EIssuesStoreType, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; +// components +import { WorkItemsModal } from "@/components/analytics/work-items/modal"; +import { + DisplayFiltersSelection, + FilterSelection, + FiltersDropdown, + IssueLayoutIcon, +} from "@/components/issues/issue-layouts"; +// helpers +// hooks +import { useIssues, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; + +export const ModuleIssuesMobileHeader = observer(() => { + const [analyticsModal, setAnalyticsModal] = useState(false); + const { currentProjectDetails } = useProject(); + const { getModuleById } = useModule(); + const { t } = useTranslation(); + const layouts = [ + { key: "list", i18n_title: "issue.layouts.list", icon: List }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar }, + ]; + const { workspaceSlug, projectId, moduleId } = useParams() as { + workspaceSlug: string; + projectId: string; + moduleId: string; + }; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + const activeLayout = issueFilters?.displayFilters?.layout; + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId); + }, + [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + return ( +
+ setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{t(layout.i18n_title)}
+
+ ))} +
+
+ + Filters + + + } + isFiltersApplied={isIssueFilterActive(issueFilters)} + > + + +
+
+ + Display + + + } + > + + +
+ + +
+
+ ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx new file mode 100644 index 00000000000..81acf8c68a7 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, MODULE_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// ui +import { Breadcrumbs, Button, Header } from "@plane/ui"; +// components +import { ModuleViewHeader } from "@/components/modules"; +// hooks +import { useCommandPalette, useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; +// constants + +export const ModulesListHeader: React.FC = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { toggleCreateModuleModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + + const { loader } = useProject(); + + const { t } = useTranslation(); + + // auth + const canUserCreateModule = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
+ +
+ + + +
+
+ + + {canUserCreateModule ? ( + + ) : ( + <> + )} + +
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx new file mode 100644 index 00000000000..f00c7b223e0 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// types +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, TModuleFilters } from "@plane/types"; +// components +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules"; +// helpers +// hooks +import { useModuleFilter, useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectModulesPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store + const { getProjectById, currentProjectDetails } = useProject(); + const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } = + useModuleFilter(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/modules" }); + + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + + if (!workspaceSlug || !projectId) return <>; + + // No access to + if (currentProjectDetails?.module_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + + return ( + <> + +
+ {(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && ( + clearAllFilters(`${projectId}`)} + handleRemoveFilter={handleRemoveFilter} + handleDisplayFiltersUpdate={(val) => { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + alwaysAllowEditing + /> + )} + +
+ + ); +}); + +export default ProjectModulesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx new file mode 100644 index 00000000000..8566b04d271 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane types +import { EFileAssetType, TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types"; +// plane ui +import { getButtonStyling } from "@plane/ui"; +// plane utils +import { cn } from "@plane/utils"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { IssuePeekOverview } from "@/components/issues"; +import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages"; +// hooks +import { useEditorConfig } from "@/hooks/editor"; +import { useEditorAsset, useWorkspace } from "@/hooks/store"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// services +import { ProjectPageService, ProjectPageVersionService } from "@/services/page"; +const workspaceService = new WorkspaceService(); +const projectPageService = new ProjectPageService(); +const projectPageVersionService = new ProjectPageVersionService(); + +const PageDetailsPage = observer(() => { + const { workspaceSlug, projectId, pageId } = useParams(); + // store hooks + const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType: EPageStoreType.PROJECT, + }); + const { getWorkspaceBySlug } = useWorkspace(); + const { uploadEditorAsset } = useEditorAsset(); + // derived values + const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : ""; + const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {}; + // entity search handler + const fetchEntityCallback = useCallback( + async (payload: TSearchEntityRequestPayload) => + await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { + ...payload, + project_id: projectId?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + // editor config + const { getEditorFileHandlers } = useEditorConfig(); + // fetch page details + const { error: pageDetailsError } = useSWR( + workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null, + workspaceSlug && projectId && pageId + ? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString()) + : null, + { + revalidateIfStale: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + } + ); + // page root handlers + const pageRootHandlers: TPageRootHandlers = useMemo( + () => ({ + create: createPage, + fetchAllVersions: async (pageId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId); + }, + fetchDescriptionBinary: async () => { + if (!workspaceSlug || !projectId || !id) return; + return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id); + }, + fetchEntity: fetchEntityCallback, + fetchVersionDetails: async (pageId, versionId) => { + if (!workspaceSlug || !projectId) return; + return await projectPageVersionService.fetchVersionById( + workspaceSlug.toString(), + projectId.toString(), + pageId, + versionId + ); + }, + getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`, + updateDescription: updateDescription ?? (async () => {}), + }), + [createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug] + ); + // page root config + const pageRootConfig: TPageRootConfig = useMemo( + () => ({ + fileHandler: getEditorFileHandlers({ + projectId: projectId?.toString() ?? "", + uploadFile: async (blockId, file) => { + const { asset_id } = await uploadEditorAsset({ + blockId, + data: { + entity_identifier: id ?? "", + entity_type: EFileAssetType.PAGE_DESCRIPTION, + }, + file, + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }); + return asset_id; + }, + workspaceId, + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + }), + [getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug] + ); + + const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo( + () => ({ + documentType: "project_page", + projectId: projectId?.toString() ?? "", + workspaceSlug: workspaceSlug?.toString() ?? "", + }), + [projectId, workspaceSlug] + ); + + if ((!page || !id) && !pageDetailsError) + return ( +
+ +
+ ); + + if (pageDetailsError || !canCurrentUserAccessPage) + return ( +
+

Page not found

+

+ The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it. +

+ + View other Pages + +
+ ); + + if (!page) return null; + + return ( + <> + +
+
+ + +
+
+ + ); +}); + +export default PageDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx new file mode 100644 index 00000000000..4292e67ea45 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -0,0 +1,101 @@ +"use client"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { FileText } from "lucide-react"; +import { EProjectFeatureKey } from "@plane/constants"; +// types +import { ICustomSearchSelectOption } from "@plane/types"; +// ui +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { getPageName } from "@plane/utils"; +import { PageAccessIcon, SwitcherIcon, SwitcherLabel } from "@/components/common"; +import { PageHeaderActions } from "@/components/pages/header/actions"; +// helpers +// hooks +import { useProject } from "@/hooks/store"; +// plane web components +import { useAppRouter } from "@/hooks/use-app-router"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; +// plane web hooks +import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store"; + +export interface IPagesHeaderProps { + showButton?: boolean; +} + +const storeType = EPageStoreType.PROJECT; + +export const PageDetailsHeader = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, pageId, projectId } = useParams(); + // store hooks + const { loader } = useProject(); + const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType); + const page = usePage({ + pageId: pageId?.toString() ?? "", + storeType, + }); + // derived values + const projectPageIds = getCurrentProjectPageIds(projectId?.toString()); + + const switcherOptions = projectPageIds + .map((id) => { + const _page = id === pageId ? page : getPageById(id); + if (!_page) return; + return { + value: _page.id, + query: _page.name, + content: ( +
+ + +
+ ), + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + if (!page) return null; + + return ( +
+ +
+ + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`); + }} + title={getPageName(page?.name)} + icon={ + + + + } + isLast + /> + } + /> + +
+
+ + + + +
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx new file mode 100644 index 00000000000..a9c029852cf --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +// constants +import { + EPageAccess, + EProjectFeatureKey, + PROJECT_PAGE_TRACKER_EVENTS, + PROJECT_TRACKER_ELEMENTS, +} from "@plane/constants"; +// plane types +import { TPage } from "@plane/types"; +// plane ui +import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; +// helpers +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +// hooks +import { useProject } from "@/hooks/store"; +// plane web +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; +// plane web hooks +import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; + +export const PagesListHeader = observer(() => { + // states + const [isCreatingPage, setIsCreatingPage] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = useParams(); + const searchParams = useSearchParams(); + const pageType = searchParams.get("type"); + // store hooks + const { currentProjectDetails, loader } = useProject(); + const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT); + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + + const payload: Partial = { + access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }; + + await createPage(payload) + .then((res) => { + captureSuccess({ + eventName: PROJECT_PAGE_TRACKER_EVENTS.create, + payload: { + id: res?.id, + state: "SUCCESS", + }, + }); + const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`; + router.push(pageId); + }) + .catch((err) => { + captureError({ + eventName: PROJECT_PAGE_TRACKER_EVENTS.create, + payload: { + state: "ERROR", + }, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.data?.error || "Page could not be created. Please try again.", + }); + }) + .finally(() => setIsCreatingPage(false)); + }; + + return ( +
+ + + + + + {canCurrentUserCreatePage ? ( + + + + ) : ( + <> + )} +
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx new file mode 100644 index 00000000000..58b69809bf3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, useSearchParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, TPageNavigationTabs } from "@plane/types"; +// components +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { PagesListRoot, PagesListView } from "@/components/pages"; +// hooks +import { useProject, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; + +const ProjectPagesPage = observer(() => { + // router + const router = useAppRouter(); + const searchParams = useSearchParams(); + const type = searchParams.get("type"); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { getProjectById, currentProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" }); + + const currentPageType = (): TPageNavigationTabs => { + const pageType = type?.toString(); + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; + }; + + if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.page_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + return ( + <> + + + + + + ); +}); + +export default ProjectPagesPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx new file mode 100644 index 00000000000..2f3a1754268 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Layers, Lock } from "lucide-react"; +// plane constants +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + EUserPermissions, + EUserPermissionsLevel, + EProjectFeatureKey, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +// types +import { + EIssuesStoreType, + EViewAccess, + ICustomSearchSelectOption, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + EIssueLayoutTypes, +} from "@plane/types"; +// ui +import { Breadcrumbs, Button, Tooltip, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +// components +import { isIssueFilterActive } from "@plane/utils"; +import { SwitcherIcon, SwitcherLabel } from "@/components/common"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; +// constants +import { ViewQuickActions } from "@/components/views"; +// hooks +import { + useCommandPalette, + useIssues, + useLabel, + useMember, + useProject, + useProjectState, + useProjectView, + useUserPermissions, +} from "@/hooks/store"; +// plane web +import { useAppRouter } from "@/hooks/use-app-router"; +import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs"; + +export const ProjectViewIssuesHeader: React.FC = observer(() => { + // refs + const parentRef = useRef(null); + // router + const { workspaceSlug, projectId, viewId } = useParams(); + const router = useAppRouter(); + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + + const { currentProjectDetails, loader } = useProject(); + const { projectViewIds, getViewById } = useProjectView(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const activeLayout = issueFilters?.displayFilters?.layout; + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId || !viewId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId || !viewId) return; + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + viewId.toString() + ); + }, + [workspaceSlug, projectId, viewId, updateFilters] + ); + + const viewDetails = viewId ? getViewById(viewId.toString()) : null; + + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (!viewDetails) return; + + const switcherOptions = projectViewIds + ?.map((id) => { + const _view = id === viewId ? viewDetails : getViewById(id); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + return ( +
+ + + + + { + router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`); + }} + title={viewDetails?.name} + icon={ + + + + } + isLast + /> + } + /> + + + {viewDetails?.access === EViewAccess.PRIVATE ? ( +
+ + + +
+ ) : ( + <> + )} +
+ + {!viewDetails?.is_locked ? ( + <> + handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> + + + + + + + + + ) : ( + <> + )} + {canUserCreateIssue ? ( + + ) : ( + <> + )} +
+ +
+
+
+ ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx new file mode 100644 index 00000000000..67895bcb96c --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EUserProjectRoles, EViewAccess, TViewFilterProps } from "@plane/types"; +import { Header, EHeaderVariant } from "@plane/ui"; +import { calculateTotalFilters } from "@plane/utils"; +import { PageHead } from "@/components/core/page-title"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { ProjectViewsList } from "@/components/views"; +import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; +// constants +// helpers +// hooks +import { useProject, useProjectView, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const ProjectViewsPage = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); + // plane hooks + const { t } = useTranslation(); + // store + const { getProjectById, currentProjectDetails } = useProject(); + const { filters, updateFilters, clearAllFilters } = useProjectView(); + const { allowPermissions } = useUserPermissions(); + // derived values + const project = projectId ? getProjectById(projectId.toString()) : undefined; + const pageTitle = project?.name ? `${project?.name} - Views` : undefined; + const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/views" }); + + const handleRemoveFilter = useCallback( + (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { + let newValues = filters.filters?.[key]; + + if (key === "favorites") { + newValues = !!value; + } + if (Array.isArray(newValues)) { + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value) as string[]; + } + + updateFilters("filters", { [key]: newValues }); + }, + [filters.filters, updateFilters] + ); + + const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; + + if (!workspaceSlug || !projectId) return <>; + + // No access to + if (currentProjectDetails?.issue_views_view === false) + return ( +
+ { + router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`); + }, + disabled: !canPerformEmptyStateActions, + }} + /> +
+ ); + + return ( + <> + + {isFiltersApplied && ( +
+ +
+ )} + + + ); +}); + +export default ProjectViewsPage; diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx new file mode 100644 index 00000000000..b5dbec81cef --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -0,0 +1,89 @@ +import { FC, useEffect, useRef } from "react"; +import isEmpty from "lodash/isEmpty"; +import { observer } from "mobx-react"; +// plane helpers +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useOutsideClickDetector } from "@plane/hooks"; +// components +import { AppSidebarToggleButton } from "@/components/sidebar"; +import { SidebarDropdown, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace"; +import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; +import { HelpMenu } from "@/components/workspace/sidebar/help-menu"; +import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; +// hooks +import { useAppTheme, useUserPermissions } from "@/hooks/store"; +import { useFavorite } from "@/hooks/store/use-favorite"; +import { useAppRail } from "@/hooks/use-app-rail"; +import useSize from "@/hooks/use-window-size"; +// plane web components +import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge"; +import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list"; + +export const AppSidebar: FC = observer(() => { + // store hooks + const { allowPermissions } = useUserPermissions(); + const { toggleSidebar, sidebarCollapsed } = useAppTheme(); + const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail(); + const { groupedFavorites } = useFavorite(); + const windowSize = useSize(); + // refs + const ref = useRef(null); + + // derived values + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(); + } + } + }); + + useEffect(() => { + if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowSize]); + + const isFavoriteEmpty = isEmpty(groupedFavorites); + + return ( + <> +
+ {/* Workspace switcher and settings */} + {!shouldRenderAppRail && } + + {isAppRailEnabled && ( +
+ Projects +
+ +
+
+ )} + {/* Quick actions */} + +
+
+ + {/* Favorites Menu */} + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } + {/* Teams List */} + + {/* Projects List */} + +
+ {/* Help Section */} +
+ +
+ {!shouldRenderAppRail && } + {!isAppRailEnabled && } +
+
+ + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx new file mode 100644 index 00000000000..2778ffbf38b --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Layers } from "lucide-react"; +// plane imports +import { + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + GLOBAL_VIEW_TRACKER_ELEMENTS, + DEFAULT_GLOBAL_VIEWS_LIST, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { + EIssuesStoreType, + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + ICustomSearchSelectOption, + EIssueLayoutTypes, +} from "@plane/types"; +import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +import { isIssueFilterActive } from "@plane/utils"; +// components +import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues"; +import { + CreateUpdateWorkspaceViewModal, + WorkspaceViewQuickActions, + DefaultWorkspaceViewQuickActions, +} from "@/components/workspace"; +// hooks +import { useLabel, useMember, useIssues, useGlobalView } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper"; + +export const GlobalIssuesHeader = observer(() => { + // states + const [createViewModal, setCreateViewModal] = useState(false); + // router + const router = useAppRouter(); + const { workspaceSlug, globalViewId } = useParams(); + // store hooks + const { + issuesFilter: { filters, updateFilters }, + } = useIssues(EIssuesStoreType.GLOBAL); + const { getViewDetailsById, currentWorkspaceViews } = useGlobalView(); + const { workspaceLabels } = useLabel(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { t } = useTranslation(); + + const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; + + const activeLayout = issueFilters?.displayFilters?.layout; + const viewDetails = getViewDetailsById(globalViewId.toString()); + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !globalViewId) return; + const newValues = issueFilters?.filters?.[key] ?? []; + + if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.FILTERS, + { [key]: newValues }, + globalViewId.toString() + ); + }, + [workspaceSlug, issueFilters, updateFilters, globalViewId] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + globalViewId.toString() + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_PROPERTIES, + property, + globalViewId.toString() + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const handleLayoutChange = useCallback( + (layout: EIssueLayoutTypes) => { + if (!workspaceSlug || !globalViewId) return; + updateFilters( + workspaceSlug.toString(), + undefined, + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + globalViewId.toString() + ); + }, + [workspaceSlug, updateFilters, globalViewId] + ); + + const isLocked = viewDetails?.is_locked; + + const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultViewDetails = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId); + + const defaultOptions = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => ({ + value: view.key, + query: view.key, + content: , + })); + + const workspaceOptions = (currentWorkspaceViews || []).map((view) => { + const _view = getViewDetailsById(view); + if (!_view) return; + return { + value: _view.id, + query: _view.name, + content: , + }; + }); + + const switcherOptions = [...defaultOptions, ...workspaceOptions].filter( + (option) => option !== undefined + ) as ICustomSearchSelectOption[]; + const currentLayoutFilters = useMemo(() => { + const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET; + return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues[layout]; + }, [activeLayout]); + + return ( + <> + setCreateViewModal(false)} /> +
+ + + } /> + } + /> + { + router.push(`/${workspaceSlug}/workspace-views/${value}`); + }} + title={viewDetails?.name ?? t(defaultViewDetails?.i18n_label ?? "")} + icon={ + + + + } + isLast + /> + } + isLast + /> + + + + + {!isLocked ? ( + <> + + + + + + + + + ) : ( + <> + )} + + +
+ {viewDetails && } + {isDefaultView && defaultViewDetails && ( + + )} +
+
+
+ + ); +}); diff --git a/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx new file mode 100644 index 00000000000..593a1b0931a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { ContentWrapper } from "@/components/core"; +import { SettingsHeader } from "@/components/settings"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper"; + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+
+ {/* Header */} + + {/* Content */} + +
{children}
+
+
+
+
+
+ ); +} diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/billing/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/imports/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/integrations/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx new file mode 100644 index 00000000000..011659090a1 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import { usePathname } from "next/navigation"; +// constants +import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; +import { EUserWorkspaceRoles } from "@plane/types"; +// components +import { NotAuthorizedView } from "@/components/auth-screens"; +import { SettingsMobileNav } from "@/components/settings"; +import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper"; +// hooks +import { useUserPermissions } from "@/hooks/store"; +// local components +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +const WorkspaceSettingLayout: FC = observer((props) => { + const { children } = props; + // store hooks + const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions(); + // next hooks + const pathname = usePathname(); + // derived values + const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname); + const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString()); + + let isAuthorized: boolean | string = false; + if (pathname && workspaceSlug && userWorkspaceRole) { + isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles); + } + + return ( + <> + +
+ {workspaceUserInfo && !isAuthorized ? ( + + ) : ( +
+
{}
+
{children}
+
+ )} +
+ + ); +}); + +export default WorkspaceSettingLayout; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx new file mode 100644 index 00000000000..e05eb30f057 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/members/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Search } from "lucide-react"; +// types +import { + EUserPermissions, + EUserPermissionsLevel, + MEMBER_TRACKER_ELEMENTS, + MEMBER_TRACKER_EVENTS, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { IWorkspaceBulkInviteFormData } from "@plane/types"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { NotAuthorizedView } from "@/components/auth-screens"; +import { CountChip } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; +import { WorkspaceMembersList } from "@/components/workspace"; +// helpers +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useMember, useUserPermissions, useWorkspace } from "@/hooks/store"; +// plane web components +import { BillingActionsButton } from "@/plane-web/components/workspace/billing"; +import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members"; + +const WorkspaceMembersSettingsPage = observer(() => { + // states + const [inviteModal, setInviteModal] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + // router + const { workspaceSlug } = useParams(); + // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { + workspace: { workspaceMemberIds, inviteMembersToWorkspace }, + } = useMember(); + const { currentWorkspace } = useWorkspace(); + const { t } = useTranslation(); + + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const handleWorkspaceInvite = (data: IWorkspaceBulkInviteFormData) => { + if (!workspaceSlug) return; + + return inviteMembersToWorkspace(workspaceSlug.toString(), data) + .then(() => { + setInviteModal(false); + captureSuccess({ + eventName: MEMBER_TRACKER_EVENTS.invite, + payload: { + emails: [...data.emails.map((email) => email.email)], + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: t("workspace_settings.settings.members.invitations_sent_successfully"), + }); + }) + .catch((err) => { + captureError({ + eventName: MEMBER_TRACKER_EVENTS.invite, + payload: { + emails: [...data.emails.map((email) => email.email)], + }, + error: err, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `${err.error ?? t("something_went_wrong_please_try_again")}`, + }); + throw err; + }); + }; + + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined; + + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } + + return ( + + + setInviteModal(false)} + onSubmit={handleWorkspaceInvite} + /> +
+
+

+ {t("workspace_settings.settings.members.title")} + {workspaceMemberIds && workspaceMemberIds.length > 0 && ( + + )} +

+
+ + setSearchQuery(e.target.value)} + /> +
+ {canPerformWorkspaceAdminActions && ( + + )} + +
+ +
+
+ ); +}); + +export default WorkspaceMembersSettingsPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/mobile-header-tabs.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx new file mode 100644 index 00000000000..08ad0f72fa9 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -0,0 +1,73 @@ +import { useParams, usePathname } from "next/navigation"; +import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react"; +import { + EUserPermissionsLevel, + GROUPED_WORKSPACE_SETTINGS, + WORKSPACE_SETTINGS_CATEGORIES, + EUserPermissions, + WORKSPACE_SETTINGS_CATEGORY, +} from "@plane/constants"; +import { EUserWorkspaceRoles } from "@plane/types"; +import { SettingsSidebar } from "@/components/settings"; +import { useUserPermissions } from "@/hooks/store/user"; +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; + +const ICONS = { + general: Building, + members: Users, + export: ArrowUpToLine, + "billing-and-plans": CreditCard, + webhooks: Webhook, +}; + +export const WorkspaceActionIcons = ({ + type, + size, + className, +}: { + type: string; + size?: number; + className?: string; +}) => { + if (type === undefined) return null; + const Icon = ICONS[type as keyof typeof ICONS]; + if (!Icon) return null; + return ; +}; + +type TWorkspaceSettingsSidebarProps = { + isMobile?: boolean; +}; + +export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => { + const { isMobile = false } = props; + // router + const pathname = usePathname(); + const { workspaceSlug } = useParams(); // store hooks + const { allowPermissions } = useUserPermissions(); + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + return ( + + isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category) + )} + groupedSettings={GROUPED_WORKSPACE_SETTINGS} + workspaceSlug={workspaceSlug.toString()} + isActive={(data: { href: string }) => + data.href === "/settings" + ? pathname === `/${workspaceSlug}${data.href}/` + : new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname) + } + shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) => + data.access + ? shouldRenderSettingLink(workspaceSlug.toString(), data.key) && + allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) + : false + } + actionIcons={WorkspaceActionIcons} + /> + ); +}; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx new file mode 100644 index 00000000000..c6a7d3f5ac8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { IWebhook } from "@plane/types"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { SettingsContentWrapper } from "@/components/settings"; +import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; + +const WebhookDetailsPage = observer(() => { + // states + const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); + // router + const { workspaceSlug, webhookId } = useParams(); + // mobx store + const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + + // TODO: fix this error + // useEffect(() => { + // if (isCreated !== "true") clearSecretKey(); + // }, [clearSecretKey, isCreated]); + + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined; + + useSWR( + workspaceSlug && webhookId && isAdmin ? `WEBHOOK_DETAILS_${workspaceSlug}_${webhookId}` : null, + workspaceSlug && webhookId && isAdmin + ? () => fetchWebhookById(workspaceSlug.toString(), webhookId.toString()) + : null + ); + + const handleUpdateWebhook = async (formData: IWebhook) => { + if (!workspaceSlug || !formData || !formData.id) return; + const payload = { + url: formData?.url, + is_active: formData?.is_active, + project: formData?.project, + cycle: formData?.cycle, + module: formData?.module, + issue: formData?.issue, + issue_comment: formData?.issue_comment, + }; + await updateWebhook(workspaceSlug.toString(), formData.id, payload) + .then(() => { + captureSuccess({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, + payload: { + webhook: formData.id, + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Webhook updated successfully.", + }); + }) + .catch((error) => { + captureError({ + eventName: WORKSPACE_SETTINGS_TRACKER_EVENTS.webhook_updated, + payload: { + webhook: formData.id, + }, + error: error as Error, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.error ?? "Something went wrong. Please try again.", + }); + }); + }; + + if (!isAdmin) + return ( + <> + +
+

You are not authorized to access this page.

+
+ + ); + + if (!currentWebhook) + return ( +
+ +
+ ); + + return ( + + + setDeleteWebhookModal(false)} /> +
+
+ await handleUpdateWebhook(data)} data={currentWebhook} /> +
+ {currentWebhook && setDeleteWebhookModal(true)} />} +
+
+ ); +}); + +export default WebhookDetailsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx new file mode 100644 index 00000000000..511ab96d9a5 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { NotAuthorizedView } from "@/components/auth-screens"; +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsContentWrapper, SettingsHeading } from "@/components/settings"; +import { WebhookSettingsLoader } from "@/components/ui"; +import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; +// hooks +import { captureClick } from "@/helpers/event-tracker.helper"; +import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const WebhooksListPage = observer(() => { + // states + const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); + // router + const { workspaceSlug } = useParams(); + // plane hooks + const { t } = useTranslation(); + // mobx store + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook(); + const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/webhooks" }); + + useSWR( + workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, + workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null + ); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.webhooks.title")}` + : undefined; + + // clear secret key when modal is closed. + useEffect(() => { + if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey(); + }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); + + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } + + if (!webhooks) return ; + + return ( + + +
+ { + setShowCreateWebhookModal(false); + }} + /> + { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_WEBHOOK_BUTTON, + }); + setShowCreateWebhookModal(true); + }, + }} + /> + {Object.keys(webhooks).length > 0 ? ( +
+ +
+ ) : ( +
+
+ { + captureClick({ + elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON, + }); + setShowCreateWebhookModal(true); + }, + }} + /> +
+
+ )} +
+
+ ); +}); + +export default WebhooksListPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx new file mode 100644 index 00000000000..ecbc2f9aa58 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/api-tokens/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// plane imports +import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// component +import { APITokenService } from "@plane/services"; +import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; +import { PageHead } from "@/components/core"; +import { DetailedEmptyState } from "@/components/empty-state"; +import { SettingsHeading } from "@/components/settings"; +import { APITokenSettingsLoader } from "@/components/ui"; +import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +// store hooks +import { captureClick } from "@/helpers/event-tracker.helper"; +import { useWorkspace } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; +// services + +const apiTokenService = new APITokenService(); + +const ApiTokensPage = observer(() => { + // states + const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); + // router + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" }); + + const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list()); + + const pageTitle = currentWorkspace?.name + ? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}` + : undefined; + + if (!tokens) { + return ; + } + + return ( +
+ + setIsCreateTokenModalOpen(false)} /> +
+ {tokens.length > 0 ? ( + <> + { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> +
+ {tokens.map((token) => ( + + ))} +
+ + ) : ( +
+ { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.HEADER_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> +
+ { + captureClick({ + elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON, + }); + setIsCreateTokenModalOpen(true); + }, + }} + /> +
+
+ )} +
+
+ ); +}); + +export default ApiTokensPage; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/notifications/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/preferences/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/sidebar.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx similarity index 100% rename from web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx rename to apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx new file mode 100644 index 00000000000..f94ca21416a --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -0,0 +1,43 @@ +"use client"; +import Image from "next/image"; +import Link from "next/link"; +import { useTheme } from "next-themes"; +import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useCommandPalette } from "@/hooks/store"; + +const ProjectSettingsPage = () => { + // store hooks + const { resolvedTheme } = useTheme(); + const { toggleCreateProjectModal } = useCommandPalette(); + // derived values + const resolvedPath = + resolvedTheme === "dark" + ? "/empty-state/project-settings/no-projects-dark.png" + : "/empty-state/project-settings/no-projects-light.png"; + return ( +
+ No projects yet +
No projects yet
+
+ Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you + need to get things done. +
+
+ + Learn more about projects + + +
+
+ ); +}; + +export default ProjectSettingsPage; diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx new file mode 100644 index 00000000000..7cecc697fa4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { AppRailProvider } from "@/hooks/context/app-rail-context"; +import { WorkspaceContentWrapper } from "@/plane-web/components/workspace"; + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/app/(all)/accounts/forgot-password/layout.tsx b/apps/web/app/(all)/accounts/forgot-password/layout.tsx similarity index 100% rename from web/app/(all)/accounts/forgot-password/layout.tsx rename to apps/web/app/(all)/accounts/forgot-password/layout.tsx diff --git a/apps/web/app/(all)/accounts/forgot-password/page.tsx b/apps/web/app/(all)/accounts/forgot-password/page.tsx new file mode 100644 index 00000000000..fa9ff935859 --- /dev/null +++ b/apps/web/app/(all)/accounts/forgot-password/page.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import { Controller, useForm } from "react-hook-form"; +// icons +import { CircleCheck } from "lucide-react"; +// plane imports +import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { cn, checkEmailValidity } from "@plane/utils"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; +import { useInstance } from "@/hooks/store"; +import useTimer from "@/hooks/use-timer"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// images +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +// services +import { AuthService } from "@/services/auth.service"; + +type TForgotPasswordFormValues = { + email: string; +}; + +const defaultValues: TForgotPasswordFormValues = { + email: "", +}; + +// services +const authService = new AuthService(); + +const ForgotPasswordPage = observer(() => { + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // plane hooks + const { t } = useTranslation(); + const { config } = useInstance(); + // hooks + const { resolvedTheme } = useTheme(); + // timer + const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(0); + + // form info + const { + control, + formState: { errors, isSubmitting, isValid }, + handleSubmit, + } = useForm({ + defaultValues: { + ...defaultValues, + email: email?.toString() ?? "", + }, + }); + + const handleForgotPassword = async (formData: TForgotPasswordFormValues) => { + await authService + .sendResetPasswordLink({ + email: formData.email, + }) + .then(() => { + captureSuccess({ + eventName: AUTH_TRACKER_EVENTS.forgot_password, + payload: { + email: formData.email, + }, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("auth.forgot_password.toast.success.title"), + message: t("auth.forgot_password.toast.success.message"), + }); + setResendCodeTimer(30); + }) + .catch((err) => { + captureError({ + eventName: AUTH_TRACKER_EVENTS.forgot_password, + payload: { + email: formData.email, + }, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("auth.forgot_password.toast.error.title"), + message: err?.error ?? t("auth.forgot_password.toast.error.message"), + }); + }); + }; + + // derived values + const enableSignUpConfig = config?.enable_signup ?? false; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+ {enableSignUpConfig && ( +
+ {t("auth.common.new_to_plane")} + + {t("auth.common.create_account")} + +
+ )} +
+
+
+
+

+ {t("auth.forgot_password.title")} +

+

{t("auth.forgot_password.description")}

+
+
+
+ + checkEmailValidity(value) || t("auth.common.email.errors.invalid"), + }} + render={({ field: { value, onChange, ref } }) => ( + 0} + /> + )} + /> + {resendTimerCode > 0 && ( +

+ + {t("auth.forgot_password.email_sent")} +

+ )} +
+ + + {t("auth.common.back_to_sign_in")} + +
+
+
+
+
+
+ ); +}); + +export default ForgotPasswordPage; diff --git a/web/app/(all)/accounts/reset-password/layout.tsx b/apps/web/app/(all)/accounts/reset-password/layout.tsx similarity index 100% rename from web/app/(all)/accounts/reset-password/layout.tsx rename to apps/web/app/(all)/accounts/reset-password/layout.tsx diff --git a/web/app/(all)/accounts/reset-password/page.tsx b/apps/web/app/(all)/accounts/reset-password/page.tsx similarity index 100% rename from web/app/(all)/accounts/reset-password/page.tsx rename to apps/web/app/(all)/accounts/reset-password/page.tsx diff --git a/web/app/(all)/accounts/set-password/layout.tsx b/apps/web/app/(all)/accounts/set-password/layout.tsx similarity index 100% rename from web/app/(all)/accounts/set-password/layout.tsx rename to apps/web/app/(all)/accounts/set-password/layout.tsx diff --git a/web/app/(all)/accounts/set-password/page.tsx b/apps/web/app/(all)/accounts/set-password/page.tsx similarity index 100% rename from web/app/(all)/accounts/set-password/page.tsx rename to apps/web/app/(all)/accounts/set-password/page.tsx diff --git a/web/app/(all)/create-workspace/layout.tsx b/apps/web/app/(all)/create-workspace/layout.tsx similarity index 100% rename from web/app/(all)/create-workspace/layout.tsx rename to apps/web/app/(all)/create-workspace/layout.tsx diff --git a/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx similarity index 100% rename from web/app/(all)/create-workspace/page.tsx rename to apps/web/app/(all)/create-workspace/page.tsx diff --git a/web/app/(all)/installations/[provider]/layout.tsx b/apps/web/app/(all)/installations/[provider]/layout.tsx similarity index 100% rename from web/app/(all)/installations/[provider]/layout.tsx rename to apps/web/app/(all)/installations/[provider]/layout.tsx diff --git a/web/app/(all)/installations/[provider]/page.tsx b/apps/web/app/(all)/installations/[provider]/page.tsx similarity index 100% rename from web/app/(all)/installations/[provider]/page.tsx rename to apps/web/app/(all)/installations/[provider]/page.tsx diff --git a/web/app/(all)/invitations/layout.tsx b/apps/web/app/(all)/invitations/layout.tsx similarity index 100% rename from web/app/(all)/invitations/layout.tsx rename to apps/web/app/(all)/invitations/layout.tsx diff --git a/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx new file mode 100644 index 00000000000..c3ba0e1527d --- /dev/null +++ b/apps/web/app/(all)/invitations/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; + +import { useTheme } from "next-themes"; +import useSWR, { mutate } from "swr"; +import { CheckCircle2 } from "lucide-react"; +// plane imports +import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// types +import type { IWorkspaceMemberInvitation } from "@plane/types"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { truncateText } from "@plane/utils"; +// components +import { EmptyState } from "@/components/common"; +import { WorkspaceLogo } from "@/components/workspace/logo"; +import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; +// helpers +// hooks +import { captureError, captureSuccess, joinEventGroup } from "@/helpers/event-tracker.helper"; +import { useUser, useUserProfile, useWorkspace } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// services +import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web services +import { WorkspaceService } from "@/plane-web/services"; +// images +import emptyInvitation from "@/public/empty-state/invitation.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +const workspaceService = new WorkspaceService(); + +const UserInvitationsPage = observer(() => { + // states + const [invitationsRespond, setInvitationsRespond] = useState([]); + const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); + // router + const router = useAppRouter(); + // store hooks + const { t } = useTranslation(); + const { data: currentUser } = useUser(); + const { updateUserProfile } = useUserProfile(); + + const { fetchWorkspaces } = useWorkspace(); + // next-themes + const { resolvedTheme } = useTheme(); + + const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS", () => workspaceService.userWorkspaceInvitations()); + + const redirectWorkspaceSlug = + // currentUserSettings?.workspace?.last_workspace_slug || + // currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { + if (action === "accepted") { + setInvitationsRespond((prevData) => [...prevData, workspace_invitation.id]); + } else if (action === "withdraw") { + setInvitationsRespond((prevData) => prevData.filter((item: string) => item !== workspace_invitation.id)); + } + }; + + const submitInvitations = () => { + if (invitationsRespond.length === 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("please_select_at_least_one_invitation"), + }); + return; + } + + setIsJoiningWorkspaces(true); + + workspaceService + .joinWorkspaces({ invitations: invitationsRespond }) + .then(() => { + mutate(USER_WORKSPACES_LIST); + const firstInviteId = invitationsRespond[0]; + const invitation = invitations?.find((i) => i.id === firstInviteId); + const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; + if (redirectWorkspace?.id) { + joinEventGroup(GROUP_WORKSPACE_TRACKER_EVENT, redirectWorkspace?.id, { + date: new Date().toDateString(), + workspace_id: redirectWorkspace?.id, + }); + } + captureSuccess({ + eventName: MEMBER_TRACKER_EVENTS.accept, + payload: { + member_id: invitation?.id, + }, + }); + updateUserProfile({ last_workspace_id: redirectWorkspace?.id }) + .then(() => { + setIsJoiningWorkspaces(false); + fetchWorkspaces().then(() => { + router.push(`/${redirectWorkspace?.slug}`); + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), + }); + setIsJoiningWorkspaces(false); + }); + }) + .catch((err) => { + captureError({ + eventName: MEMBER_TRACKER_EVENTS.accept, + payload: { + member_id: invitationsRespond?.[0], + }, + error: err, + }); + setToast({ + type: TOAST_TYPE.ERROR, + title: t("error"), + message: t("something_went_wrong_please_try_again"), + }); + setIsJoiningWorkspaces(false); + }); + }; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+
+ +
+ Plane logo +
+ +
+ {currentUser?.email} +
+
+ {invitations ? ( + invitations.length > 0 ? ( +
+
+
{t("we_see_that_someone_has_invited_you_to_join_a_workspace")}
+

{t("join_a_workspace")}

+
+ {invitations.map((invitation) => { + const isSelected = invitationsRespond.includes(invitation.id); + + return ( +
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} + > +
+ +
+
+
{truncateText(invitation.workspace.name, 30)}
+

{ROLE[invitation.role]}

+
+ + + +
+ ); + })} +
+
+ + + + + + +
+
+
+ ) : ( +
+ router.push("/"), + }} + /> +
+ ) + ) : null} +
+ + ); +}); + +export default UserInvitationsPage; diff --git a/web/app/(all)/layout.preload.tsx b/apps/web/app/(all)/layout.preload.tsx similarity index 94% rename from web/app/(all)/layout.preload.tsx rename to apps/web/app/(all)/layout.preload.tsx index 18ca3b4b38b..fb72b72a5c0 100644 --- a/web/app/(all)/layout.preload.tsx +++ b/apps/web/app/(all)/layout.preload.tsx @@ -18,7 +18,7 @@ export const usePreloadResources = () => { `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, ]; - urls.forEach(url => preloadItem(url)); + urls.forEach((url) => preloadItem(url)); }, []); }; diff --git a/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx similarity index 100% rename from web/app/(all)/layout.tsx rename to apps/web/app/(all)/layout.tsx diff --git a/web/app/(all)/onboarding/layout.tsx b/apps/web/app/(all)/onboarding/layout.tsx similarity index 100% rename from web/app/(all)/onboarding/layout.tsx rename to apps/web/app/(all)/onboarding/layout.tsx diff --git a/apps/web/app/(all)/onboarding/page.tsx b/apps/web/app/(all)/onboarding/page.tsx new file mode 100644 index 00000000000..7e7d15a10ee --- /dev/null +++ b/apps/web/app/(all)/onboarding/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// types +import { USER_TRACKER_EVENTS } from "@plane/constants"; +import { TOnboardingSteps, TUserProfile } from "@plane/types"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { LogoSpinner } from "@/components/common"; +import { InviteMembers, CreateOrJoinWorkspaces, ProfileSetup } from "@/components/onboarding"; +// constants +import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { captureSuccess } from "@/helpers/event-tracker.helper"; +import { useUser, useWorkspace, useUserProfile } from "@/hooks/store"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceService } from "@/plane-web/services"; +// services + +enum EOnboardingSteps { + PROFILE_SETUP = "PROFILE_SETUP", + WORKSPACE_CREATE_OR_JOIN = "WORKSPACE_CREATE_OR_JOIN", + INVITE_MEMBERS = "INVITE_MEMBERS", +} + +const workspaceService = new WorkspaceService(); + +const OnboardingPage = observer(() => { + // states + const [step, setStep] = useState(null); + const [totalSteps, setTotalSteps] = useState(null); + // store hooks + const { isLoading: userLoader, data: user, updateCurrentUser } = useUser(); + const { data: profile, updateUserProfile, finishUserOnboarding } = useUserProfile(); + const { workspaces, fetchWorkspaces } = useWorkspace(); + + // computed values + const workspacesList = Object.values(workspaces ?? {}); + // fetching workspaces list + const { isLoading: workspaceListLoader } = useSWR(USER_WORKSPACES_LIST, () => { + user?.id && fetchWorkspaces(); + }); + // fetching user workspace invitations + const { isLoading: invitationsLoader, data: invitations } = useSWR( + `USER_WORKSPACE_INVITATIONS_LIST_${user?.id}`, + () => { + if (user?.id) return workspaceService.userWorkspaceInvitations(); + } + ); + // handle step change + const stepChange = async (steps: Partial) => { + if (!user) return; + + const payload: Partial = { + onboarding_step: { + ...profile.onboarding_step, + ...steps, + }, + }; + + await updateUserProfile(payload); + }; + + // complete onboarding + const finishOnboarding = async () => { + if (!user) return; + + await finishUserOnboarding() + .then(() => { + captureSuccess({ + eventName: USER_TRACKER_EVENTS.onboarding_complete, + payload: { + email: user.email, + user_id: user.id, + }, + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Failed", + message: "Failed to finish onboarding, Please try again later.", + }); + }); + }; + + useEffect(() => { + // Never update the total steps if it's already set. + if (!totalSteps && userLoader === false && workspaceListLoader === false) { + // If user is already invited to a workspace, only show profile setup steps. + if (workspacesList && workspacesList?.length > 0) { + // If password is auto set then show two different steps for profile setup, else merge them. + if (user?.is_password_autoset) setTotalSteps(2); + else setTotalSteps(1); + } else { + // If password is auto set then total steps will increase to 4 due to extra step at profile setup stage. + if (user?.is_password_autoset) setTotalSteps(4); + else setTotalSteps(3); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userLoader, workspaceListLoader]); + + // If the user completes the profile setup and has workspaces (through invitations), then finish the onboarding. + useEffect(() => { + if (userLoader === false && profile && workspaceListLoader === false) { + const onboardingStep = profile.onboarding_step; + if (onboardingStep.profile_complete && !onboardingStep.workspace_create && workspacesList.length > 0) + finishOnboarding(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userLoader, profile, workspaceListLoader]); + + useEffect(() => { + const handleStepChange = async () => { + if (!user) return; + + const onboardingStep = profile.onboarding_step; + + if (!onboardingStep.profile_complete) setStep(EOnboardingSteps.PROFILE_SETUP); + + if ( + onboardingStep.profile_complete && + !(onboardingStep.workspace_join || onboardingStep.workspace_create || workspacesList?.length > 0) + ) { + setStep(EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN); + } + + if ( + onboardingStep.profile_complete && + (onboardingStep.workspace_join || onboardingStep.workspace_create) && + !onboardingStep.workspace_invite + ) + setStep(EOnboardingSteps.INVITE_MEMBERS); + }; + + handleStepChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, step, profile.onboarding_step, updateCurrentUser, workspacesList]); + + return ( + + {user && totalSteps && step !== null && !invitationsLoader ? ( +
+ {step === EOnboardingSteps.PROFILE_SETUP ? ( + + ) : step === EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN ? ( + + ) : step === EOnboardingSteps.INVITE_MEMBERS ? ( + + ) : ( +
+ Something Went wrong. Please try again. +
+ )} +
+ ) : ( +
+ +
+ )} +
+ ); +}); + +export default OnboardingPage; diff --git a/web/app/(all)/profile/activity/page.tsx b/apps/web/app/(all)/profile/activity/page.tsx similarity index 100% rename from web/app/(all)/profile/activity/page.tsx rename to apps/web/app/(all)/profile/activity/page.tsx diff --git a/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx similarity index 100% rename from web/app/(all)/profile/appearance/page.tsx rename to apps/web/app/(all)/profile/appearance/page.tsx diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx new file mode 100644 index 00000000000..21e02480d30 --- /dev/null +++ b/apps/web/app/(all)/profile/layout.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { CommandPalette } from "@/components/command-palette"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// layout +import { ProfileLayoutSidebar } from "./sidebar"; + +type Props = { + children: ReactNode; +}; + +export default function ProfileSettingsLayout(props: Props) { + const { children } = props; + + return ( + <> + + +
+ +
+
{children}
+
+
+
+ + ); +} diff --git a/web/app/(all)/profile/notifications/page.tsx b/apps/web/app/(all)/profile/notifications/page.tsx similarity index 100% rename from web/app/(all)/profile/notifications/page.tsx rename to apps/web/app/(all)/profile/notifications/page.tsx diff --git a/web/app/(all)/profile/page.tsx b/apps/web/app/(all)/profile/page.tsx similarity index 100% rename from web/app/(all)/profile/page.tsx rename to apps/web/app/(all)/profile/page.tsx diff --git a/web/app/(all)/profile/security/page.tsx b/apps/web/app/(all)/profile/security/page.tsx similarity index 100% rename from web/app/(all)/profile/security/page.tsx rename to apps/web/app/(all)/profile/security/page.tsx diff --git a/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx similarity index 100% rename from web/app/(all)/profile/sidebar.tsx rename to apps/web/app/(all)/profile/sidebar.tsx diff --git a/apps/web/app/(all)/sign-up/layout.tsx b/apps/web/app/(all)/sign-up/layout.tsx new file mode 100644 index 00000000000..9e259b304ac --- /dev/null +++ b/apps/web/app/(all)/sign-up/layout.tsx @@ -0,0 +1,13 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Sign up - Plane", + robots: { + index: true, + follow: false, + }, +}; + +export default function SignUpLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/(all)/sign-up/page.tsx b/apps/web/app/(all)/sign-up/page.tsx new file mode 100644 index 00000000000..25deaf8f167 --- /dev/null +++ b/apps/web/app/(all)/sign-up/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { useTheme } from "next-themes"; +// components +import { AUTH_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { AuthRoot } from "@/components/account"; +// constants +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// assets +import { AuthenticationWrapper } from "@/lib/wrappers"; +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +export type AuthType = "sign-in" | "sign-up"; + +const SignInPage = observer(() => { + // plane hooks + const { t } = useTranslation(); + // hooks + const { resolvedTheme } = useTheme(); + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+ {t("auth.common.already_have_an_account")} + + {t("auth.common.login")} + +
+
+
+ +
+
+
+
+ ); +}); + +export default SignInPage; diff --git a/web/app/(all)/workspace-invitations/layout.tsx b/apps/web/app/(all)/workspace-invitations/layout.tsx similarity index 100% rename from web/app/(all)/workspace-invitations/layout.tsx rename to apps/web/app/(all)/workspace-invitations/layout.tsx diff --git a/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx similarity index 100% rename from web/app/(all)/workspace-invitations/page.tsx rename to apps/web/app/(all)/workspace-invitations/page.tsx diff --git a/apps/web/app/(home)/layout.tsx b/apps/web/app/(home)/layout.tsx new file mode 100644 index 00000000000..af7645f3c78 --- /dev/null +++ b/apps/web/app/(home)/layout.tsx @@ -0,0 +1,19 @@ +import { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + robots: { + index: true, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function HomeLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/web/app/(home)/page.tsx b/apps/web/app/(home)/page.tsx new file mode 100644 index 00000000000..c9f0c485011 --- /dev/null +++ b/apps/web/app/(home)/page.tsx @@ -0,0 +1,83 @@ +"use client"; +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { useTheme } from "next-themes"; +// components +import { AUTH_TRACKER_ELEMENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { AuthRoot } from "@/components/account"; +import { PageHead } from "@/components/core"; +// constants +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useInstance } from "@/hooks/store"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +const HomePage = observer(() => { + const { resolvedTheme } = useTheme(); + // plane hooks + const { t } = useTranslation(); + // store + const { config } = useInstance(); + // derived values + const enableSignUpConfig = config?.enable_signup ?? false; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + + + <> +
+ +
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+ {enableSignUpConfig && ( +
+ {t("auth.common.new_to_plane")} + + {t("auth.common.create_account")} + +
+ )} +
+
+ +
+
+
+ +
+
+ ); +}); + +export default HomePage; diff --git a/web/app/error.tsx b/apps/web/app/error.tsx similarity index 100% rename from web/app/error.tsx rename to apps/web/app/error.tsx diff --git a/web/app/global-error.tsx b/apps/web/app/global-error.tsx similarity index 100% rename from web/app/global-error.tsx rename to apps/web/app/global-error.tsx diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 00000000000..433dea7f915 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,109 @@ +import { Metadata, Viewport } from "next"; +import Script from "next/script"; + +// styles +import "@/styles/globals.css"; + +import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; + +// helpers +import { cn } from "@plane/utils"; + +// local +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: SITE_DESCRIPTION, + metadataBase: new URL("https://app.plane.so"), + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: "Open-source project management tool to manage work items, cycles, and product roadmaps easily", + url: "https://app.plane.so/", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], + }, + keywords: + "software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + card: "summary_large_image", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "Plane - Modern project management", + }, + ], + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + maximumScale: 1, + userScalable: false, + width: "device-width", + viewportFit: "cover", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + + + {/* Meta info for PWA */} + + + + + + + + + + + + + +
+
+ +
+
{children}
+
+
+ + {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( + + )} + + ); +} diff --git a/web/app/not-found.tsx b/apps/web/app/not-found.tsx similarity index 100% rename from web/app/not-found.tsx rename to apps/web/app/not-found.tsx diff --git a/web/app/provider.tsx b/apps/web/app/provider.tsx similarity index 100% rename from web/app/provider.tsx rename to apps/web/app/provider.tsx diff --git a/web/ce/components/active-cycles/index.ts b/apps/web/ce/components/active-cycles/index.ts similarity index 100% rename from web/ce/components/active-cycles/index.ts rename to apps/web/ce/components/active-cycles/index.ts diff --git a/web/ce/components/active-cycles/root.tsx b/apps/web/ce/components/active-cycles/root.tsx similarity index 100% rename from web/ce/components/active-cycles/root.tsx rename to apps/web/ce/components/active-cycles/root.tsx diff --git a/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx similarity index 100% rename from web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx rename to apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx diff --git a/web/ce/components/analytics/tabs.tsx b/apps/web/ce/components/analytics/tabs.tsx similarity index 100% rename from web/ce/components/analytics/tabs.tsx rename to apps/web/ce/components/analytics/tabs.tsx diff --git a/web/ce/components/cycles/active-cycle/index.ts b/apps/web/ce/components/app-rail/index.ts similarity index 100% rename from web/ce/components/cycles/active-cycle/index.ts rename to apps/web/ce/components/app-rail/index.ts diff --git a/apps/web/ce/components/app-rail/root.tsx b/apps/web/ce/components/app-rail/root.tsx new file mode 100644 index 00000000000..259764b2695 --- /dev/null +++ b/apps/web/ce/components/app-rail/root.tsx @@ -0,0 +1,4 @@ +"use client"; +import React from "react"; + +export const AppRailRoot = () => <>; diff --git a/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx similarity index 100% rename from web/ce/components/breadcrumbs/common.tsx rename to apps/web/ce/components/breadcrumbs/common.tsx diff --git a/web/ce/components/breadcrumbs/index.ts b/apps/web/ce/components/breadcrumbs/index.ts similarity index 100% rename from web/ce/components/breadcrumbs/index.ts rename to apps/web/ce/components/breadcrumbs/index.ts diff --git a/web/ce/components/breadcrumbs/project-feature.tsx b/apps/web/ce/components/breadcrumbs/project-feature.tsx similarity index 100% rename from web/ce/components/breadcrumbs/project-feature.tsx rename to apps/web/ce/components/breadcrumbs/project-feature.tsx diff --git a/apps/web/ce/components/breadcrumbs/project.tsx b/apps/web/ce/components/breadcrumbs/project.tsx new file mode 100644 index 00000000000..00c815cdce3 --- /dev/null +++ b/apps/web/ce/components/breadcrumbs/project.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Briefcase } from "lucide-react"; +// plane imports +import { ICustomSearchSelectOption } from "@plane/types"; +import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui"; +// components +import { SwitcherLabel } from "@/components/common"; +// hooks +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { TProject } from "@/plane-web/types"; + +type TProjectBreadcrumbProps = { + workspaceSlug: string; + projectId: string; + handleOnClick?: () => void; +}; + +export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => { + const { workspaceSlug, projectId, handleOnClick } = props; + // router + const router = useAppRouter(); + // store hooks + const { joinedProjectIds, getPartialProjectById } = useProject(); + const currentProjectDetails = getPartialProjectById(projectId); + + // store hooks + + if (!currentProjectDetails) return null; + + // derived values + const switcherOptions = joinedProjectIds + .map((projectId) => { + const project = getPartialProjectById(projectId); + return { + value: projectId, + query: project?.name, + content: , + }; + }) + .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; + + // helpers + const renderIcon = (projectDetails: TProject) => ( + + + + ); + + return ( + <> + { + router.push(`/${workspaceSlug}/projects/${value}/issues`); + }} + title={currentProjectDetails?.name} + icon={renderIcon(currentProjectDetails)} + handleOnClick={() => { + if (handleOnClick) handleOnClick(); + else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`); + }} + shouldTruncate + /> + } + showSeparator={false} + /> + + ); +}); diff --git a/web/ce/components/command-palette/actions/index.ts b/apps/web/ce/components/command-palette/actions/index.ts similarity index 100% rename from web/ce/components/command-palette/actions/index.ts rename to apps/web/ce/components/command-palette/actions/index.ts diff --git a/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx b/apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx similarity index 100% rename from web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx rename to apps/web/ce/components/command-palette/actions/work-item-actions/change-state-list.tsx diff --git a/web/ce/components/command-palette/actions/work-item-actions/index.ts b/apps/web/ce/components/command-palette/actions/work-item-actions/index.ts similarity index 100% rename from web/ce/components/command-palette/actions/work-item-actions/index.ts rename to apps/web/ce/components/command-palette/actions/work-item-actions/index.ts diff --git a/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx similarity index 100% rename from web/ce/components/command-palette/helpers.tsx rename to apps/web/ce/components/command-palette/helpers.tsx diff --git a/web/ce/components/command-palette/index.ts b/apps/web/ce/components/command-palette/index.ts similarity index 100% rename from web/ce/components/command-palette/index.ts rename to apps/web/ce/components/command-palette/index.ts diff --git a/web/ce/components/command-palette/modals/index.ts b/apps/web/ce/components/command-palette/modals/index.ts similarity index 100% rename from web/ce/components/command-palette/modals/index.ts rename to apps/web/ce/components/command-palette/modals/index.ts diff --git a/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/issue-level.tsx similarity index 97% rename from web/ce/components/command-palette/modals/issue-level.tsx rename to apps/web/ce/components/command-palette/modals/issue-level.tsx index f88908f2535..9f80727d3cc 100644 --- a/web/ce/components/command-palette/modals/issue-level.tsx +++ b/apps/web/ce/components/command-palette/modals/issue-level.tsx @@ -2,8 +2,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; // plane imports -import { EIssueServiceType, EIssuesStoreType } from "@plane/constants"; -import { TIssue } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType, TIssue } from "@plane/types"; // components import { BulkDeleteIssuesModal } from "@/components/core"; import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; diff --git a/web/ce/components/command-palette/modals/project-level.tsx b/apps/web/ce/components/command-palette/modals/project-level.tsx similarity index 100% rename from web/ce/components/command-palette/modals/project-level.tsx rename to apps/web/ce/components/command-palette/modals/project-level.tsx diff --git a/web/ce/components/command-palette/modals/workspace-level.tsx b/apps/web/ce/components/command-palette/modals/workspace-level.tsx similarity index 100% rename from web/ce/components/command-palette/modals/workspace-level.tsx rename to apps/web/ce/components/command-palette/modals/workspace-level.tsx diff --git a/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx similarity index 96% rename from web/ce/components/comments/comment-block.tsx rename to apps/web/ce/components/comments/comment-block.tsx index 11b98d6cb1a..08c5bb331c2 100644 --- a/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -6,7 +6,6 @@ import { TIssueComment } from "@plane/types"; import { Avatar, Tooltip } from "@plane/ui"; import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // hooks -// import { useMember } from "@/hooks/store"; type TCommentBlock = { @@ -18,13 +17,17 @@ type TCommentBlock = { export const CommentBlock: FC = observer((props) => { const { comment, ends, quickActions, children } = props; + // refs const commentBlockRef = useRef(null); // store hooks const { getUserDetails } = useMember(); - const { t } = useTranslation(); + // derived values const userDetails = getUserDetails(comment?.actor); + // translation + const { t } = useTranslation(); + + if (!comment || !userDetails) return null; - if (!comment || !userDetails) return <>; return (
{ + const { header } = props; + // store hooks + const { sidebarCollapsed } = useAppTheme(); + + return ( + <> + {sidebarCollapsed && } +
{header}
+ + ); +}; diff --git a/apps/web/ce/components/common/index.ts b/apps/web/ce/components/common/index.ts new file mode 100644 index 00000000000..38406d4cc32 --- /dev/null +++ b/apps/web/ce/components/common/index.ts @@ -0,0 +1,2 @@ +export * from "./subscription"; +export * from "./extended-app-header"; diff --git a/apps/web/ce/components/common/subscription/index.ts b/apps/web/ce/components/common/subscription/index.ts new file mode 100644 index 00000000000..cfd65903d4a --- /dev/null +++ b/apps/web/ce/components/common/subscription/index.ts @@ -0,0 +1 @@ +export * from "./subscription-pill"; diff --git a/web/ce/components/common/subscription/subscription-pill.tsx b/apps/web/ce/components/common/subscription/subscription-pill.tsx similarity index 100% rename from web/ce/components/common/subscription/subscription-pill.tsx rename to apps/web/ce/components/common/subscription/subscription-pill.tsx diff --git a/web/ce/components/cycles/analytics-sidebar/index.ts b/apps/web/ce/components/cycles/active-cycle/index.ts similarity index 100% rename from web/ce/components/cycles/analytics-sidebar/index.ts rename to apps/web/ce/components/cycles/active-cycle/index.ts diff --git a/web/ce/components/cycles/active-cycle/root.tsx b/apps/web/ce/components/cycles/active-cycle/root.tsx similarity index 100% rename from web/ce/components/cycles/active-cycle/root.tsx rename to apps/web/ce/components/cycles/active-cycle/root.tsx diff --git a/web/ce/components/cycles/additional-actions.tsx b/apps/web/ce/components/cycles/additional-actions.tsx similarity index 100% rename from web/ce/components/cycles/additional-actions.tsx rename to apps/web/ce/components/cycles/additional-actions.tsx diff --git a/web/ce/components/cycles/analytics-sidebar/base.tsx b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx similarity index 100% rename from web/ce/components/cycles/analytics-sidebar/base.tsx rename to apps/web/ce/components/cycles/analytics-sidebar/base.tsx diff --git a/web/ce/components/de-dupe/duplicate-modal/index.ts b/apps/web/ce/components/cycles/analytics-sidebar/index.ts similarity index 100% rename from web/ce/components/de-dupe/duplicate-modal/index.ts rename to apps/web/ce/components/cycles/analytics-sidebar/index.ts diff --git a/web/ce/components/cycles/analytics-sidebar/root.tsx b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx similarity index 100% rename from web/ce/components/cycles/analytics-sidebar/root.tsx rename to apps/web/ce/components/cycles/analytics-sidebar/root.tsx diff --git a/web/ce/components/cycles/end-cycle/index.ts b/apps/web/ce/components/cycles/end-cycle/index.ts similarity index 100% rename from web/ce/components/cycles/end-cycle/index.ts rename to apps/web/ce/components/cycles/end-cycle/index.ts diff --git a/web/ce/components/cycles/end-cycle/modal.tsx b/apps/web/ce/components/cycles/end-cycle/modal.tsx similarity index 100% rename from web/ce/components/cycles/end-cycle/modal.tsx rename to apps/web/ce/components/cycles/end-cycle/modal.tsx diff --git a/web/ce/components/cycles/end-cycle/use-end-cycle.tsx b/apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx similarity index 100% rename from web/ce/components/cycles/end-cycle/use-end-cycle.tsx rename to apps/web/ce/components/cycles/end-cycle/use-end-cycle.tsx diff --git a/web/ce/components/cycles/index.ts b/apps/web/ce/components/cycles/index.ts similarity index 100% rename from web/ce/components/cycles/index.ts rename to apps/web/ce/components/cycles/index.ts diff --git a/web/ce/components/de-dupe/de-dupe-button.tsx b/apps/web/ce/components/de-dupe/de-dupe-button.tsx similarity index 100% rename from web/ce/components/de-dupe/de-dupe-button.tsx rename to apps/web/ce/components/de-dupe/de-dupe-button.tsx diff --git a/web/ce/components/de-dupe/duplicate-popover/index.ts b/apps/web/ce/components/de-dupe/duplicate-modal/index.ts similarity index 100% rename from web/ce/components/de-dupe/duplicate-popover/index.ts rename to apps/web/ce/components/de-dupe/duplicate-modal/index.ts diff --git a/web/ce/components/de-dupe/duplicate-modal/root.tsx b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx similarity index 100% rename from web/ce/components/de-dupe/duplicate-modal/root.tsx rename to apps/web/ce/components/de-dupe/duplicate-modal/root.tsx diff --git a/web/ce/components/editor/embeds/mentions/index.ts b/apps/web/ce/components/de-dupe/duplicate-popover/index.ts similarity index 100% rename from web/ce/components/editor/embeds/mentions/index.ts rename to apps/web/ce/components/de-dupe/duplicate-popover/index.ts diff --git a/web/ce/components/de-dupe/duplicate-popover/root.tsx b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx similarity index 100% rename from web/ce/components/de-dupe/duplicate-popover/root.tsx rename to apps/web/ce/components/de-dupe/duplicate-popover/root.tsx diff --git a/web/ce/components/de-dupe/index.ts b/apps/web/ce/components/de-dupe/index.ts similarity index 100% rename from web/ce/components/de-dupe/index.ts rename to apps/web/ce/components/de-dupe/index.ts diff --git a/web/ce/components/de-dupe/issue-block/button-label.tsx b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx similarity index 100% rename from web/ce/components/de-dupe/issue-block/button-label.tsx rename to apps/web/ce/components/de-dupe/issue-block/button-label.tsx diff --git a/web/ce/components/de-dupe/issue-block/index.ts b/apps/web/ce/components/de-dupe/issue-block/index.ts similarity index 100% rename from web/ce/components/de-dupe/issue-block/index.ts rename to apps/web/ce/components/de-dupe/issue-block/index.ts diff --git a/web/ce/components/editor/embeds/index.ts b/apps/web/ce/components/editor/embeds/index.ts similarity index 100% rename from web/ce/components/editor/embeds/index.ts rename to apps/web/ce/components/editor/embeds/index.ts diff --git a/web/ce/components/issues/issue-details/issue-properties-activity/index.ts b/apps/web/ce/components/editor/embeds/mentions/index.ts similarity index 100% rename from web/ce/components/issues/issue-details/issue-properties-activity/index.ts rename to apps/web/ce/components/editor/embeds/mentions/index.ts diff --git a/web/ce/components/editor/embeds/mentions/root.tsx b/apps/web/ce/components/editor/embeds/mentions/root.tsx similarity index 100% rename from web/ce/components/editor/embeds/mentions/root.tsx rename to apps/web/ce/components/editor/embeds/mentions/root.tsx diff --git a/web/ce/components/editor/index.ts b/apps/web/ce/components/editor/index.ts similarity index 100% rename from web/ce/components/editor/index.ts rename to apps/web/ce/components/editor/index.ts diff --git a/web/ce/components/epics/epic-modal/index.ts b/apps/web/ce/components/epics/epic-modal/index.ts similarity index 100% rename from web/ce/components/epics/epic-modal/index.ts rename to apps/web/ce/components/epics/epic-modal/index.ts diff --git a/web/ce/components/epics/epic-modal/modal.tsx b/apps/web/ce/components/epics/epic-modal/modal.tsx similarity index 100% rename from web/ce/components/epics/epic-modal/modal.tsx rename to apps/web/ce/components/epics/epic-modal/modal.tsx diff --git a/web/ce/components/epics/index.ts b/apps/web/ce/components/epics/index.ts similarity index 100% rename from web/ce/components/epics/index.ts rename to apps/web/ce/components/epics/index.ts diff --git a/web/ce/components/estimates/estimate-list-item-buttons.tsx b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx similarity index 75% rename from web/ce/components/estimates/estimate-list-item-buttons.tsx rename to apps/web/ce/components/estimates/estimate-list-item-buttons.tsx index 72acb4dfc56..58bae9b746d 100644 --- a/web/ce/components/estimates/estimate-list-item-buttons.tsx +++ b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -1,6 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Pen, Trash } from "lucide-react"; +import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { Tooltip } from "@plane/ui"; // components import { ProIcon } from "@/components/common"; @@ -29,13 +30,17 @@ export const EstimateListItemButtons: FC = observer((props) = } position="top" > - diff --git a/apps/web/ce/components/estimates/helper.tsx b/apps/web/ce/components/estimates/helper.tsx new file mode 100644 index 00000000000..5a1d9eaf441 --- /dev/null +++ b/apps/web/ce/components/estimates/helper.tsx @@ -0,0 +1,12 @@ +import { TEstimateSystemKeys, EEstimateSystem } from "@plane/types"; + +export const isEstimateSystemEnabled = (key: TEstimateSystemKeys) => { + switch (key) { + case EEstimateSystem.POINTS: + return true; + case EEstimateSystem.CATEGORIES: + return true; + default: + return false; + } +}; diff --git a/web/ce/components/estimates/index.ts b/apps/web/ce/components/estimates/index.ts similarity index 100% rename from web/ce/components/estimates/index.ts rename to apps/web/ce/components/estimates/index.ts diff --git a/web/ce/components/estimates/inputs/index.ts b/apps/web/ce/components/estimates/inputs/index.ts similarity index 100% rename from web/ce/components/estimates/inputs/index.ts rename to apps/web/ce/components/estimates/inputs/index.ts diff --git a/web/ce/components/estimates/inputs/time-input.tsx b/apps/web/ce/components/estimates/inputs/time-input.tsx similarity index 100% rename from web/ce/components/estimates/inputs/time-input.tsx rename to apps/web/ce/components/estimates/inputs/time-input.tsx diff --git a/web/ce/components/estimates/points/delete.tsx b/apps/web/ce/components/estimates/points/delete.tsx similarity index 100% rename from web/ce/components/estimates/points/delete.tsx rename to apps/web/ce/components/estimates/points/delete.tsx diff --git a/web/ce/components/estimates/points/index.ts b/apps/web/ce/components/estimates/points/index.ts similarity index 100% rename from web/ce/components/estimates/points/index.ts rename to apps/web/ce/components/estimates/points/index.ts diff --git a/web/ce/components/estimates/update/index.ts b/apps/web/ce/components/estimates/update/index.ts similarity index 100% rename from web/ce/components/estimates/update/index.ts rename to apps/web/ce/components/estimates/update/index.ts diff --git a/web/ce/components/estimates/update/modal.tsx b/apps/web/ce/components/estimates/update/modal.tsx similarity index 100% rename from web/ce/components/estimates/update/modal.tsx rename to apps/web/ce/components/estimates/update/modal.tsx diff --git a/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx new file mode 100644 index 00000000000..ec7e52fd123 --- /dev/null +++ b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx @@ -0,0 +1,59 @@ +import { FC } from "react"; +// components +import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; +import RenderIfVisible from "@/components/core/render-if-visible-HOC"; +// hooks +import { BlockRow } from "@/components/gantt-chart/blocks/block-row"; +import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +// types + +export type GanttChartBlocksProps = { + blockIds: string[]; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + handleScrollToBlock: (block: IGanttBlock) => void; + enableAddBlock: boolean | ((blockId: string) => boolean); + showAllBlocks: boolean; + selectionHelpers: TSelectionHelper; + ganttContainerRef: React.RefObject; +}; + +export const GanttChartRowList: FC = (props) => { + const { + blockIds, + blockUpdateHandler, + handleScrollToBlock, + enableAddBlock, + showAllBlocks, + selectionHelpers, + ganttContainerRef, + } = props; + + return ( +
+ {blockIds?.map((blockId) => ( + <> + } + shouldRecordHeights={false} + > + + + + ))} +
+ ); +}; diff --git a/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx new file mode 100644 index 00000000000..0c8f2a7c0b2 --- /dev/null +++ b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx @@ -0,0 +1,53 @@ +import { FC } from "react"; +// +import type { IBlockUpdateDependencyData } from "@plane/types"; +import { GanttChartBlock } from "@/components/gantt-chart/blocks/block"; + +export type GanttChartBlocksProps = { + blockIds: string[]; + blockToRender: (data: any) => React.ReactNode; + enableBlockLeftResize: boolean | ((blockId: string) => boolean); + enableBlockRightResize: boolean | ((blockId: string) => boolean); + enableBlockMove: boolean | ((blockId: string) => boolean); + ganttContainerRef: React.RefObject; + showAllBlocks: boolean; + updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise; + enableDependency: boolean | ((blockId: string) => boolean); +}; + +export const GanttChartBlocksList: FC = (props) => { + const { + blockIds, + blockToRender, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + ganttContainerRef, + showAllBlocks, + updateBlockDates, + enableDependency, + } = props; + + return ( + <> + {blockIds?.map((blockId) => ( + + ))} + + ); +}; diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts similarity index 100% rename from web/ce/components/gantt-chart/dependency/blockDraggables/index.ts rename to apps/web/ce/components/gantt-chart/dependency/blockDraggables/index.ts diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx similarity index 100% rename from web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx rename to apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx diff --git a/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx similarity index 100% rename from web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx rename to apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx diff --git a/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx similarity index 100% rename from web/ce/components/gantt-chart/dependency/dependency-paths.tsx rename to apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx diff --git a/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx b/apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx similarity index 100% rename from web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx rename to apps/web/ce/components/gantt-chart/dependency/draggable-dependency-path.tsx diff --git a/web/ce/components/gantt-chart/dependency/index.ts b/apps/web/ce/components/gantt-chart/dependency/index.ts similarity index 100% rename from web/ce/components/gantt-chart/dependency/index.ts rename to apps/web/ce/components/gantt-chart/dependency/index.ts diff --git a/web/ce/components/gantt-chart/index.ts b/apps/web/ce/components/gantt-chart/index.ts similarity index 100% rename from web/ce/components/gantt-chart/index.ts rename to apps/web/ce/components/gantt-chart/index.ts diff --git a/web/ce/components/global/index.ts b/apps/web/ce/components/global/index.ts similarity index 100% rename from web/ce/components/global/index.ts rename to apps/web/ce/components/global/index.ts diff --git a/web/ce/components/global/product-updates-header.tsx b/apps/web/ce/components/global/product-updates-header.tsx similarity index 98% rename from web/ce/components/global/product-updates-header.tsx rename to apps/web/ce/components/global/product-updates-header.tsx index 8a2a94c5b42..776d9667dd7 100644 --- a/web/ce/components/global/product-updates-header.tsx +++ b/apps/web/ce/components/global/product-updates-header.tsx @@ -14,7 +14,7 @@ export const ProductUpdatesHeader = observer(() => {
{t("whats_new")}
-
{ + const { t } = useTranslation(); + return ( + + {t("version")}: v{packageJson.version} + + ); +}; diff --git a/web/ce/components/home/header.tsx b/apps/web/ce/components/home/header.tsx similarity index 100% rename from web/ce/components/home/header.tsx rename to apps/web/ce/components/home/header.tsx diff --git a/web/ce/components/home/index.ts b/apps/web/ce/components/home/index.ts similarity index 100% rename from web/ce/components/home/index.ts rename to apps/web/ce/components/home/index.ts diff --git a/web/ce/components/home/peek-overviews.tsx b/apps/web/ce/components/home/peek-overviews.tsx similarity index 100% rename from web/ce/components/home/peek-overviews.tsx rename to apps/web/ce/components/home/peek-overviews.tsx diff --git a/apps/web/ce/components/inbox/source-pill.tsx b/apps/web/ce/components/inbox/source-pill.tsx new file mode 100644 index 00000000000..07e72178075 --- /dev/null +++ b/apps/web/ce/components/inbox/source-pill.tsx @@ -0,0 +1,7 @@ +import { EInboxIssueSource } from "@plane/types"; + +export type TInboxSourcePill = { + source: EInboxIssueSource; +}; + +export const InboxSourcePill = (props: TInboxSourcePill) => <>; diff --git a/web/ce/components/instance/index.ts b/apps/web/ce/components/instance/index.ts similarity index 100% rename from web/ce/components/instance/index.ts rename to apps/web/ce/components/instance/index.ts diff --git a/web/ce/components/instance/maintenance-message.tsx b/apps/web/ce/components/instance/maintenance-message.tsx similarity index 100% rename from web/ce/components/instance/maintenance-message.tsx rename to apps/web/ce/components/instance/maintenance-message.tsx diff --git a/web/ce/components/issues/quick-add/index.ts b/apps/web/ce/components/issues/bulk-operations/index.ts similarity index 100% rename from web/ce/components/issues/quick-add/index.ts rename to apps/web/ce/components/issues/bulk-operations/index.ts diff --git a/web/ce/components/issues/bulk-operations/root.tsx b/apps/web/ce/components/issues/bulk-operations/root.tsx similarity index 100% rename from web/ce/components/issues/bulk-operations/root.tsx rename to apps/web/ce/components/issues/bulk-operations/root.tsx diff --git a/web/ce/components/issues/filters/applied-filters/index.ts b/apps/web/ce/components/issues/filters/applied-filters/index.ts similarity index 100% rename from web/ce/components/issues/filters/applied-filters/index.ts rename to apps/web/ce/components/issues/filters/applied-filters/index.ts diff --git a/web/ce/components/issues/filters/applied-filters/issue-types.tsx b/apps/web/ce/components/issues/filters/applied-filters/issue-types.tsx similarity index 100% rename from web/ce/components/issues/filters/applied-filters/issue-types.tsx rename to apps/web/ce/components/issues/filters/applied-filters/issue-types.tsx diff --git a/web/ce/components/issues/filters/index.ts b/apps/web/ce/components/issues/filters/index.ts similarity index 100% rename from web/ce/components/issues/filters/index.ts rename to apps/web/ce/components/issues/filters/index.ts diff --git a/web/ce/components/issues/filters/issue-types.tsx b/apps/web/ce/components/issues/filters/issue-types.tsx similarity index 100% rename from web/ce/components/issues/filters/issue-types.tsx rename to apps/web/ce/components/issues/filters/issue-types.tsx diff --git a/web/ce/components/issues/filters/team-project.tsx b/apps/web/ce/components/issues/filters/team-project.tsx similarity index 100% rename from web/ce/components/issues/filters/team-project.tsx rename to apps/web/ce/components/issues/filters/team-project.tsx diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx new file mode 100644 index 00000000000..cad3d0f22dd --- /dev/null +++ b/apps/web/ce/components/issues/header.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// icons +import { Circle, ExternalLink } from "lucide-react"; +// plane imports +import { + EUserPermissions, + EUserPermissionsLevel, + SPACE_BASE_PATH, + SPACE_BASE_URL, + WORK_ITEM_TRACKER_ELEMENTS, + EProjectFeatureKey, +} from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EIssuesStoreType } from "@plane/types"; +import { Breadcrumbs, Button, Tooltip, Header } from "@plane/ui"; +// components +import { CountChip } from "@/components/common"; +// constants +import HeaderFilters from "@/components/issues/filters"; +// helpers +// hooks +import { useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web +import { CommonProjectBreadcrumbs } from "../breadcrumbs/common"; + +export const IssuesHeader = observer(() => { + // router + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; + // store hooks + const { + issues: { getGroupIssueCount }, + } = useIssues(EIssuesStoreType.PROJECT); + // i18n + const { t } = useTranslation(); + + const { currentProjectDetails, loader } = useProject(); + + const { toggleCreateIssueModal } = useCommandPalette(); + const { allowPermissions } = useUserPermissions(); + const { isMobile } = usePlatformOS(); + + const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; + const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`; + + const issuesCount = getGroupIssueCount(undefined, undefined, false); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + return ( +
+ +
+ router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> + + + {issuesCount && issuesCount > 0 ? ( + 1 ? "work items" : "work item"} in this project`} + position="bottom" + > + + + ) : null} +
+ {currentProjectDetails?.anchor ? ( + + + {t("workspace_projects.network.public.title")} + + + ) : ( + <> + )} +
+ +
+ +
+ {canUserCreateIssue ? ( + + ) : ( + <> + )} +
+
+ ); +}); diff --git a/web/ce/components/issues/index.ts b/apps/web/ce/components/issues/index.ts similarity index 100% rename from web/ce/components/issues/index.ts rename to apps/web/ce/components/issues/index.ts diff --git a/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx similarity index 100% rename from web/ce/components/issues/issue-detail-widgets/action-buttons.tsx rename to apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx diff --git a/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx similarity index 100% rename from web/ce/components/issues/issue-detail-widgets/collapsibles.tsx rename to apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx diff --git a/web/ce/components/issues/issue-detail-widgets/modals.tsx b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx similarity index 100% rename from web/ce/components/issues/issue-detail-widgets/modals.tsx rename to apps/web/ce/components/issues/issue-detail-widgets/modals.tsx diff --git a/web/ce/components/issues/issue-details/additional-activity-root.tsx b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx similarity index 100% rename from web/ce/components/issues/issue-details/additional-activity-root.tsx rename to apps/web/ce/components/issues/issue-details/additional-activity-root.tsx diff --git a/web/ce/components/issues/issue-details/additional-properties.tsx b/apps/web/ce/components/issues/issue-details/additional-properties.tsx similarity index 100% rename from web/ce/components/issues/issue-details/additional-properties.tsx rename to apps/web/ce/components/issues/issue-details/additional-properties.tsx diff --git a/web/ce/components/issues/issue-details/index.ts b/apps/web/ce/components/issues/issue-details/index.ts similarity index 100% rename from web/ce/components/issues/issue-details/index.ts rename to apps/web/ce/components/issues/issue-details/index.ts diff --git a/web/ce/components/issues/issue-details/issue-creator.tsx b/apps/web/ce/components/issues/issue-details/issue-creator.tsx similarity index 100% rename from web/ce/components/issues/issue-details/issue-creator.tsx rename to apps/web/ce/components/issues/issue-details/issue-creator.tsx diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx similarity index 100% rename from web/ce/components/issues/issue-details/issue-identifier.tsx rename to apps/web/ce/components/issues/issue-details/issue-identifier.tsx diff --git a/web/ce/components/issues/worklog/property/index.ts b/apps/web/ce/components/issues/issue-details/issue-properties-activity/index.ts similarity index 100% rename from web/ce/components/issues/worklog/property/index.ts rename to apps/web/ce/components/issues/issue-details/issue-properties-activity/index.ts diff --git a/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx similarity index 100% rename from web/ce/components/issues/issue-details/issue-properties-activity/root.tsx rename to apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx diff --git a/web/ce/components/issues/issue-details/issue-type-activity.tsx b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx similarity index 100% rename from web/ce/components/issues/issue-details/issue-type-activity.tsx rename to apps/web/ce/components/issues/issue-details/issue-type-activity.tsx diff --git a/web/ce/components/issues/issue-details/issue-type-switcher.tsx b/apps/web/ce/components/issues/issue-details/issue-type-switcher.tsx similarity index 100% rename from web/ce/components/issues/issue-details/issue-type-switcher.tsx rename to apps/web/ce/components/issues/issue-details/issue-type-switcher.tsx diff --git a/web/ce/components/issues/issue-details/parent-select-root.tsx b/apps/web/ce/components/issues/issue-details/parent-select-root.tsx similarity index 100% rename from web/ce/components/issues/issue-details/parent-select-root.tsx rename to apps/web/ce/components/issues/issue-details/parent-select-root.tsx diff --git a/web/ce/components/issues/issue-layouts/additional-properties.tsx b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx similarity index 100% rename from web/ce/components/issues/issue-layouts/additional-properties.tsx rename to apps/web/ce/components/issues/issue-layouts/additional-properties.tsx diff --git a/web/ce/components/issues/issue-layouts/empty-states/index.ts b/apps/web/ce/components/issues/issue-layouts/empty-states/index.ts similarity index 100% rename from web/ce/components/issues/issue-layouts/empty-states/index.ts rename to apps/web/ce/components/issues/issue-layouts/empty-states/index.ts diff --git a/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx b/apps/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx similarity index 100% rename from web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx rename to apps/web/ce/components/issues/issue-layouts/empty-states/team-issues.tsx diff --git a/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx b/apps/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx similarity index 100% rename from web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx rename to apps/web/ce/components/issues/issue-layouts/empty-states/team-view-issues.tsx diff --git a/web/ce/components/issues/issue-layouts/issue-stats.tsx b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx similarity index 100% rename from web/ce/components/issues/issue-layouts/issue-stats.tsx rename to apps/web/ce/components/issues/issue-layouts/issue-stats.tsx diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx similarity index 92% rename from web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx rename to apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx index f9aed404035..28ac44dc9cf 100644 --- a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx @@ -10,9 +10,9 @@ export interface CopyMenuHelperProps { shouldRender: boolean; }; activeLayout: string; - setTrackElement: (element: string) => void; setCreateUpdateIssueModal: (open: boolean) => void; setDuplicateWorkItemModal?: (open: boolean) => void; + workspaceSlug?: string; } export const createCopyMenuWithDuplication = (props: CopyMenuHelperProps): TContextMenuItem => { diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx similarity index 100% rename from web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx rename to apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx diff --git a/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts similarity index 100% rename from web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts rename to apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/index.ts diff --git a/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx similarity index 100% rename from web/ce/components/issues/issue-layouts/utils.tsx rename to apps/web/ce/components/issues/issue-layouts/utils.tsx diff --git a/apps/web/ce/components/issues/issue-modal/index.ts b/apps/web/ce/components/issues/issue-modal/index.ts new file mode 100644 index 00000000000..304be8c9164 --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./provider"; +export * from "./issue-type-select"; +export * from "./template-select"; diff --git a/web/ce/components/issues/issue-modal/issue-type-select.tsx b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx similarity index 100% rename from web/ce/components/issues/issue-modal/issue-type-select.tsx rename to apps/web/ce/components/issues/issue-modal/issue-type-select.tsx diff --git a/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx new file mode 100644 index 00000000000..ad35b48922e --- /dev/null +++ b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx @@ -0,0 +1,10 @@ +import React from "react"; + +export type TWorkItemModalAdditionalPropertiesProps = { + isDraft?: boolean; + projectId: string | null; + workItemId: string | undefined; + workspaceSlug: string; +}; + +export const WorkItemModalAdditionalProperties: React.FC = () => null; diff --git a/web/ce/components/issues/issue-modal/provider.tsx b/apps/web/ce/components/issues/issue-modal/provider.tsx similarity index 94% rename from web/ce/components/issues/issue-modal/provider.tsx rename to apps/web/ce/components/issues/issue-modal/provider.tsx index 788f1ae86be..0a0ef1e3f37 100644 --- a/web/ce/components/issues/issue-modal/provider.tsx +++ b/apps/web/ce/components/issues/issue-modal/provider.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // plane imports import { ISearchIssueResponse, TIssue } from "@plane/types"; // components @@ -44,6 +44,7 @@ export const IssueModalProvider = observer((props: TIssueModalProviderProps) => handleProjectEntitiesFetch: () => Promise.resolve(), handleTemplateChange: () => Promise.resolve(), handleConvert: () => Promise.resolve(), + handleCreateSubWorkItem: () => Promise.resolve(), }} > {children} diff --git a/web/ce/components/issues/issue-modal/template-select.tsx b/apps/web/ce/components/issues/issue-modal/template-select.tsx similarity index 100% rename from web/ce/components/issues/issue-modal/template-select.tsx rename to apps/web/ce/components/issues/issue-modal/template-select.tsx diff --git a/web/core/components/analytics/insight-table/index.ts b/apps/web/ce/components/issues/quick-add/index.ts similarity index 100% rename from web/core/components/analytics/insight-table/index.ts rename to apps/web/ce/components/issues/quick-add/index.ts diff --git a/apps/web/ce/components/issues/quick-add/root.tsx b/apps/web/ce/components/issues/quick-add/root.tsx new file mode 100644 index 00000000000..2a3bc5edbe7 --- /dev/null +++ b/apps/web/ce/components/issues/quick-add/root.tsx @@ -0,0 +1,76 @@ +import { FC, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import { UseFormRegister, UseFormSetFocus } from "react-hook-form"; +// plane constants +// plane helpers +import { useOutsideClickDetector } from "@plane/hooks"; +// types +import { TIssue, EIssueLayoutTypes } from "@plane/types"; +// components +import { + CalendarQuickAddIssueForm, + GanttQuickAddIssueForm, + KanbanQuickAddIssueForm, + ListQuickAddIssueForm, + SpreadsheetQuickAddIssueForm, + TQuickAddIssueForm, +} from "@/components/issues/issue-layouts"; +// hooks +import { useProject } from "@/hooks/store"; +import useKeypress from "@/hooks/use-keypress"; + +export type TQuickAddIssueFormRoot = { + isOpen: boolean; + layout: EIssueLayoutTypes; + prePopulatedData?: Partial; + projectId: string; + hasError?: boolean; + setFocus: UseFormSetFocus; + register: UseFormRegister; + onSubmit: () => void; + onClose: () => void; + isEpic: boolean; +}; + +export const QuickAddIssueFormRoot: FC = observer((props) => { + const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose, isEpic } = props; + // store hooks + const { getProjectById } = useProject(); + // derived values + const projectDetail = getProjectById(projectId); + // refs + const ref = useRef(null); + // click detection + useKeypress("Escape", onClose); + useOutsideClickDetector(ref, onClose); + // set focus on name input + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + if (!projectDetail) return <>; + + const QUICK_ADD_ISSUE_FORMS: Record> = { + [EIssueLayoutTypes.LIST]: ListQuickAddIssueForm, + [EIssueLayoutTypes.KANBAN]: KanbanQuickAddIssueForm, + [EIssueLayoutTypes.CALENDAR]: CalendarQuickAddIssueForm, + [EIssueLayoutTypes.GANTT]: GanttQuickAddIssueForm, + [EIssueLayoutTypes.SPREADSHEET]: SpreadsheetQuickAddIssueForm, + }; + + const CurrentLayoutQuickAddIssueForm = QUICK_ADD_ISSUE_FORMS[layout] ?? null; + + if (!CurrentLayoutQuickAddIssueForm) return <>; + + return ( + + ); +}); diff --git a/web/ce/components/issues/worklog/activity/filter-root.tsx b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx similarity index 100% rename from web/ce/components/issues/worklog/activity/filter-root.tsx rename to apps/web/ce/components/issues/worklog/activity/filter-root.tsx diff --git a/web/ce/components/issues/worklog/activity/index.ts b/apps/web/ce/components/issues/worklog/activity/index.ts similarity index 100% rename from web/ce/components/issues/worklog/activity/index.ts rename to apps/web/ce/components/issues/worklog/activity/index.ts diff --git a/web/ce/components/issues/worklog/activity/root.tsx b/apps/web/ce/components/issues/worklog/activity/root.tsx similarity index 100% rename from web/ce/components/issues/worklog/activity/root.tsx rename to apps/web/ce/components/issues/worklog/activity/root.tsx diff --git a/web/ce/components/issues/worklog/activity/worklog-create-button.tsx b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx similarity index 100% rename from web/ce/components/issues/worklog/activity/worklog-create-button.tsx rename to apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx diff --git a/web/ce/components/issues/worklog/index.ts b/apps/web/ce/components/issues/worklog/index.ts similarity index 100% rename from web/ce/components/issues/worklog/index.ts rename to apps/web/ce/components/issues/worklog/index.ts diff --git a/web/core/components/analytics/overview/index.ts b/apps/web/ce/components/issues/worklog/property/index.ts similarity index 100% rename from web/core/components/analytics/overview/index.ts rename to apps/web/ce/components/issues/worklog/property/index.ts diff --git a/web/ce/components/issues/worklog/property/root.tsx b/apps/web/ce/components/issues/worklog/property/root.tsx similarity index 100% rename from web/ce/components/issues/worklog/property/root.tsx rename to apps/web/ce/components/issues/worklog/property/root.tsx diff --git a/web/ce/components/license/index.ts b/apps/web/ce/components/license/index.ts similarity index 100% rename from web/ce/components/license/index.ts rename to apps/web/ce/components/license/index.ts diff --git a/web/ce/components/license/modal/index.ts b/apps/web/ce/components/license/modal/index.ts similarity index 100% rename from web/ce/components/license/modal/index.ts rename to apps/web/ce/components/license/modal/index.ts diff --git a/web/ce/components/license/modal/upgrade-modal.tsx b/apps/web/ce/components/license/modal/upgrade-modal.tsx similarity index 98% rename from web/ce/components/license/modal/upgrade-modal.tsx rename to apps/web/ce/components/license/modal/upgrade-modal.tsx index 3b209198aff..531925c0401 100644 --- a/web/ce/components/license/modal/upgrade-modal.tsx +++ b/apps/web/ce/components/license/modal/upgrade-modal.tsx @@ -6,13 +6,13 @@ import { observer } from "mobx-react"; import { BUSINESS_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES, - EProductSubscriptionEnum, PLANE_COMMUNITY_PRODUCTS, PRO_PLAN_FEATURES, SUBSCRIPTION_REDIRECTION_URLS, SUBSCRIPTION_WEBPAGE_URLS, TALK_TO_SALES_URL, } from "@plane/constants"; +import { EProductSubscriptionEnum } from "@plane/types"; import { EModalWidth, ModalCore } from "@plane/ui"; import { cn } from "@plane/utils"; // components diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx similarity index 97% rename from web/ce/components/pages/editor/ai/ask-pi-menu.tsx rename to apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index 93d0c998a64..19e658b12b2 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/apps/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -4,7 +4,7 @@ import { CircleArrowUp, CornerDownRight, RefreshCcw, Sparkles } from "lucide-rea import { Tooltip } from "@plane/ui"; // components import { cn } from "@plane/utils"; -import { RichTextReadOnlyEditor } from "@/components/editor"; +import { RichTextEditor } from "@/components/editor"; // helpers // hooks import { useWorkspace } from "@/hooks/store"; @@ -38,7 +38,8 @@ export const AskPiMenu: React.FC = (props) => { {response ? (
- = (props) => { {response ? (
- {
diff --git a/apps/web/ce/components/workspace/billing/comparison/root.tsx b/apps/web/ce/components/workspace/billing/comparison/root.tsx new file mode 100644 index 00000000000..cf8f3dec522 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/comparison/root.tsx @@ -0,0 +1,47 @@ +import { observer } from "mobx-react"; +// plane imports +import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +// components +import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base"; +import { PLANE_PLANS, TPlanePlans } from "@/constants/plans"; +// plane web imports +import { PlanDetail } from "./plan-detail"; + +type TPlansComparisonProps = { + isCompareAllFeaturesSectionOpen: boolean; + getBillingFrequency: (subscriptionType: EProductSubscriptionEnum) => TBillingFrequency | undefined; + setBillingFrequency: (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency) => void; + setIsCompareAllFeaturesSectionOpen: React.Dispatch>; +}; + +export const PlansComparison = observer((props: TPlansComparisonProps) => { + const { + isCompareAllFeaturesSectionOpen, + getBillingFrequency, + setBillingFrequency, + setIsCompareAllFeaturesSectionOpen, + } = props; + // plan details + const { planDetails } = PLANE_PLANS; + + return ( + { + const currentPlanKey = planKey as TPlanePlans; + if (!shouldRenderPlanDetail(currentPlanKey)) return null; + return ( + setBillingFrequency(plan.id, frequency)} + /> + ); + })} + isSelfManaged + isCompareAllFeaturesSectionOpen={isCompareAllFeaturesSectionOpen} + setIsCompareAllFeaturesSectionOpen={setIsCompareAllFeaturesSectionOpen} + /> + ); +}); diff --git a/web/ce/components/workspace/billing/index.ts b/apps/web/ce/components/workspace/billing/index.ts similarity index 100% rename from web/ce/components/workspace/billing/index.ts rename to apps/web/ce/components/workspace/billing/index.ts diff --git a/apps/web/ce/components/workspace/billing/root.tsx b/apps/web/ce/components/workspace/billing/root.tsx new file mode 100644 index 00000000000..c089a4905f6 --- /dev/null +++ b/apps/web/ce/components/workspace/billing/root.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { DEFAULT_PRODUCT_BILLING_FREQUENCY, SUBSCRIPTION_WITH_BILLING_FREQUENCY } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { EProductSubscriptionEnum, TBillingFrequency, TProductBillingFrequency } from "@plane/types"; +import { getSubscriptionTextColor } from "@plane/ui"; +import { cn } from "@plane/utils"; +// components +import { SettingsHeading } from "@/components/settings"; +// local imports +import { PlansComparison } from "./comparison/root"; + +export const BillingRoot = observer(() => { + const [isCompareAllFeaturesSectionOpen, setIsCompareAllFeaturesSectionOpen] = useState(false); + const [productBillingFrequency, setProductBillingFrequency] = useState( + DEFAULT_PRODUCT_BILLING_FREQUENCY + ); + const { t } = useTranslation(); + + /** + * Retrieves the billing frequency for a given subscription type + * @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to get frequency for + * @returns {TBillingFrequency | undefined} - Billing frequency if subscription supports it, undefined otherwise + */ + const getBillingFrequency = (subscriptionType: EProductSubscriptionEnum): TBillingFrequency | undefined => + SUBSCRIPTION_WITH_BILLING_FREQUENCY.includes(subscriptionType) + ? productBillingFrequency[subscriptionType] + : undefined; + + /** + * Updates the billing frequency for a specific subscription type + * @param {EProductSubscriptionEnum} subscriptionType - Type of subscription to update + * @param {TBillingFrequency} frequency - New billing frequency to set + * @returns {void} + */ + const setBillingFrequency = (subscriptionType: EProductSubscriptionEnum, frequency: TBillingFrequency): void => + setProductBillingFrequency({ ...productBillingFrequency, [subscriptionType]: frequency }); + + return ( +
+ +
+
+
+
+
+

+ Community +

+
+ Unlimited projects, issues, cycles, modules, pages, and storage +
+
+
+
+
+
All plans
+
+ +
+ ); +}); diff --git a/apps/web/ce/components/workspace/content-wrapper.tsx b/apps/web/ce/components/workspace/content-wrapper.tsx new file mode 100644 index 00000000000..79a74f56fdb --- /dev/null +++ b/apps/web/ce/components/workspace/content-wrapper.tsx @@ -0,0 +1,9 @@ +"use client"; +import React from "react"; +import { observer } from "mobx-react"; + +export const WorkspaceContentWrapper = observer(({ children }: { children: React.ReactNode }) => ( +
+
{children}
+
+)); diff --git a/web/ce/components/workspace/delete-workspace-modal.tsx b/apps/web/ce/components/workspace/delete-workspace-modal.tsx similarity index 100% rename from web/ce/components/workspace/delete-workspace-modal.tsx rename to apps/web/ce/components/workspace/delete-workspace-modal.tsx diff --git a/web/ce/components/workspace/delete-workspace-section.tsx b/apps/web/ce/components/workspace/delete-workspace-section.tsx similarity index 87% rename from web/ce/components/workspace/delete-workspace-section.tsx rename to apps/web/ce/components/workspace/delete-workspace-section.tsx index 00fb7b87891..33c85e5a94f 100644 --- a/web/ce/components/workspace/delete-workspace-section.tsx +++ b/apps/web/ce/components/workspace/delete-workspace-section.tsx @@ -2,6 +2,7 @@ import { FC, useState } from "react"; import { observer } from "mobx-react"; import { ChevronDown, ChevronUp } from "lucide-react"; // types +import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { IWorkspace } from "@plane/types"; // ui @@ -48,7 +49,11 @@ export const DeleteWorkspaceSection: FC = observer((props) => {t("workspace_settings.settings.general.delete_workspace_description")}
-
diff --git a/web/ce/components/workspace/edition-badge.tsx b/apps/web/ce/components/workspace/edition-badge.tsx similarity index 100% rename from web/ce/components/workspace/edition-badge.tsx rename to apps/web/ce/components/workspace/edition-badge.tsx diff --git a/apps/web/ce/components/workspace/index.ts b/apps/web/ce/components/workspace/index.ts new file mode 100644 index 00000000000..489ef6352cb --- /dev/null +++ b/apps/web/ce/components/workspace/index.ts @@ -0,0 +1,8 @@ +export * from "./edition-badge"; +export * from "./upgrade-badge"; +export * from "./billing"; +export * from "./delete-workspace-section"; +export * from "./sidebar"; +export * from "./members"; +export * from "./content-wrapper"; +export * from "./app-switcher"; diff --git a/web/ce/components/workspace/members/index.ts b/apps/web/ce/components/workspace/members/index.ts similarity index 100% rename from web/ce/components/workspace/members/index.ts rename to apps/web/ce/components/workspace/members/index.ts diff --git a/web/ce/components/workspace/members/invite-modal.tsx b/apps/web/ce/components/workspace/members/invite-modal.tsx similarity index 100% rename from web/ce/components/workspace/members/invite-modal.tsx rename to apps/web/ce/components/workspace/members/invite-modal.tsx diff --git a/web/ce/components/workspace/settings/useMemberColumns.tsx b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx similarity index 100% rename from web/ce/components/workspace/settings/useMemberColumns.tsx rename to apps/web/ce/components/workspace/settings/useMemberColumns.tsx diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx new file mode 100644 index 00000000000..6b8a94f6ba2 --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/app-search.tsx @@ -0,0 +1,24 @@ +import { observer } from "mobx-react"; +import { Search } from "lucide-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// hooks +import { useCommandPalette } from "@/hooks/store"; + +export const AppSearch = observer(() => { + // store hooks + const { toggleCommandPaletteModal } = useCommandPalette(); + // translation + const { t } = useTranslation(); + + return ( + + ); +}); diff --git a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx similarity index 98% rename from web/ce/components/workspace/sidebar/extended-sidebar-item.tsx rename to apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx index 5e7343e0a8a..815fdb2637e 100644 --- a/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx +++ b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -171,10 +171,7 @@ export const ExtendedSidebarItem: FC = observer((prop className={cn( "flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab", { - // "cursor-not-allowed opacity-60": project.sort_order === null, "cursor-grabbing": isDragging, - - // "!hidden": isSidebarCollapsed, } )} ref={dragHandleRef} diff --git a/web/ce/components/workspace/sidebar/helper.tsx b/apps/web/ce/components/workspace/sidebar/helper.tsx similarity index 100% rename from web/ce/components/workspace/sidebar/helper.tsx rename to apps/web/ce/components/workspace/sidebar/helper.tsx diff --git a/web/ce/components/workspace/sidebar/index.ts b/apps/web/ce/components/workspace/sidebar/index.ts similarity index 100% rename from web/ce/components/workspace/sidebar/index.ts rename to apps/web/ce/components/workspace/sidebar/index.ts diff --git a/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx new file mode 100644 index 00000000000..3b61b8ed6b4 --- /dev/null +++ b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -0,0 +1,71 @@ +"use client"; +import { FC } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +// components +import { SidebarNavItem } from "@/components/sidebar"; +import { NotificationAppSidebarOption } from "@/components/workspace-notifications"; +// hooks +import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store"; +// local imports +import { getSidebarNavigationItemIcon } from "./helper"; + +type TSidebarItemProps = { + item: IWorkspaceSidebarNavigationItem; +}; + +export const SidebarItem: FC = observer((props) => { + const { item } = props; + const { t } = useTranslation(); + // nextjs hooks + const pathname = usePathname(); + const { workspaceSlug } = useParams(); + const { allowPermissions } = useUserPermissions(); + const { getNavigationPreferences } = useWorkspace(); + const { data } = useUser(); + + // store hooks + const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme(); + + const handleLinkClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(); + } + if (isExtendedSidebarOpened) toggleExtendedSidebar(false); + }; + + const staticItems = ["home", "inbox", "pi-chat", "projects"]; + + if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) { + return null; + } + + const itemHref = + item.key === "your_work" + ? `/${workspaceSlug.toString()}${item.href}/${data?.id}` + : `/${workspaceSlug.toString()}${item.href}`; + + const isActive = itemHref === pathname; + + const sidebarPreference = getNavigationPreferences(workspaceSlug.toString()); + const isPinned = sidebarPreference?.[item.key]?.is_pinned; + if (!isPinned && !staticItems.includes(item.key)) return null; + + const icon = getSidebarNavigationItemIcon(item.key); + + return ( + handleLinkClick()}> + +
+ {icon} +

{t(item.labelTranslationKey)}

+
+ {item.key === "inbox" && } +
+ + ); +}); diff --git a/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx b/apps/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx similarity index 100% rename from web/ce/components/workspace/sidebar/teams-sidebar-list.tsx rename to apps/web/ce/components/workspace/sidebar/teams-sidebar-list.tsx diff --git a/web/ce/components/workspace/upgrade-badge.tsx b/apps/web/ce/components/workspace/upgrade-badge.tsx similarity index 100% rename from web/ce/components/workspace/upgrade-badge.tsx rename to apps/web/ce/components/workspace/upgrade-badge.tsx diff --git a/web/ce/constants/ai.ts b/apps/web/ce/constants/ai.ts similarity index 100% rename from web/ce/constants/ai.ts rename to apps/web/ce/constants/ai.ts diff --git a/web/ce/constants/editor.ts b/apps/web/ce/constants/editor.ts similarity index 100% rename from web/ce/constants/editor.ts rename to apps/web/ce/constants/editor.ts diff --git a/web/ce/constants/gantt-chart.ts b/apps/web/ce/constants/gantt-chart.ts similarity index 100% rename from web/ce/constants/gantt-chart.ts rename to apps/web/ce/constants/gantt-chart.ts diff --git a/web/ce/constants/index.ts b/apps/web/ce/constants/index.ts similarity index 100% rename from web/ce/constants/index.ts rename to apps/web/ce/constants/index.ts diff --git a/web/ce/constants/project/index.ts b/apps/web/ce/constants/project/index.ts similarity index 100% rename from web/ce/constants/project/index.ts rename to apps/web/ce/constants/project/index.ts diff --git a/web/ce/constants/project/settings/features.tsx b/apps/web/ce/constants/project/settings/features.tsx similarity index 100% rename from web/ce/constants/project/settings/features.tsx rename to apps/web/ce/constants/project/settings/features.tsx diff --git a/web/ce/constants/project/settings/index.ts b/apps/web/ce/constants/project/settings/index.ts similarity index 100% rename from web/ce/constants/project/settings/index.ts rename to apps/web/ce/constants/project/settings/index.ts diff --git a/web/ce/constants/project/settings/tabs.ts b/apps/web/ce/constants/project/settings/tabs.ts similarity index 100% rename from web/ce/constants/project/settings/tabs.ts rename to apps/web/ce/constants/project/settings/tabs.ts diff --git a/web/ce/constants/sidebar-favorites.ts b/apps/web/ce/constants/sidebar-favorites.ts similarity index 100% rename from web/ce/constants/sidebar-favorites.ts rename to apps/web/ce/constants/sidebar-favorites.ts diff --git a/apps/web/ce/helpers/command-palette.ts b/apps/web/ce/helpers/command-palette.ts new file mode 100644 index 00000000000..1cb04b623dd --- /dev/null +++ b/apps/web/ce/helpers/command-palette.ts @@ -0,0 +1,118 @@ +// types +import { + CYCLE_TRACKER_ELEMENTS, + MODULE_TRACKER_ELEMENTS, + PROJECT_PAGE_TRACKER_ELEMENTS, + PROJECT_TRACKER_ELEMENTS, + WORK_ITEM_TRACKER_ELEMENTS, +} from "@plane/constants"; +import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; +// store +import { captureClick } from "@/helpers/event-tracker.helper"; +import { store } from "@/lib/store-context"; + +export const getGlobalShortcutsList: () => TCommandPaletteActionList = () => { + const { toggleCreateIssueModal } = store.commandPalette; + + return { + c: { + title: "Create a new work item", + description: "Create a new work item in the current project", + action: () => { + toggleCreateIssueModal(true); + captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON }); + }, + }, + }; +}; + +export const getWorkspaceShortcutsList: () => TCommandPaletteActionList = () => { + const { toggleCreateProjectModal } = store.commandPalette; + + return { + p: { + title: "Create a new project", + description: "Create a new project in the current workspace", + action: () => { + toggleCreateProjectModal(true); + captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON }); + }, + }, + }; +}; + +export const getProjectShortcutsList: () => TCommandPaletteActionList = () => { + const { + toggleCreatePageModal, + toggleCreateModuleModal, + toggleCreateCycleModal, + toggleCreateViewModal, + toggleBulkDeleteIssueModal, + } = store.commandPalette; + + return { + d: { + title: "Create a new page", + description: "Create a new page in the current project", + action: () => { + toggleCreatePageModal({ isOpen: true }); + captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_CREATE_BUTTON }); + }, + }, + m: { + title: "Create a new module", + description: "Create a new module in the current project", + action: () => { + toggleCreateModuleModal(true); + captureClick({ elementName: MODULE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); + }, + }, + q: { + title: "Create a new cycle", + description: "Create a new cycle in the current project", + action: () => { + toggleCreateCycleModal(true); + captureClick({ elementName: CYCLE_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_ITEM }); + }, + }, + v: { + title: "Create a new view", + description: "Create a new view in the current project", + action: () => toggleCreateViewModal(true), + }, + backspace: { + title: "Bulk delete work items", + description: "Bulk delete work items in the current project", + action: () => toggleBulkDeleteIssueModal(true), + }, + delete: { + title: "Bulk delete work items", + description: "Bulk delete work items in the current project", + action: () => toggleBulkDeleteIssueModal(true), + }, + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const handleAdditionalKeyDownEvents = (e: KeyboardEvent) => null; + +export const getNavigationShortcutsList = (): TCommandPaletteShortcut[] => [ + { keys: "Ctrl,K", description: "Open command menu" }, +]; + +export const getCommonShortcutsList = (platform: string): TCommandPaletteShortcut[] => [ + { keys: "P", description: "Create project" }, + { keys: "C", description: "Create work item" }, + { keys: "Q", description: "Create cycle" }, + { keys: "M", description: "Create module" }, + { keys: "V", description: "Create view" }, + { keys: "D", description: "Create page" }, + { keys: "Delete", description: "Bulk delete work items" }, + { keys: "Shift,/", description: "Open shortcuts guide" }, + { + keys: platform === "MacOS" ? "Ctrl,control,C" : "Ctrl,Alt,C", + description: "Copy work item URL from the work item details page", + }, +]; + +export const getAdditionalShortcutsList = (): TCommandPaletteShortcutList[] => []; diff --git a/web/ce/helpers/epic-analytics.ts b/apps/web/ce/helpers/epic-analytics.ts similarity index 100% rename from web/ce/helpers/epic-analytics.ts rename to apps/web/ce/helpers/epic-analytics.ts diff --git a/web/ce/helpers/instance.helper.ts b/apps/web/ce/helpers/instance.helper.ts similarity index 100% rename from web/ce/helpers/instance.helper.ts rename to apps/web/ce/helpers/instance.helper.ts diff --git a/web/ce/helpers/issue-action-helper.ts b/apps/web/ce/helpers/issue-action-helper.ts similarity index 100% rename from web/ce/helpers/issue-action-helper.ts rename to apps/web/ce/helpers/issue-action-helper.ts diff --git a/web/ce/helpers/issue-filter.helper.ts b/apps/web/ce/helpers/issue-filter.helper.ts similarity index 100% rename from web/ce/helpers/issue-filter.helper.ts rename to apps/web/ce/helpers/issue-filter.helper.ts diff --git a/web/ce/helpers/pi-chat.helper.ts b/apps/web/ce/helpers/pi-chat.helper.ts similarity index 100% rename from web/ce/helpers/pi-chat.helper.ts rename to apps/web/ce/helpers/pi-chat.helper.ts diff --git a/web/ce/helpers/project-settings.ts b/apps/web/ce/helpers/project-settings.ts similarity index 100% rename from web/ce/helpers/project-settings.ts rename to apps/web/ce/helpers/project-settings.ts diff --git a/web/ce/helpers/workspace.helper.ts b/apps/web/ce/helpers/workspace.helper.ts similarity index 82% rename from web/ce/helpers/workspace.helper.ts rename to apps/web/ce/helpers/workspace.helper.ts index 7f223f12f41..5e4bf3e4641 100644 --- a/web/ce/helpers/workspace.helper.ts +++ b/apps/web/ce/helpers/workspace.helper.ts @@ -1,2 +1,2 @@ export type TRenderSettingsLink = (workspaceSlug: string, settingKey: string) => boolean; -export const shouldRenderSettingLink: TRenderSettingsLink = (workspaceSlug, settingKey) => true; \ No newline at end of file +export const shouldRenderSettingLink: TRenderSettingsLink = (workspaceSlug, settingKey) => true; diff --git a/web/ce/hooks/store/index.ts b/apps/web/ce/hooks/store/index.ts similarity index 100% rename from web/ce/hooks/store/index.ts rename to apps/web/ce/hooks/store/index.ts diff --git a/web/ce/hooks/store/use-page-store.ts b/apps/web/ce/hooks/store/use-page-store.ts similarity index 100% rename from web/ce/hooks/store/use-page-store.ts rename to apps/web/ce/hooks/store/use-page-store.ts diff --git a/web/ce/hooks/store/use-page.ts b/apps/web/ce/hooks/store/use-page.ts similarity index 100% rename from web/ce/hooks/store/use-page.ts rename to apps/web/ce/hooks/store/use-page.ts diff --git a/web/ce/hooks/use-additional-editor-mention.tsx b/apps/web/ce/hooks/use-additional-editor-mention.tsx similarity index 100% rename from web/ce/hooks/use-additional-editor-mention.tsx rename to apps/web/ce/hooks/use-additional-editor-mention.tsx diff --git a/web/ce/hooks/use-additional-favorite-item-details.ts b/apps/web/ce/hooks/use-additional-favorite-item-details.ts similarity index 100% rename from web/ce/hooks/use-additional-favorite-item-details.ts rename to apps/web/ce/hooks/use-additional-favorite-item-details.ts diff --git a/web/ce/hooks/use-bulk-operation-status.ts b/apps/web/ce/hooks/use-bulk-operation-status.ts similarity index 100% rename from web/ce/hooks/use-bulk-operation-status.ts rename to apps/web/ce/hooks/use-bulk-operation-status.ts diff --git a/web/ce/hooks/use-debounced-duplicate-issues.tsx b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx similarity index 100% rename from web/ce/hooks/use-debounced-duplicate-issues.tsx rename to apps/web/ce/hooks/use-debounced-duplicate-issues.tsx diff --git a/web/ce/hooks/use-editor-flagging.ts b/apps/web/ce/hooks/use-editor-flagging.ts similarity index 100% rename from web/ce/hooks/use-editor-flagging.ts rename to apps/web/ce/hooks/use-editor-flagging.ts diff --git a/web/ce/hooks/use-file-size.ts b/apps/web/ce/hooks/use-file-size.ts similarity index 100% rename from web/ce/hooks/use-file-size.ts rename to apps/web/ce/hooks/use-file-size.ts diff --git a/web/ce/hooks/use-issue-embed.tsx b/apps/web/ce/hooks/use-issue-embed.tsx similarity index 100% rename from web/ce/hooks/use-issue-embed.tsx rename to apps/web/ce/hooks/use-issue-embed.tsx diff --git a/web/ce/hooks/use-issue-properties.tsx b/apps/web/ce/hooks/use-issue-properties.tsx similarity index 100% rename from web/ce/hooks/use-issue-properties.tsx rename to apps/web/ce/hooks/use-issue-properties.tsx diff --git a/web/ce/hooks/use-notification-preview.tsx b/apps/web/ce/hooks/use-notification-preview.tsx similarity index 88% rename from web/ce/hooks/use-notification-preview.tsx rename to apps/web/ce/hooks/use-notification-preview.tsx index b0c18d5542d..d4299915836 100644 --- a/web/ce/hooks/use-notification-preview.tsx +++ b/apps/web/ce/hooks/use-notification-preview.tsx @@ -1,5 +1,4 @@ -import { EIssueServiceType } from "@plane/constants"; -import { IWorkItemPeekOverview } from "@plane/types"; +import { EIssueServiceType, IWorkItemPeekOverview } from "@plane/types"; import { IssuePeekOverview } from "@/components/issues"; import { useIssueDetail } from "@/hooks/store"; import { TPeekIssue } from "@/store/issue/issue-details/root.store"; diff --git a/web/ce/hooks/use-page-flag.ts b/apps/web/ce/hooks/use-page-flag.ts similarity index 100% rename from web/ce/hooks/use-page-flag.ts rename to apps/web/ce/hooks/use-page-flag.ts diff --git a/web/ce/hooks/use-workspace-issue-properties-extended.tsx b/apps/web/ce/hooks/use-workspace-issue-properties-extended.tsx similarity index 100% rename from web/ce/hooks/use-workspace-issue-properties-extended.tsx rename to apps/web/ce/hooks/use-workspace-issue-properties-extended.tsx diff --git a/web/ce/layouts/project-wrapper.tsx b/apps/web/ce/layouts/project-wrapper.tsx similarity index 100% rename from web/ce/layouts/project-wrapper.tsx rename to apps/web/ce/layouts/project-wrapper.tsx diff --git a/web/ce/layouts/workspace-wrapper.tsx b/apps/web/ce/layouts/workspace-wrapper.tsx similarity index 100% rename from web/ce/layouts/workspace-wrapper.tsx rename to apps/web/ce/layouts/workspace-wrapper.tsx diff --git a/web/ce/services/index.ts b/apps/web/ce/services/index.ts similarity index 100% rename from web/ce/services/index.ts rename to apps/web/ce/services/index.ts diff --git a/web/ce/services/project/estimate.service.ts b/apps/web/ce/services/project/estimate.service.ts similarity index 100% rename from web/ce/services/project/estimate.service.ts rename to apps/web/ce/services/project/estimate.service.ts diff --git a/apps/web/ce/services/project/index.ts b/apps/web/ce/services/project/index.ts new file mode 100644 index 00000000000..15e12c5fd3a --- /dev/null +++ b/apps/web/ce/services/project/index.ts @@ -0,0 +1,2 @@ +export * from "./estimate.service"; +export * from "./view.service"; diff --git a/web/ce/services/project/project-state.service.ts b/apps/web/ce/services/project/project-state.service.ts similarity index 100% rename from web/ce/services/project/project-state.service.ts rename to apps/web/ce/services/project/project-state.service.ts diff --git a/web/ce/services/project/view.service.ts b/apps/web/ce/services/project/view.service.ts similarity index 94% rename from web/ce/services/project/view.service.ts rename to apps/web/ce/services/project/view.service.ts index 475cb3124cf..5ab65a1b6b1 100644 --- a/web/ce/services/project/view.service.ts +++ b/apps/web/ce/services/project/view.service.ts @@ -1,5 +1,5 @@ -import { EViewAccess, API_BASE_URL } from "@plane/constants"; -import { TPublishViewSettings } from "@plane/types"; +import { API_BASE_URL } from "@plane/constants"; +import { EViewAccess, TPublishViewSettings } from "@plane/types"; import { ViewService as CoreViewService } from "@/services/view.service"; export class ViewService extends CoreViewService { diff --git a/apps/web/ce/services/workspace.service.ts b/apps/web/ce/services/workspace.service.ts new file mode 100644 index 00000000000..d1e175c8105 --- /dev/null +++ b/apps/web/ce/services/workspace.service.ts @@ -0,0 +1,24 @@ +import { API_BASE_URL } from "@plane/constants"; +import { EViewAccess } from "@plane/types"; +import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; + +export class WorkspaceService extends CoreWorkspaceService { + constructor() { + super(API_BASE_URL); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async lockView(workspaceSlug: string, viewId: string) { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async unLockView(workspaceSlug: string, viewId: string) { + return Promise.resolve(); + } +} diff --git a/web/ce/store/analytics.store.ts b/apps/web/ce/store/analytics.store.ts similarity index 100% rename from web/ce/store/analytics.store.ts rename to apps/web/ce/store/analytics.store.ts diff --git a/web/ce/store/command-palette.store.ts b/apps/web/ce/store/command-palette.store.ts similarity index 100% rename from web/ce/store/command-palette.store.ts rename to apps/web/ce/store/command-palette.store.ts diff --git a/web/ce/store/cycle/index.ts b/apps/web/ce/store/cycle/index.ts similarity index 100% rename from web/ce/store/cycle/index.ts rename to apps/web/ce/store/cycle/index.ts diff --git a/web/ce/store/estimates/estimate.ts b/apps/web/ce/store/estimates/estimate.ts similarity index 100% rename from web/ce/store/estimates/estimate.ts rename to apps/web/ce/store/estimates/estimate.ts diff --git a/web/ce/store/issue/epic/filter.store.ts b/apps/web/ce/store/issue/epic/filter.store.ts similarity index 100% rename from web/ce/store/issue/epic/filter.store.ts rename to apps/web/ce/store/issue/epic/filter.store.ts diff --git a/web/ce/store/issue/epic/index.ts b/apps/web/ce/store/issue/epic/index.ts similarity index 100% rename from web/ce/store/issue/epic/index.ts rename to apps/web/ce/store/issue/epic/index.ts diff --git a/web/ce/store/issue/epic/issue.store.ts b/apps/web/ce/store/issue/epic/issue.store.ts similarity index 100% rename from web/ce/store/issue/epic/issue.store.ts rename to apps/web/ce/store/issue/epic/issue.store.ts diff --git a/web/ce/store/issue/helpers/base-issue-store.ts b/apps/web/ce/store/issue/helpers/base-issue-store.ts similarity index 100% rename from web/ce/store/issue/helpers/base-issue-store.ts rename to apps/web/ce/store/issue/helpers/base-issue-store.ts diff --git a/web/ce/store/issue/helpers/base-issue.store.ts b/apps/web/ce/store/issue/helpers/base-issue.store.ts similarity index 100% rename from web/ce/store/issue/helpers/base-issue.store.ts rename to apps/web/ce/store/issue/helpers/base-issue.store.ts diff --git a/web/ce/store/issue/issue-details/activity.store.ts b/apps/web/ce/store/issue/issue-details/activity.store.ts similarity index 89% rename from web/ce/store/issue/issue-details/activity.store.ts rename to apps/web/ce/store/issue/issue-details/activity.store.ts index 93b925aba9d..47b4205a1b7 100644 --- a/web/ce/store/issue/issue-details/activity.store.ts +++ b/apps/web/ce/store/issue/issue-details/activity.store.ts @@ -8,8 +8,9 @@ import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // plane package imports -import { EIssueServiceType, E_SORT_ORDER, EActivityFilterType } from "@plane/constants"; +import { E_SORT_ORDER, EActivityFilterType } from "@plane/constants"; import { + EIssueServiceType, TIssueActivityComment, TIssueActivity, TIssueActivityMap, @@ -42,7 +43,7 @@ export interface IIssueActivityStore extends IIssueActivityStoreActions { // helper methods getActivitiesByIssueId: (issueId: string) => string[] | undefined; getActivityById: (activityId: string) => TIssueActivity | undefined; - getActivityCommentByIssueId: (issueId: string, sortOrder: E_SORT_ORDER) => TIssueActivityComment[] | undefined; + getActivityAndCommentsByIssueId: (issueId: string, sortOrder: E_SORT_ORDER) => TIssueActivityComment[] | undefined; } export class IssueActivityStore implements IIssueActivityStore { @@ -83,7 +84,7 @@ export class IssueActivityStore implements IIssueActivityStore { return this.activityMap[activityId] ?? undefined; }; - getActivityCommentByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { + getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { if (!issueId) return undefined; let activityComments: TIssueActivityComment[] = []; @@ -91,10 +92,12 @@ export class IssueActivityStore implements IIssueActivityStore { const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; - const activities = this.getActivitiesByIssueId(issueId) || []; - const comments = currentStore.comment.getCommentsByIssueId(issueId) || []; + const activities = this.getActivitiesByIssueId(issueId); + const comments = currentStore.comment.getCommentsByIssueId(issueId); - activities.forEach((activityId) => { + if (!activities || !comments) return undefined; + + activities?.forEach((activityId) => { const activity = this.getActivityById(activityId); if (!activity) return; activityComments.push({ @@ -104,7 +107,7 @@ export class IssueActivityStore implements IIssueActivityStore { }); }); - comments.forEach((commentId) => { + comments?.forEach((commentId) => { const comment = currentStore.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ diff --git a/web/ce/store/issue/issue-details/root.store.ts b/apps/web/ce/store/issue/issue-details/root.store.ts similarity index 100% rename from web/ce/store/issue/issue-details/root.store.ts rename to apps/web/ce/store/issue/issue-details/root.store.ts diff --git a/web/ce/store/issue/team-views/filter.store.ts b/apps/web/ce/store/issue/team-views/filter.store.ts similarity index 100% rename from web/ce/store/issue/team-views/filter.store.ts rename to apps/web/ce/store/issue/team-views/filter.store.ts diff --git a/web/ce/store/issue/team-views/index.ts b/apps/web/ce/store/issue/team-views/index.ts similarity index 100% rename from web/ce/store/issue/team-views/index.ts rename to apps/web/ce/store/issue/team-views/index.ts diff --git a/web/ce/store/issue/team-views/issue.store.ts b/apps/web/ce/store/issue/team-views/issue.store.ts similarity index 100% rename from web/ce/store/issue/team-views/issue.store.ts rename to apps/web/ce/store/issue/team-views/issue.store.ts diff --git a/web/ce/store/issue/team/filter.store.ts b/apps/web/ce/store/issue/team/filter.store.ts similarity index 100% rename from web/ce/store/issue/team/filter.store.ts rename to apps/web/ce/store/issue/team/filter.store.ts diff --git a/web/ce/store/issue/team/index.ts b/apps/web/ce/store/issue/team/index.ts similarity index 100% rename from web/ce/store/issue/team/index.ts rename to apps/web/ce/store/issue/team/index.ts diff --git a/web/ce/store/issue/team/issue.store.ts b/apps/web/ce/store/issue/team/issue.store.ts similarity index 100% rename from web/ce/store/issue/team/issue.store.ts rename to apps/web/ce/store/issue/team/issue.store.ts diff --git a/web/ce/store/issue/workspace/issue.store.ts b/apps/web/ce/store/issue/workspace/issue.store.ts similarity index 100% rename from web/ce/store/issue/workspace/issue.store.ts rename to apps/web/ce/store/issue/workspace/issue.store.ts diff --git a/web/ce/store/member/project-member.store.ts b/apps/web/ce/store/member/project-member.store.ts similarity index 96% rename from web/ce/store/member/project-member.store.ts rename to apps/web/ce/store/member/project-member.store.ts index 1b90c9c1179..717da465906 100644 --- a/web/ce/store/member/project-member.store.ts +++ b/apps/web/ce/store/member/project-member.store.ts @@ -1,6 +1,6 @@ import { computedFn } from "mobx-utils"; +import { EUserProjectRoles } from "@plane/types"; // plane imports -import { EUserProjectRoles } from "@plane/constants"; // plane web imports import { RootStore } from "@/plane-web/store/root.store"; // store diff --git a/web/ce/store/pages/extended-base-page.ts b/apps/web/ce/store/pages/extended-base-page.ts similarity index 100% rename from web/ce/store/pages/extended-base-page.ts rename to apps/web/ce/store/pages/extended-base-page.ts diff --git a/web/ce/store/project-inbox.store.ts b/apps/web/ce/store/project-inbox.store.ts similarity index 100% rename from web/ce/store/project-inbox.store.ts rename to apps/web/ce/store/project-inbox.store.ts diff --git a/web/ce/store/root.store.ts b/apps/web/ce/store/root.store.ts similarity index 100% rename from web/ce/store/root.store.ts rename to apps/web/ce/store/root.store.ts diff --git a/web/ce/store/state.store.ts b/apps/web/ce/store/state.store.ts similarity index 100% rename from web/ce/store/state.store.ts rename to apps/web/ce/store/state.store.ts diff --git a/web/ce/store/timeline/base-timeline.store.ts b/apps/web/ce/store/timeline/base-timeline.store.ts similarity index 96% rename from web/ce/store/timeline/base-timeline.store.ts rename to apps/web/ce/store/timeline/base-timeline.store.ts index c021fa93ea8..517a5700fbe 100644 --- a/web/ce/store/timeline/base-timeline.store.ts +++ b/apps/web/ce/store/timeline/base-timeline.store.ts @@ -3,7 +3,13 @@ import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // components -import type { ChartDataType, IBlockUpdateDependencyData, IGanttBlock, TGanttViews } from "@plane/types"; +import type { + ChartDataType, + IBlockUpdateDependencyData, + IGanttBlock, + TGanttViews, + EGanttBlockType, +} from "@plane/types"; import { renderFormattedPayloadDate } from "@plane/utils"; import { currentViewDataWithView } from "@/components/gantt-chart/data"; import { @@ -177,7 +183,7 @@ export class BaseTimeLineStore implements IBaseTimelineStore { * @param getDataById * @returns */ - updateBlocks(getDataById: (id: string) => BlockData | undefined | null) { + updateBlocks(getDataById: (id: string) => BlockData | undefined | null, type?: EGanttBlockType, index?: number) { if (!this.blockIds || !Array.isArray(this.blockIds) || this.isDragging) return true; const updatedBlockMaps: { path: string[]; value: any }[] = []; @@ -195,7 +201,11 @@ export class BaseTimeLineStore implements IBaseTimelineStore { sort_order: blockData?.sort_order ?? undefined, start_date: blockData?.start_date ?? undefined, target_date: blockData?.target_date ?? undefined, - project_id: blockData?.project_id ?? undefined, + meta: { + type, + index, + project_id: blockData?.project_id, + }, }; if (this.currentViewData && (this.currentViewData?.data?.startDate || this.currentViewData?.data?.dayWidth)) { block.position = getItemPositionWidth(this.currentViewData, block); @@ -285,7 +295,7 @@ export class BaseTimeLineStore implements IBaseTimelineStore { if (!currBlock?.position || !this.currentViewData) return []; - const updatePayload: IBlockUpdateDependencyData = { id }; + const updatePayload: IBlockUpdateDependencyData = { id, meta: currBlock.meta }; // If shouldUpdateHalfBlock or the start date is available then update start date if (shouldUpdateHalfBlock || currBlock.start_date) { diff --git a/apps/web/ce/store/timeline/index.ts b/apps/web/ce/store/timeline/index.ts new file mode 100644 index 00000000000..5f423f4b720 --- /dev/null +++ b/apps/web/ce/store/timeline/index.ts @@ -0,0 +1,26 @@ +import { RootStore } from "@/plane-web/store/root.store"; +import { IIssuesTimeLineStore, IssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import { IModulesTimeLineStore, ModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import { BaseTimeLineStore, IBaseTimelineStore } from "./base-timeline.store"; + +export interface ITimelineStore { + issuesTimeLineStore: IIssuesTimeLineStore; + modulesTimeLineStore: IModulesTimeLineStore; + projectTimeLineStore: IBaseTimelineStore; + groupedTimeLineStore: IBaseTimelineStore; +} + +export class TimeLineStore implements ITimelineStore { + issuesTimeLineStore: IIssuesTimeLineStore; + modulesTimeLineStore: IModulesTimeLineStore; + projectTimeLineStore: IBaseTimelineStore; + groupedTimeLineStore: IBaseTimelineStore; + + constructor(rootStore: RootStore) { + this.issuesTimeLineStore = new IssuesTimeLineStore(rootStore); + this.modulesTimeLineStore = new ModulesTimeLineStore(rootStore); + // Dummy store + this.projectTimeLineStore = new BaseTimeLineStore(rootStore); + this.groupedTimeLineStore = new BaseTimeLineStore(rootStore); + } +} diff --git a/web/ce/store/user/permission.store.ts b/apps/web/ce/store/user/permission.store.ts similarity index 100% rename from web/ce/store/user/permission.store.ts rename to apps/web/ce/store/user/permission.store.ts diff --git a/web/ce/types/gantt-chart.ts b/apps/web/ce/types/gantt-chart.ts similarity index 100% rename from web/ce/types/gantt-chart.ts rename to apps/web/ce/types/gantt-chart.ts diff --git a/web/ce/types/index.ts b/apps/web/ce/types/index.ts similarity index 100% rename from web/ce/types/index.ts rename to apps/web/ce/types/index.ts diff --git a/web/ce/types/issue-types/index.ts b/apps/web/ce/types/issue-types/index.ts similarity index 100% rename from web/ce/types/issue-types/index.ts rename to apps/web/ce/types/issue-types/index.ts diff --git a/web/ce/types/issue-types/issue-property-values.d.ts b/apps/web/ce/types/issue-types/issue-property-values.d.ts similarity index 100% rename from web/ce/types/issue-types/issue-property-values.d.ts rename to apps/web/ce/types/issue-types/issue-property-values.d.ts diff --git a/web/ce/types/projects/index.ts b/apps/web/ce/types/projects/index.ts similarity index 100% rename from web/ce/types/projects/index.ts rename to apps/web/ce/types/projects/index.ts diff --git a/web/ce/types/projects/project-activity.ts b/apps/web/ce/types/projects/project-activity.ts similarity index 100% rename from web/ce/types/projects/project-activity.ts rename to apps/web/ce/types/projects/project-activity.ts diff --git a/apps/web/ce/types/projects/projects.ts b/apps/web/ce/types/projects/projects.ts new file mode 100644 index 00000000000..462192e262c --- /dev/null +++ b/apps/web/ce/types/projects/projects.ts @@ -0,0 +1,5 @@ +import { IPartialProject, IProject } from "@plane/types"; + +export type TPartialProject = IPartialProject; + +export type TProject = TPartialProject & IProject; diff --git a/web/core/components/account/auth-forms/auth-banner.tsx b/apps/web/core/components/account/auth-forms/auth-banner.tsx similarity index 100% rename from web/core/components/account/auth-forms/auth-banner.tsx rename to apps/web/core/components/account/auth-forms/auth-banner.tsx diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/apps/web/core/components/account/auth-forms/auth-header.tsx similarity index 100% rename from web/core/components/account/auth-forms/auth-header.tsx rename to apps/web/core/components/account/auth-forms/auth-header.tsx diff --git a/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx similarity index 100% rename from web/core/components/account/auth-forms/auth-root.tsx rename to apps/web/core/components/account/auth-forms/auth-root.tsx diff --git a/web/core/components/account/auth-forms/email.tsx b/apps/web/core/components/account/auth-forms/email.tsx similarity index 100% rename from web/core/components/account/auth-forms/email.tsx rename to apps/web/core/components/account/auth-forms/email.tsx diff --git a/web/core/components/account/auth-forms/forgot-password-popover.tsx b/apps/web/core/components/account/auth-forms/forgot-password-popover.tsx similarity index 100% rename from web/core/components/account/auth-forms/forgot-password-popover.tsx rename to apps/web/core/components/account/auth-forms/forgot-password-popover.tsx diff --git a/web/core/components/account/auth-forms/index.ts b/apps/web/core/components/account/auth-forms/index.ts similarity index 100% rename from web/core/components/account/auth-forms/index.ts rename to apps/web/core/components/account/auth-forms/index.ts diff --git a/web/core/components/account/auth-forms/password.tsx b/apps/web/core/components/account/auth-forms/password.tsx similarity index 92% rename from web/core/components/account/auth-forms/password.tsx rename to apps/web/core/components/account/auth-forms/password.tsx index 3c2927418dd..6e05d0c22b4 100644 --- a/web/core/components/account/auth-forms/password.tsx +++ b/apps/web/core/components/account/auth-forms/password.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; // icons import { Eye, EyeOff, Info, X, XCircle } from "lucide-react"; // plane imports -import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS } from "@plane/constants"; +import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button, Input, Spinner } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; @@ -16,7 +16,7 @@ import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/accou // helpers import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; // hooks -import { useEventTracker } from "@/hooks/store"; +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // services import { AuthService } from "@/services/auth.service"; @@ -46,8 +46,6 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props; // plane imports const { t } = useTranslation(); - // hooks - const { captureEvent } = useEventTracker(); // ref const formRef = useRef(null); // states @@ -77,7 +75,6 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const redirectToUniqueCodeSignIn = async () => { handleAuthStep(EAuthSteps.UNIQUE_CODE); - captureEvent(AUTH_TRACKER_EVENTS.sign_in_with_code); }; const passwordSupport = @@ -85,7 +82,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
{isSMTPConfigured ? ( captureEvent(AUTH_TRACKER_EVENTS.forgot_password)} + data-ph-element={AUTH_TRACKER_ELEMENTS.FORGOT_PASSWORD_FROM_SIGNIN} href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`} className="text-xs font-medium text-custom-primary-100" > @@ -154,17 +151,32 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { : true; if (isPasswordValid) { setIsSubmitting(true); - captureEvent( - mode === EAuthModes.SIGN_IN - ? AUTH_TRACKER_EVENTS.sign_in_with_password - : AUTH_TRACKER_EVENTS.sign_up_with_password - ); + captureSuccess({ + eventName: + mode === EAuthModes.SIGN_IN + ? AUTH_TRACKER_EVENTS.sign_in_with_password + : AUTH_TRACKER_EVENTS.sign_up_with_password, + payload: { + email: passwordFormData.email, + }, + }); if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met } else { setBannerMessage(true); } }} - onError={() => setIsSubmitting(false)} + onError={() => { + setIsSubmitting(false); + captureError({ + eventName: + mode === EAuthModes.SIGN_IN + ? AUTH_TRACKER_EVENTS.sign_in_with_password + : AUTH_TRACKER_EVENTS.sign_up_with_password, + payload: { + email: passwordFormData.email, + }, + }); + }} > @@ -292,6 +304,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { {isSMTPConfigured && ( + )} +
+
+ +
+ + handleFormChange("code", e.target.value)} + placeholder={t("auth.common.unique_code.placeholder")} + className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoFocus + /> +
+

+ + {t("auth.common.unique_code.paste_code")} +

+ +
+
+ +
+ +
+ + ); +}; diff --git a/web/core/components/account/deactivate-account-modal.tsx b/apps/web/core/components/account/deactivate-account-modal.tsx similarity index 91% rename from web/core/components/account/deactivate-account-modal.tsx rename to apps/web/core/components/account/deactivate-account-modal.tsx index 1132a8d74cb..684416184b2 100644 --- a/web/core/components/account/deactivate-account-modal.tsx +++ b/apps/web/core/components/account/deactivate-account-modal.tsx @@ -3,10 +3,12 @@ import React, { useState } from "react"; import { Trash2 } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; +import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // hooks +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useUser } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -35,6 +37,9 @@ export const DeactivateAccountModal: React.FC = (props) => { await deactivateAccount() .then(() => { + captureSuccess({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account, + }); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", @@ -44,13 +49,16 @@ export const DeactivateAccountModal: React.FC = (props) => { router.push("/"); handleClose(); }) - .catch((err: any) => + .catch((err: any) => { + captureError({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.deactivate_account, + }); setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error, - }) - ) + }); + }) .finally(() => setIsDeactivating(false)); }; diff --git a/web/core/components/account/index.ts b/apps/web/core/components/account/index.ts similarity index 100% rename from web/core/components/account/index.ts rename to apps/web/core/components/account/index.ts diff --git a/web/core/components/account/oauth/github-button.tsx b/apps/web/core/components/account/oauth/github-button.tsx similarity index 100% rename from web/core/components/account/oauth/github-button.tsx rename to apps/web/core/components/account/oauth/github-button.tsx diff --git a/web/core/components/account/oauth/gitlab-button.tsx b/apps/web/core/components/account/oauth/gitlab-button.tsx similarity index 100% rename from web/core/components/account/oauth/gitlab-button.tsx rename to apps/web/core/components/account/oauth/gitlab-button.tsx diff --git a/web/core/components/account/oauth/google-button.tsx b/apps/web/core/components/account/oauth/google-button.tsx similarity index 100% rename from web/core/components/account/oauth/google-button.tsx rename to apps/web/core/components/account/oauth/google-button.tsx diff --git a/web/core/components/account/oauth/index.ts b/apps/web/core/components/account/oauth/index.ts similarity index 100% rename from web/core/components/account/oauth/index.ts rename to apps/web/core/components/account/oauth/index.ts diff --git a/web/core/components/account/oauth/oauth-options.tsx b/apps/web/core/components/account/oauth/oauth-options.tsx similarity index 89% rename from web/core/components/account/oauth/oauth-options.tsx rename to apps/web/core/components/account/oauth/oauth-options.tsx index eb4ec48518b..c4b6ff647a0 100644 --- a/web/core/components/account/oauth/oauth-options.tsx +++ b/apps/web/core/components/account/oauth/oauth-options.tsx @@ -12,7 +12,8 @@ export const OAuthOptions: React.FC = observer(() => { // hooks const { config } = useInstance(); - const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; + const isOAuthEnabled = + (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; if (!isOAuthEnabled) return null; diff --git a/web/core/components/account/password-strength-meter.tsx b/apps/web/core/components/account/password-strength-meter.tsx similarity index 100% rename from web/core/components/account/password-strength-meter.tsx rename to apps/web/core/components/account/password-strength-meter.tsx diff --git a/web/core/components/account/terms-and-conditions.tsx b/apps/web/core/components/account/terms-and-conditions.tsx similarity index 100% rename from web/core/components/account/terms-and-conditions.tsx rename to apps/web/core/components/account/terms-and-conditions.tsx diff --git a/web/core/components/analytics/analytics-filter-actions.tsx b/apps/web/core/components/analytics/analytics-filter-actions.tsx similarity index 95% rename from web/core/components/analytics/analytics-filter-actions.tsx rename to apps/web/core/components/analytics/analytics-filter-actions.tsx index 13019063b8c..3b4014ea1b9 100644 --- a/web/core/components/analytics/analytics-filter-actions.tsx +++ b/apps/web/core/components/analytics/analytics-filter-actions.tsx @@ -1,5 +1,5 @@ // plane web components -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // hooks import { useProject } from "@/hooks/store"; import { useAnalytics } from "@/hooks/store/use-analytics"; diff --git a/web/core/components/analytics/analytics-section-wrapper.tsx b/apps/web/core/components/analytics/analytics-section-wrapper.tsx similarity index 100% rename from web/core/components/analytics/analytics-section-wrapper.tsx rename to apps/web/core/components/analytics/analytics-section-wrapper.tsx diff --git a/web/core/components/analytics/analytics-wrapper.tsx b/apps/web/core/components/analytics/analytics-wrapper.tsx similarity index 100% rename from web/core/components/analytics/analytics-wrapper.tsx rename to apps/web/core/components/analytics/analytics-wrapper.tsx diff --git a/web/core/components/analytics/empty-state.tsx b/apps/web/core/components/analytics/empty-state.tsx similarity index 100% rename from web/core/components/analytics/empty-state.tsx rename to apps/web/core/components/analytics/empty-state.tsx diff --git a/web/core/components/analytics/export.ts b/apps/web/core/components/analytics/export.ts similarity index 100% rename from web/core/components/analytics/export.ts rename to apps/web/core/components/analytics/export.ts diff --git a/web/core/components/analytics/index.ts b/apps/web/core/components/analytics/index.ts similarity index 100% rename from web/core/components/analytics/index.ts rename to apps/web/core/components/analytics/index.ts diff --git a/web/core/components/analytics/insight-card.tsx b/apps/web/core/components/analytics/insight-card.tsx similarity index 100% rename from web/core/components/analytics/insight-card.tsx rename to apps/web/core/components/analytics/insight-card.tsx diff --git a/web/core/components/analytics/insight-table/data-table.tsx b/apps/web/core/components/analytics/insight-table/data-table.tsx similarity index 100% rename from web/core/components/analytics/insight-table/data-table.tsx rename to apps/web/core/components/analytics/insight-table/data-table.tsx diff --git a/web/core/components/analytics/work-items/index.ts b/apps/web/core/components/analytics/insight-table/index.ts similarity index 100% rename from web/core/components/analytics/work-items/index.ts rename to apps/web/core/components/analytics/insight-table/index.ts diff --git a/web/core/components/analytics/insight-table/loader.tsx b/apps/web/core/components/analytics/insight-table/loader.tsx similarity index 100% rename from web/core/components/analytics/insight-table/loader.tsx rename to apps/web/core/components/analytics/insight-table/loader.tsx diff --git a/web/core/components/analytics/insight-table/root.tsx b/apps/web/core/components/analytics/insight-table/root.tsx similarity index 100% rename from web/core/components/analytics/insight-table/root.tsx rename to apps/web/core/components/analytics/insight-table/root.tsx diff --git a/web/core/components/analytics/loaders.tsx b/apps/web/core/components/analytics/loaders.tsx similarity index 100% rename from web/core/components/analytics/loaders.tsx rename to apps/web/core/components/analytics/loaders.tsx diff --git a/web/core/components/analytics/overview/active-project-item.tsx b/apps/web/core/components/analytics/overview/active-project-item.tsx similarity index 100% rename from web/core/components/analytics/overview/active-project-item.tsx rename to apps/web/core/components/analytics/overview/active-project-item.tsx diff --git a/web/core/components/analytics/overview/active-projects.tsx b/apps/web/core/components/analytics/overview/active-projects.tsx similarity index 100% rename from web/core/components/analytics/overview/active-projects.tsx rename to apps/web/core/components/analytics/overview/active-projects.tsx diff --git a/web/core/components/core/description-versions/index.ts b/apps/web/core/components/analytics/overview/index.ts similarity index 100% rename from web/core/components/core/description-versions/index.ts rename to apps/web/core/components/analytics/overview/index.ts diff --git a/web/core/components/analytics/overview/project-insights.tsx b/apps/web/core/components/analytics/overview/project-insights.tsx similarity index 100% rename from web/core/components/analytics/overview/project-insights.tsx rename to apps/web/core/components/analytics/overview/project-insights.tsx diff --git a/web/core/components/analytics/overview/root.tsx b/apps/web/core/components/analytics/overview/root.tsx similarity index 100% rename from web/core/components/analytics/overview/root.tsx rename to apps/web/core/components/analytics/overview/root.tsx diff --git a/web/core/components/analytics/select/analytics-params.tsx b/apps/web/core/components/analytics/select/analytics-params.tsx similarity index 94% rename from web/core/components/analytics/select/analytics-params.tsx rename to apps/web/core/components/analytics/select/analytics-params.tsx index f4ef0d9eb52..f3af96a5953 100644 --- a/web/core/components/analytics/select/analytics-params.tsx +++ b/apps/web/core/components/analytics/select/analytics-params.tsx @@ -3,9 +3,8 @@ import { observer } from "mobx-react"; import { Control, Controller, UseFormSetValue } from "react-hook-form"; import { Calendar, SlidersHorizontal } from "lucide-react"; // plane package imports -import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, ChartYAxisMetric } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; -import { IAnalyticsParams } from "@plane/types"; +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; +import { ChartYAxisMetric, IAnalyticsParams } from "@plane/types"; import { cn } from "@plane/utils"; // plane web components import { SelectXAxis } from "./select-x-axis"; diff --git a/web/core/components/analytics/select/duration.tsx b/apps/web/core/components/analytics/select/duration.tsx similarity index 100% rename from web/core/components/analytics/select/duration.tsx rename to apps/web/core/components/analytics/select/duration.tsx diff --git a/apps/web/core/components/analytics/select/project.tsx b/apps/web/core/components/analytics/select/project.tsx new file mode 100644 index 00000000000..fa03d56dfe1 --- /dev/null +++ b/apps/web/core/components/analytics/select/project.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Briefcase } from "lucide-react"; +// plane package imports +import { CustomSearchSelect, Logo } from "@plane/ui"; +// hooks +import { useProject } from "@/hooks/store"; + +type Props = { + value: string[] | undefined; + onChange: (val: string[] | null) => void; + projectIds: string[] | undefined; +}; + +export const ProjectSelect: React.FC = observer((props) => { + const { value, onChange, projectIds } = props; + const { getProjectById } = useProject(); + + const options = projectIds?.map((projectId) => { + const projectDetails = getProjectById(projectId); + + return { + value: projectDetails?.id, + query: `${projectDetails?.name} ${projectDetails?.identifier}`, + content: ( +
+ {projectDetails?.logo_props ? ( + + ) : ( + + )} + {projectDetails?.name} +
+ ), + }; + }); + + return ( + onChange(val)} + options={options} + label={ +
+ + {value && value.length > 3 + ? `3+ projects` + : value && value.length > 0 + ? projectIds + ?.filter((p) => value.includes(p)) + .map((p) => getProjectById(p)?.name) + .join(", ") + : "All projects"} +
+ } + multiple + /> + ); +}); diff --git a/web/core/components/analytics/select/select-x-axis.tsx b/apps/web/core/components/analytics/select/select-x-axis.tsx similarity index 94% rename from web/core/components/analytics/select/select-x-axis.tsx rename to apps/web/core/components/analytics/select/select-x-axis.tsx index a655c9a13de..1be296ff39b 100644 --- a/web/core/components/analytics/select/select-x-axis.tsx +++ b/apps/web/core/components/analytics/select/select-x-axis.tsx @@ -1,6 +1,6 @@ "use client"; // plane package imports -import { ChartXAxisProperty } from "@plane/constants"; +import { ChartXAxisProperty } from "@plane/types"; import { CustomSelect } from "@plane/ui"; type Props = { diff --git a/web/core/components/analytics/select/select-y-axis.tsx b/apps/web/core/components/analytics/select/select-y-axis.tsx similarity index 94% rename from web/core/components/analytics/select/select-y-axis.tsx rename to apps/web/core/components/analytics/select/select-y-axis.tsx index 931b1976d72..4982f8fb0eb 100644 --- a/web/core/components/analytics/select/select-y-axis.tsx +++ b/apps/web/core/components/analytics/select/select-y-axis.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Briefcase } from "lucide-react"; -import { ChartYAxisMetric, EEstimateSystem } from "@plane/constants"; +import { EEstimateSystem } from "@plane/constants"; +import { ChartYAxisMetric } from "@plane/types"; // plane package imports import { CustomSelect } from "@plane/ui"; // hooks diff --git a/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx similarity index 98% rename from web/core/components/analytics/total-insights.tsx rename to apps/web/core/components/analytics/total-insights.tsx index e85c9c68a21..258ac11e1eb 100644 --- a/web/core/components/analytics/total-insights.tsx +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -1,5 +1,5 @@ // plane package imports -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; import { IInsightField, ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; diff --git a/web/core/components/analytics/trend-piece.tsx b/apps/web/core/components/analytics/trend-piece.tsx similarity index 100% rename from web/core/components/analytics/trend-piece.tsx rename to apps/web/core/components/analytics/trend-piece.tsx diff --git a/web/core/components/analytics/work-items/created-vs-resolved.tsx b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx similarity index 100% rename from web/core/components/analytics/work-items/created-vs-resolved.tsx rename to apps/web/core/components/analytics/work-items/created-vs-resolved.tsx diff --git a/web/core/components/analytics/work-items/customized-insights.tsx b/apps/web/core/components/analytics/work-items/customized-insights.tsx similarity index 92% rename from web/core/components/analytics/work-items/customized-insights.tsx rename to apps/web/core/components/analytics/work-items/customized-insights.tsx index 6574658227f..bafd0d8ef47 100644 --- a/web/core/components/analytics/work-items/customized-insights.tsx +++ b/apps/web/core/components/analytics/work-items/customized-insights.tsx @@ -2,9 +2,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useForm } from "react-hook-form"; // plane package imports -import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IAnalyticsParams } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric, IAnalyticsParams } from "@plane/types"; import { cn } from "@plane/utils"; // plane web components import AnalyticsSectionWrapper from "../analytics-section-wrapper"; diff --git a/web/core/components/editor/embeds/mentions/index.ts b/apps/web/core/components/analytics/work-items/index.ts similarity index 100% rename from web/core/components/editor/embeds/mentions/index.ts rename to apps/web/core/components/analytics/work-items/index.ts diff --git a/web/core/components/analytics/work-items/modal/content.tsx b/apps/web/core/components/analytics/work-items/modal/content.tsx similarity index 100% rename from web/core/components/analytics/work-items/modal/content.tsx rename to apps/web/core/components/analytics/work-items/modal/content.tsx diff --git a/web/core/components/analytics/work-items/modal/header.tsx b/apps/web/core/components/analytics/work-items/modal/header.tsx similarity index 100% rename from web/core/components/analytics/work-items/modal/header.tsx rename to apps/web/core/components/analytics/work-items/modal/header.tsx diff --git a/apps/web/core/components/analytics/work-items/modal/index.tsx b/apps/web/core/components/analytics/work-items/modal/index.tsx new file mode 100644 index 00000000000..06d09aa1e9b --- /dev/null +++ b/apps/web/core/components/analytics/work-items/modal/index.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// plane package imports +import { createPortal } from "react-dom"; +import { ICycle, IModule, IProject } from "@plane/types"; +import { cn } from "@plane/utils"; +import { useAnalytics } from "@/hooks/store"; +// plane web components +import { WorkItemsModalMainContent } from "./content"; +import { WorkItemsModalHeader } from "./header"; + +type Props = { + isOpen: boolean; + onClose: () => void; + projectDetails?: IProject | undefined; + cycleDetails?: ICycle | undefined; + moduleDetails?: IModule | undefined; + isEpic?: boolean; +}; + +export const WorkItemsModal: React.FC = observer((props) => { + const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props; + const { updateIsEpic } = useAnalytics(); + const [fullScreen, setFullScreen] = useState(false); + + const handleClose = () => { + setFullScreen(false); + onClose(); + }; + + useEffect(() => { + updateIsEpic(isEpic ?? false); + }, [isEpic, updateIsEpic]); + + const portalContainer = document.getElementById("full-screen-portal") as HTMLElement; + + if (!isOpen) return null; + + const content = ( +
+
+
+ + +
+
+
+ ); + + return fullScreen && portalContainer ? createPortal(content, portalContainer) : content; +}); diff --git a/web/core/components/analytics/work-items/priority-chart.tsx b/apps/web/core/components/analytics/work-items/priority-chart.tsx similarity index 96% rename from web/core/components/analytics/work-items/priority-chart.tsx rename to apps/web/core/components/analytics/work-items/priority-chart.tsx index 5c00699783c..ac7704628f6 100644 --- a/web/core/components/analytics/work-items/priority-chart.tsx +++ b/apps/web/core/components/analytics/work-items/priority-chart.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react"; -import { ColumnDef, RowData, Table } from "@tanstack/react-table"; -import { mkConfig } from "export-to-csv"; +import { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; @@ -12,14 +11,11 @@ import { ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, ChartXAxisDateGrouping, - ChartXAxisProperty, - ChartYAxisMetric, EChartModels, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { BarChart } from "@plane/propel/charts/bar-chart"; -import { ExportConfig } from "@plane/types"; -import { TBarItem, TChart, TChartDatum } from "@plane/types/src/charts"; +import { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; // plane web components import { Button } from "@plane/ui"; import { generateExtendedColors, parseChartData } from "@/components/chart/utils"; @@ -36,7 +32,11 @@ import { generateBarColor } from "./utils"; declare module "@tanstack/react-table" { interface ColumnMeta { - export: ExportConfig; + export: { + key: string; + value: (row: Row) => string | number; + label?: string; + }; } } diff --git a/web/core/components/analytics/work-items/root.tsx b/apps/web/core/components/analytics/work-items/root.tsx similarity index 100% rename from web/core/components/analytics/work-items/root.tsx rename to apps/web/core/components/analytics/work-items/root.tsx diff --git a/apps/web/core/components/analytics/work-items/utils.ts b/apps/web/core/components/analytics/work-items/utils.ts new file mode 100644 index 00000000000..37c74ec8124 --- /dev/null +++ b/apps/web/core/components/analytics/work-items/utils.ts @@ -0,0 +1,46 @@ +// plane package imports +import { ChartXAxisProperty, ChartYAxisMetric, IState } from "@plane/types"; + +interface ParamsProps { + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; +} + +export const generateBarColor = ( + value: string | null | undefined, + params: ParamsProps, + baseColors: string[], + workspaceStates?: IState[] +): string => { + if (!value) return baseColors[0]; + let color = baseColors[0]; + // Priority + if (params.x_axis === ChartXAxisProperty.PRIORITY) { + color = + value === "urgent" + ? "#ef4444" + : value === "high" + ? "#f97316" + : value === "medium" + ? "#eab308" + : value === "low" + ? "#22c55e" + : "#ced4da"; + } + + // State + if (params.x_axis === ChartXAxisProperty.STATES) { + if (workspaceStates && workspaceStates.length > 0) { + const state = workspaceStates.find((s) => s.id === value); + if (state) { + color = state.color; + } else { + const index = Math.abs(value.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0)) % baseColors.length; + color = baseColors[index]; + } + } + } + + return color; +}; diff --git a/web/core/components/analytics/work-items/workitems-insight-table.tsx b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx similarity index 97% rename from web/core/components/analytics/work-items/workitems-insight-table.tsx rename to apps/web/core/components/analytics/work-items/workitems-insight-table.tsx index 98935271f53..d0b6262fdd3 100644 --- a/web/core/components/analytics/work-items/workitems-insight-table.tsx +++ b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx @@ -6,7 +6,7 @@ import useSWR from "swr"; import { Briefcase, UserRound } from "lucide-react"; // plane package imports import { useTranslation } from "@plane/i18n"; -import { WorkItemInsightColumns, AnalyticsTableDataMap, ExportConfig } from "@plane/types"; +import { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; // plane web components import { Avatar } from "@plane/ui"; import { getFileURL } from "@plane/utils"; @@ -23,7 +23,11 @@ const analyticsService = new AnalyticsService(); declare module "@tanstack/react-table" { interface ColumnMeta { - export: ExportConfig; + export: { + key: string; + value: (row: Row) => string | number; + label?: string; + }; } } diff --git a/web/core/components/api-token/delete-token-modal.tsx b/apps/web/core/components/api-token/delete-token-modal.tsx similarity index 79% rename from web/core/components/api-token/delete-token-modal.tsx rename to apps/web/core/components/api-token/delete-token-modal.tsx index eed0ecdb90c..f139a278317 100644 --- a/web/core/components/api-token/delete-token-modal.tsx +++ b/apps/web/core/components/api-token/delete-token-modal.tsx @@ -3,6 +3,7 @@ import { useState, FC } from "react"; import { mutate } from "swr"; // types +import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { APITokenService } from "@plane/services"; import { IApiToken } from "@plane/types"; @@ -10,6 +11,7 @@ import { IApiToken } from "@plane/types"; import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; // fetch-keys import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; type Props = { isOpen: boolean; @@ -48,6 +50,12 @@ export const DeleteApiTokenModal: FC = (props) => { (prevData) => (prevData ?? []).filter((token) => token.id !== tokenId), false ); + captureSuccess({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, + payload: { + token: tokenId, + }, + }); handleClose(); }) @@ -58,6 +66,15 @@ export const DeleteApiTokenModal: FC = (props) => { message: err?.message ?? t("workspace_settings.settings.api_tokens.delete.error.message"), }) ) + .catch((err) => { + captureError({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_deleted, + payload: { + token: tokenId, + }, + error: err as Error, + }); + }) .finally(() => setDeleteLoading(false)); }; diff --git a/web/core/components/api-token/empty-state.tsx b/apps/web/core/components/api-token/empty-state.tsx similarity index 100% rename from web/core/components/api-token/empty-state.tsx rename to apps/web/core/components/api-token/empty-state.tsx diff --git a/web/core/components/api-token/index.ts b/apps/web/core/components/api-token/index.ts similarity index 100% rename from web/core/components/api-token/index.ts rename to apps/web/core/components/api-token/index.ts diff --git a/web/core/components/api-token/modal/create-token-modal.tsx b/apps/web/core/components/api-token/modal/create-token-modal.tsx similarity index 86% rename from web/core/components/api-token/modal/create-token-modal.tsx rename to apps/web/core/components/api-token/modal/create-token-modal.tsx index 94d72c56d17..d7d6e14d376 100644 --- a/web/core/components/api-token/modal/create-token-modal.tsx +++ b/apps/web/core/components/api-token/modal/create-token-modal.tsx @@ -3,6 +3,7 @@ import React, { useState } from "react"; import { mutate } from "swr"; // types +import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { APITokenService } from "@plane/services"; import { IApiToken } from "@plane/types"; // ui @@ -12,6 +13,7 @@ import { renderFormattedDate, csvDownload } from "@plane/utils"; import { CreateApiTokenForm, GeneratedTokenDetails } from "@/components/api-token"; // fetch-keys import { API_TOKENS_LIST } from "@/constants/fetch-keys"; +import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // helpers // services @@ -66,6 +68,12 @@ export const CreateApiTokenModal: React.FC = (props) => { }, false ); + captureSuccess({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, + payload: { + token: res.id, + }, + }); }) .catch((err) => { setToast({ @@ -74,6 +82,10 @@ export const CreateApiTokenModal: React.FC = (props) => { message: err.message || err.detail, }); + captureError({ + eventName: PROFILE_SETTINGS_TRACKER_EVENTS.pat_created, + }); + throw err; }); }; diff --git a/apps/web/core/components/api-token/modal/form.tsx b/apps/web/core/components/api-token/modal/form.tsx new file mode 100644 index 00000000000..279722cfdd2 --- /dev/null +++ b/apps/web/core/components/api-token/modal/form.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useState } from "react"; +import { add } from "date-fns"; +import { Controller, useForm } from "react-hook-form"; +import { Calendar } from "lucide-react"; +// types +import { useTranslation } from "@plane/i18n"; +import { IApiToken } from "@plane/types"; +// ui +import { Button, CustomSelect, Input, TextArea, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; +import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils"; +// components +import { DateDropdown } from "@/components/dropdowns"; +// helpers +type Props = { + handleClose: () => void; + neverExpires: boolean; + toggleNeverExpires: () => void; + onSubmit: (data: Partial) => Promise; +}; + +const EXPIRY_DATE_OPTIONS = [ + { + key: "1_week", + label: "1 week", + value: { weeks: 1 }, + }, + { + key: "1_month", + label: "1 month", + value: { months: 1 }, + }, + { + key: "3_months", + label: "3 months", + value: { months: 3 }, + }, + { + key: "1_year", + label: "1 year", + value: { years: 1 }, + }, +]; + +const defaultValues: Partial = { + label: "", + description: "", + expired_at: null, +}; + +const getExpiryDate = (val: string): Date | null | undefined => { + const today = new Date(); + const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value; + if (dateToAdd) return add(today, dateToAdd); + return null; +}; + +const getFormattedDate = (date: Date): Date => { + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes(); + const seconds = now.getSeconds(); + return add(date, { hours, minutes, seconds }); +}; + +export const CreateApiTokenForm: React.FC = (props) => { + const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props; + // states + const [customDate, setCustomDate] = useState(null); + // form + const { + control, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + } = useForm({ defaultValues }); + // hooks + const { t } = useTranslation(); + + const handleFormSubmit = async (data: IApiToken) => { + // if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error + if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate))) + return setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Please select an expiration date.", + }); + + const payload: Partial = { + label: data.label, + description: data.description, + }; + + // if never expires is toggled on, set expired_at to null + if (neverExpires) payload.expired_at = null; + // if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date + else if (data.expired_at === "custom") { + payload.expired_at = customDate && getFormattedDate(customDate).toISOString(); + } + // if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date + else { + const expiryDate = getExpiryDate(data.expired_at ?? ""); + if (expiryDate) payload.expired_at = expiryDate.toISOString(); + } + + await onSubmit(payload).then(() => { + reset(defaultValues); + setCustomDate(null); + }); + }; + + const today = new Date(); + const tomorrow = add(today, { days: 1 }); + const expiredAt = watch("expired_at"); + const expiryDate = getExpiryDate(expiredAt ?? ""); + const customDateFormatted = customDate && getFormattedDate(customDate); + + return ( +
+
+

+ {t("workspace_settings.settings.api_tokens.create_token")} +

+
+
+ val.trim() !== "" || t("title_is_required"), + }} + render={({ field: { value, onChange } }) => ( + + )} + /> + {errors.label && {errors.label.message}} +
+ ( +