diff --git a/.github/actions/buildpush-action/action.yml b/.github/actions/buildpush-action/action.yml new file mode 100644 index 0000000000..acf06982d3 --- /dev/null +++ b/.github/actions/buildpush-action/action.yml @@ -0,0 +1,126 @@ +name: "Build and Push Docker Image" +description: "Reusable action for building and pushing Docker images" +inputs: + docker-username: + description: "The Dockerhub username" + required: true + docker-token: + description: "The Dockerhub Token" + required: true + + # Docker Image Options + docker-image-owner: + description: "The owner of the Docker image" + required: true + docker-image-name: + description: "The name of the Docker image" + required: true + build-context: + description: "The build context" + required: true + default: "." + dockerfile-path: + description: "The path to the Dockerfile" + required: true + build-args: + description: "The build arguments" + required: false + default: "" + + # Buildx Options + buildx-driver: + description: "Buildx driver" + required: true + default: "docker-container" + buildx-version: + description: "Buildx version" + required: true + default: "latest" + buildx-platforms: + description: "Buildx platforms" + required: true + default: "linux/amd64" + buildx-endpoint: + description: "Buildx endpoint" + required: true + default: "default" + + # Release Build Options + build-release: + description: "Flag to publish release" + required: false + default: "false" + build-prerelease: + description: "Flag to publish prerelease" + required: false + default: "false" + release-version: + description: "The release version" + required: false + default: "latest" + +runs: + using: "composite" + steps: + - name: Set Docker Tag + shell: bash + env: + IMG_OWNER: ${{ inputs.docker-image-owner }} + IMG_NAME: ${{ inputs.docker-image-name }} + BUILD_RELEASE: ${{ inputs.build-release }} + IS_PRERELEASE: ${{ inputs.build-prerelease }} + REL_VERSION: ${{ inputs.release-version }} + run: | + FLAT_BRANCH_VERSION=$(echo "${{ github.ref_name }}" | sed 's/[^a-zA-Z0-9.-]//g') + + if [ "${{ env.BUILD_RELEASE }}" == "true" ]; then + semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" + if [[ ! ${{ env.REL_VERSION }} =~ $semver_regex ]]; then + echo "Invalid Release Version Format : ${{ env.REL_VERSION }}" + echo "Please provide a valid SemVer version" + echo "e.g. v1.2.3 or v1.2.3-alpha-1" + echo "Exiting the build process" + exit 1 # Exit with status 1 to fail the step + fi + + TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${{ env.REL_VERSION }} + + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:stable + fi + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:latest + else + TAG=${{ env.IMG_OWNER }}/${{ env.IMG_NAME }}:${FLAT_BRANCH_VERSION} + fi + + echo "DOCKER_TAGS=${TAG}" >> $GITHUB_ENV + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ inputs.docker-username }} + password: ${{ inputs.docker-token}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ inputs.buildx-driver }} + version: ${{ inputs.buildx-version }} + endpoint: ${{ inputs.buildx-endpoint }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5.1.0 + with: + context: ${{ inputs.build-context }} + file: ${{ inputs.dockerfile-path }} + platforms: ${{ inputs.buildx-platforms }} + tags: ${{ env.DOCKER_TAGS }} + push: true + build-args: ${{ inputs.build-args }} + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ inputs.docker-username }} + DOCKER_PASSWORD: ${{ inputs.docker-token }} \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index d3e501a442..b3c66bd87e 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -1,29 +1,45 @@ -name: Branch Build +name: Branch Build CE on: workflow_dispatch: inputs: + build_type: + description: "Type of build to run" + required: true + type: choice + default: "Build" + options: + - "Build" + - "Release" + releaseVersion: + description: "Release Version" + type: string + default: v0.0.0 + isPrerelease: + description: "Is Pre-release" + type: boolean + default: false + required: true arm64: description: "Build for ARM64 architecture" required: false default: false type: boolean - push: - branches: - - master - - preview - release: - types: [released, prereleased] + # push: + # branches: + # - master env: - TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ github.ref_name }} ARM64_BUILD: ${{ github.event.inputs.arm64 }} - IS_PRERELEASE: ${{ github.event.release.prerelease }} + BUILD_TYPE: ${{ github.event.inputs.build_type }} + RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} + IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }} jobs: branch_build_setup: name: Build Setup - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 outputs: gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} @@ -36,13 +52,24 @@ jobs: build_space: ${{ steps.changed_files.outputs.space_any_changed }} build_web: ${{ steps.changed_files.outputs.web_any_changed }} build_live: ${{ steps.changed_files.outputs.live_any_changed }} - flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} + + dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }} + dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }} + dh_img_admin: ${{ steps.set_env_variables.outputs.DH_IMG_ADMIN }} + 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 }} + + 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 }} steps: - id: set_env_variables name: Set Environment Variables run: | - if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then + if [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ env.BUILD_TYPE }}" == "Release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT @@ -53,9 +80,43 @@ jobs: echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT fi - echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT - flat_branch_name=$(echo ${{ env.TARGET_BRANCH }} | sed 's/[^a-zA-Z0-9\._]/-/g') - echo "FLAT_BRANCH_NAME=${flat_branch_name}" >> $GITHUB_OUTPUT + BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" |sed 's/[^a-zA-Z0-9.-]//g') + echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT + + echo "DH_IMG_WEB=plane-frontend" >> $GITHUB_OUTPUT + echo "DH_IMG_SPACE=plane-space" >> $GITHUB_OUTPUT + echo "DH_IMG_ADMIN=plane-admin" >> $GITHUB_OUTPUT + 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 "BUILD_TYPE=${{env.BUILD_TYPE}}" >> $GITHUB_OUTPUT + BUILD_RELEASE=false + BUILD_PRERELEASE=false + RELVERSION="latest" + + 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 + + semver_regex="^v([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" + if [[ ! $FLAT_RELEASE_VERSION =~ $semver_regex ]]; then + echo "Invalid Release Version Format : $FLAT_RELEASE_VERSION" + echo "Please provide a valid SemVer version" + echo "e.g. v1.2.3 or v1.2.3-alpha-1" + echo "Exiting the build process" + exit 1 # Exit with status 1 to fail the step + fi + BUILD_RELEASE=true + RELVERSION=$FLAT_RELEASE_VERSION + + if [ "${{ env.IS_PRERELEASE }}" == "true" ]; then + BUILD_PRERELEASE=true + fi + fi + echo "BUILD_RELEASE=${BUILD_RELEASE}" >> $GITHUB_OUTPUT + echo "BUILD_PRERELEASE=${BUILD_PRERELEASE}" >> $GITHUB_OUTPUT + echo "RELEASE_VERSION=${RELVERSION}" >> $GITHUB_OUTPUT - id: checkout_files name: Checkout Files @@ -73,24 +134,24 @@ jobs: admin: - admin/** - packages/** - - 'package.json' - - 'yarn.lock' - - 'tsconfig.json' - - 'turbo.json' + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" space: - space/** - packages/** - - 'package.json' - - 'yarn.lock' - - 'tsconfig.json' - - 'turbo.json' + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" web: - web/** - packages/** - - 'package.json' - - 'yarn.lock' - - 'tsconfig.json' - - 'turbo.json' + - "package.json" + - "yarn.lock" + - "tsconfig.json" + - "turbo.json" live: - live/** - packages/** @@ -99,338 +160,224 @@ jobs: - 'tsconfig.json' - 'turbo.json' - branch_build_push_web: - if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} - name: Build-Push Web Docker Image - runs-on: ubuntu-20.04 - needs: [branch_build_setup] - env: - FRONTEND_TAG: makeplane/plane-frontend:${{ 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 Frontend Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-frontend:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-frontend:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-frontend:latest - else - TAG=${{ env.FRONTEND_TAG }} - fi - echo "FRONTEND_TAG=${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 Frontend to Docker Container Registry - uses: docker/build-push-action@v5.1.0 - with: - context: . - file: ./web/Dockerfile.web - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.FRONTEND_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - branch_build_push_admin: - if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Admin Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - ADMIN_TAG: makeplane/plane-admin:${{ 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 Admin Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-admin:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-admin:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-admin:latest - else - TAG=${{ env.ADMIN_TAG }} - fi - echo "ADMIN_TAG=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: 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 + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - - - name: Build and Push Frontend to Docker Container Registry - uses: docker/build-push-action@v5.1.0 + - name: Admin Build and Push + uses: ./.github/actions/buildpush-action with: - context: . - file: ./admin/Dockerfile.admin - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.ADMIN_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + 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 }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_admin }} + build-context: . + dockerfile-path: ./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 }} + buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + + branch_build_push_web: + if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Web Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Web Build and Push + uses: ./.github/actions/buildpush-action + 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 }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_web }} + build-context: . + dockerfile-path: ./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 }} + buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} branch_build_push_space: - if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Space Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - SPACE_TAG: makeplane/plane-space:${{ 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 Space Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-space:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-space:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-space:latest - else - TAG=${{ env.SPACE_TAG }} - fi - echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: 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 + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - - - name: Build and Push Space to Docker Hub - uses: docker/build-push-action@v5.1.0 + - name: Space Build and Push + uses: ./.github/actions/buildpush-action with: - context: . - file: ./space/Dockerfile.space - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.SPACE_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - - branch_build_push_apiserver: - if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} - name: Build-Push API Server Docker Image - runs-on: ubuntu-20.04 - needs: [branch_build_setup] - env: - BACKEND_TAG: makeplane/plane-backend:${{ 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 Backend Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-backend:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-backend:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-backend:latest - else - TAG=${{ env.BACKEND_TAG }} - fi - echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: ${{ env.BUILDX_DRIVER }} - version: ${{ env.BUILDX_VERSION }} - endpoint: ${{ env.BUILDX_ENDPOINT }} - - - name: Check out the repo - uses: actions/checkout@v4 - - - name: Build and Push Backend to Docker Hub - uses: docker/build-push-action@v5.1.0 - with: - context: ./apiserver - file: ./apiserver/Dockerfile.api - platforms: ${{ env.BUILDX_PLATFORMS }} - push: true - tags: ${{ env.BACKEND_TAG }} - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + 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 }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_space }} + build-context: . + dockerfile-path: ./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 }} + buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} branch_build_push_live: - if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Live Collaboration Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - LIVE_TAG: makeplane/plane-live:${{ 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 Live Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-live:${{ github.event.release.tag_name }} - if [ "${{ github.event.release.prerelease }}" != "true" ]; then - TAG=${TAG},makeplane/plane-live:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-live:latest - else - TAG=${{ env.LIVE_TAG }} - fi - echo "LIVE_TAG=${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 + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - - - name: Build and Push Live Server to Docker Hub - uses: docker/build-push-action@v5.1.0 + - name: Live Build and Push + uses: ./.github/actions/buildpush-action with: - context: . - file: ./live/Dockerfile.live - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.LIVE_TAG }} - push: true - env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + 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 }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-token: ${{ secrets.DOCKERHUB_TOKEN }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} + build-context: . + dockerfile-path: ./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: + if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push API Server Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Backend Build and Push + uses: ./.github/actions/buildpush-action + 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 }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-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 + 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_proxy: - if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push Proxy Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] - env: - PROXY_TAG: makeplane/plane-proxy:${{ 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 Proxy Docker Tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - TAG=makeplane/plane-proxy:${{ github.event.release.tag_name }} - if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then - TAG=${TAG},makeplane/plane-proxy:stable - fi - elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-proxy:latest - else - TAG=${{ env.PROXY_TAG }} - fi - echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV - - - name: Login to Docker Hub - uses: docker/login-action@v3 + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Proxy Build and Push + uses: ./.github/actions/buildpush-action with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + 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 }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + docker-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 + 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 }} - - 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 + attach_assets_to_build: + if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }} + name: Attach Assets to Build + runs-on: ubuntu-20.04 + needs: [ branch_build_setup ] + steps: + - name: Checkout uses: actions/checkout@v4 - - name: Build and Push Plane-Proxy to Docker Hub - uses: docker/build-push-action@v5.1.0 + - name: Update Assets + run: | + cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh + + - name: Attach Assets + id: attach_assets + uses: actions/upload-artifact@v4 with: - context: ./nginx - file: ./nginx/Dockerfile - platforms: ${{ env.BUILDX_PLATFORMS }} - tags: ${{ env.PROXY_TAG }} - push: true + name: selfhost-assets + retention-days: 2 + path: | + ${{ github.workspace }}/deploy/selfhost/setup.sh + ${{ github.workspace }}/deploy/selfhost/restore.sh + ${{ github.workspace }}/deploy/selfhost/docker-compose.yml + ${{ github.workspace }}/deploy/selfhost/variables.env + + publish_release: + if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }} + name: Build Release + runs-on: ubuntu-20.04 + needs: + [ + branch_build_setup, + branch_build_push_admin, + branch_build_push_web, + branch_build_push_space, + branch_build_push_live, + branch_build_push_apiserver, + branch_build_push_proxy + ] + env: + REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Update Assets + run: | + cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2.0.8 env: - DOCKER_BUILDKIT: 1 - DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + with: + tag_name: ${{ env.REL_VERSION }} + name: ${{ env.REL_VERSION }} + draft: false + prerelease: ${{ env.IS_PRERELEASE }} + generate_release_notes: true + files: | + ${{ github.workspace }}/deploy/selfhost/setup.sh + ${{ github.workspace }}/deploy/selfhost/restore.sh + ${{ github.workspace }}/deploy/selfhost/docker-compose.yml + ${{ github.workspace }}/deploy/selfhost/variables.env \ No newline at end of file diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/sync-repo-pr.yml similarity index 89% rename from .github/workflows/create-sync-pr.yml rename to .github/workflows/sync-repo-pr.yml index 46f6365fd9..df12960660 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/sync-repo-pr.yml @@ -8,14 +8,13 @@ on: env: CURRENT_BRANCH: ${{ github.ref_name }} - TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop + TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows - REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }} ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} jobs: - Create_PR: + create_pull_request: runs-on: ubuntu-latest permissions: pull-requests: write @@ -48,6 +47,6 @@ jobs: echo "Pull Request already exists: $PR_EXISTS" else echo "Creating new pull request" - PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "") + PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "") echo "Pull Request created: $PR_URL" fi diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/sync-repo.yml similarity index 94% rename from .github/workflows/repo-sync.yml rename to .github/workflows/sync-repo.yml index 2c211cf318..9ac4771ef6 100644 --- a/.github/workflows/repo-sync.yml +++ b/.github/workflows/sync-repo.yml @@ -35,9 +35,8 @@ jobs: env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | - RUN_ID="${{ github.run_id }}" TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" - TARGET_BRANCH="sync/${RUN_ID}" + TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index b5a7b4f157..e0741f7c4a 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -5,11 +5,13 @@ import { observer } from "mobx-react"; import { useTheme as useNextTheme } from "next-themes"; import { LogOut, UserCog2, Palette } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; +// plane ui import { Avatar } from "@plane/ui"; -// hooks -import { API_BASE_URL, cn } from "@/helpers/common.helper"; -import { useTheme, useUser } from "@/hooks/store"; // helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useTheme, useUser } from "@/hooks/store"; // services import { AuthService } from "@/services/auth.service"; @@ -122,7 +124,7 @@ export const SidebarDropdown = observer(() => { { + if (!path) return undefined; + const isValidURL = path.startsWith("http"); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; diff --git a/admin/helpers/string.helper.ts b/admin/helpers/string.helper.ts new file mode 100644 index 0000000000..a48508118e --- /dev/null +++ b/admin/helpers/string.helper.ts @@ -0,0 +1,21 @@ +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/admin/package.json b/admin/package.json index 1246bc912d..bd8202bbe7 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "0.23.0", + "version": "0.23.1", "private": true, "scripts": { "dev": "turbo run develop", @@ -22,7 +22,6 @@ "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", "axios": "^1.7.4", - "js-cookie": "^3.0.5", "lodash": "^4.17.21", "lucide-react": "^0.356.0", "mobx": "^6.12.0", @@ -41,7 +40,6 @@ "devDependencies": { "@plane/eslint-config": "*", "@plane/typescript-config": "*", - "@types/js-cookie": "^3.0.6", "@types/node": "18.16.1", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/apiserver/package.json b/apiserver/package.json index b9c1349812..f26382c893 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.23.0" + "version": "0.23.1" } diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 72c5f8da98..263be85b85 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -5,7 +5,6 @@ from .issue import ( IssueSerializer, LabelSerializer, IssueLinkSerializer, - IssueAttachmentSerializer, IssueCommentSerializer, IssueAttachmentSerializer, IssueActivitySerializer, diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index ab054ae51c..c4a131fd74 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -11,7 +11,7 @@ from plane.db.models import ( IssueType, IssueActivity, IssueAssignee, - IssueAttachment, + FileAsset, IssueComment, IssueLabel, IssueLink, @@ -31,6 +31,7 @@ from .user import UserLiteSerializer from django.core.exceptions import ValidationError from django.core.validators import URLValidator + class IssueSerializer(BaseSerializer): assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField( @@ -211,7 +212,7 @@ class IssueSerializer(BaseSerializer): updated_by_id = instance.updated_by_id if assignees is not None: - IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.filter(issue=instance).delete(soft=False) IssueAssignee.objects.bulk_create( [ IssueAssignee( @@ -228,7 +229,7 @@ class IssueSerializer(BaseSerializer): ) if labels is not None: - IssueLabel.objects.filter(issue=instance).delete() + IssueLabel.objects.filter(issue=instance).delete(soft=False) IssueLabel.objects.bulk_create( [ IssueLabel( @@ -315,7 +316,7 @@ class IssueLinkSerializer(BaseSerializer): "created_at", "updated_at", ] - + def validate_url(self, value): # Check URL format validate_url = URLValidator() @@ -359,7 +360,7 @@ class IssueLinkSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "id", diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index d1fea20230..591a1203dc 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -19,6 +19,7 @@ class ProjectSerializer(BaseSerializer): 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 @@ -32,6 +33,7 @@ class ProjectSerializer(BaseSerializer): "created_by", "updated_by", "deleted_at", + "cover_image_url", ] def validate(self, data): @@ -87,6 +89,8 @@ class ProjectSerializer(BaseSerializer): class ProjectLiteSerializer(BaseSerializer): + cover_image_url = serializers.CharField(read_only=True) + class Meta: model = Project fields = [ @@ -97,5 +101,6 @@ class ProjectLiteSerializer(BaseSerializer): "icon_prop", "emoji", "description", + "cover_image_url", ] read_only_fields = fields diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index e853b90c29..b266d7d545 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer): "last_name", "email", "avatar", + "avatar_url", "display_name", "email", ] diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3814466322..882692dac0 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -13,8 +13,12 @@ from django.db.models import ( Q, Sum, FloatField, + Case, + When, + Value, ) -from django.db.models.functions import Cast +from django.db.models.functions import Cast, Concat +from django.db import models # Third party imports from rest_framework import status @@ -32,7 +36,7 @@ from plane.db.models import ( CycleIssue, Issue, Project, - IssueAttachment, + FileAsset, IssueLink, ProjectMember, UserFavorite, @@ -207,8 +211,7 @@ class CycleAPIEndpoint(BaseAPIView): # Incomplete Cycles if cycle_view == "incomplete": queryset = queryset.filter( - Q(end_date__gte=timezone.now().date()) - | Q(end_date__isnull=True), + Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True), ) return self.paginate( request=request, @@ -309,10 +312,7 @@ class CycleAPIEndpoint(BaseAPIView): request_data = request.data - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order request_data = { @@ -404,11 +404,7 @@ class CycleAPIEndpoint(BaseAPIView): epoch=int(timezone.now().timestamp()), ) # Delete the cycle - cycle.delete() - # Delete the cycle issues - CycleIssue.objects.filter( - cycle_id=self.kwargs.get("pk"), - ).delete() + cycle.delete(soft=False) # Delete the user favorite cycle UserFavorite.objects.filter( entity_type="cycle", @@ -537,7 +533,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): cycle = Cycle.objects.get( pk=cycle_id, project_id=project_id, workspace__slug=slug ) - if cycle.end_date >= timezone.now().date(): + if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, status=status.HTTP_400_BAD_REQUEST, @@ -645,8 +641,9 @@ class CycleIssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -887,7 +884,27 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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()) @@ -924,7 +941,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): if item["assignee_id"] else None ), - "avatar": item["avatar"], + "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"], @@ -1002,7 +1020,27 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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", @@ -1041,7 +1079,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): "assignee_id": ( str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "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"], @@ -1146,7 +1185,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): if ( new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() + and new_cycle.end_date < timezone.now() ): return Response( { diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 24eac569d6..f7e18dd76f 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -285,7 +285,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): ) # Only project admins and members can edit inbox issue attributes - if project_member.role > 5: + if project_member.role > 15: serializer = InboxIssueSerializer( inbox_issue, data=request.data, partial=True ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1cd8ed1b47..27c7042b49 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -42,7 +42,7 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueComment, IssueLink, Label, @@ -202,7 +202,15 @@ class IssueAPIEndpoint(BaseAPIView): issue_queryset = ( self.get_queryset() - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + Q(issue_cycle__cycle__deleted_at__isnull=True), + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -210,8 +218,9 @@ class IssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -1062,7 +1071,7 @@ class IssueAttachmentEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] - model = IssueAttachment + model = FileAsset parser_classes = (MultiPartParser, FormParser) def post(self, request, slug, project_id, issue_id): @@ -1070,7 +1079,7 @@ class IssueAttachmentEndpoint(BaseAPIView): if ( request.data.get("external_id") and request.data.get("external_source") - and IssueAttachment.objects.filter( + and FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, issue_id=issue_id, @@ -1078,7 +1087,7 @@ class IssueAttachmentEndpoint(BaseAPIView): external_id=request.data.get("external_id"), ).exists() ): - issue_attachment = IssueAttachment.objects.filter( + issue_attachment = FileAsset.objects.filter( workspace__slug=slug, project_id=project_id, external_id=request.data.get("external_id"), @@ -1112,7 +1121,7 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment = FileAsset.objects.get(pk=pk) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -1130,7 +1139,7 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( + issue_attachments = FileAsset.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 67ccf13a9f..45407df80f 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -21,7 +21,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, Module, ModuleIssue, @@ -393,8 +393,9 @@ class ModuleIssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 618a9ec20f..b3c3d79499 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -124,3 +124,9 @@ from .webhook import WebhookSerializer, WebhookLogSerializer from .dashboard import DashboardSerializer, WidgetSerializer from .favorite import UserFavoriteSerializer + +from .draft import ( + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 6693ba931c..f84d349a61 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -49,48 +49,46 @@ class DynamicBaseSerializer(BaseSerializer): allowed.append(list(item.keys())[0]) for field in allowed: - if field not in self.fields: - from . import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, - UserLiteSerializer, - StateLiteSerializer, - IssueSerializer, - LabelSerializer, - CycleIssueSerializer, - IssueLiteSerializer, - IssueRelationSerializer, - InboxIssueLiteSerializer, - IssueReactionLiteSerializer, - IssueAttachmentLiteSerializer, - IssueLinkLiteSerializer, - ) + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueLiteSerializer, + IssueRelationSerializer, + InboxIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueLinkLiteSerializer, + ) - # Expansion mapper - expansion = { - "user": UserLiteSerializer, - "workspace": WorkspaceLiteSerializer, - "project": ProjectLiteSerializer, - "default_assignee": UserLiteSerializer, - "project_lead": UserLiteSerializer, - "state": StateLiteSerializer, - "created_by": UserLiteSerializer, - "issue": IssueSerializer, - "actor": UserLiteSerializer, - "owned_by": UserLiteSerializer, - "members": UserLiteSerializer, - "assignees": UserLiteSerializer, - "labels": LabelSerializer, - "issue_cycle": CycleIssueSerializer, - "parent": IssueLiteSerializer, - "issue_relation": IssueRelationSerializer, - "issue_inbox": InboxIssueLiteSerializer, - "issue_reactions": IssueReactionLiteSerializer, - "issue_attachment": IssueAttachmentLiteSerializer, - "issue_link": IssueLinkLiteSerializer, - "sub_issues": IssueLiteSerializer, - } + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueLiteSerializer, + "issue_relation": IssueRelationSerializer, + "issue_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, + } + if field not in self.fields and field in expansion: self.fields[field] = expansion[field]( many=( True @@ -178,4 +176,29 @@ class DynamicBaseSerializer(BaseSerializer): instance, f"{expand}_id", None ) + # Check if issue_attachments is in fields or expand + if ( + "issue_attachments" in self.fields + or "issue_attachments" in self.expand + ): + # Import the model here to avoid circular imports + from plane.db.models import FileAsset + + issue_id = getattr(instance, "id", None) + + if issue_id: + # Fetch related issue_attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + # Serialize issue_attachments and add them to the response + response["issue_attachments"] = ( + IssueAttachmentLiteSerializer( + issue_attachments, many=True + ).data + ) + else: + response["issue_attachments"] = [] + return response diff --git a/apiserver/plane/app/serializers/draft.py b/apiserver/plane/app/serializers/draft.py new file mode 100644 index 0000000000..fca695c987 --- /dev/null +++ b/apiserver/plane/app/serializers/draft.py @@ -0,0 +1,296 @@ +# 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( + soft=False + ) + 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( + soft=False + ) + 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 is not None: + 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 index 4cdf94402a..22d9dc483d 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -27,7 +27,7 @@ from plane.db.models import ( Module, ModuleIssue, IssueLink, - IssueAttachment, + FileAsset, IssueReaction, CommentReaction, IssueVote, @@ -201,7 +201,7 @@ class IssueCreateSerializer(BaseSerializer): updated_by_id = instance.updated_by_id if assignees is not None: - IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.filter(issue=instance).delete(soft=False) IssueAssignee.objects.bulk_create( [ IssueAssignee( @@ -218,7 +218,7 @@ class IssueCreateSerializer(BaseSerializer): ) if labels is not None: - IssueLabel.objects.filter(issue=instance).delete() + IssueLabel.objects.filter(issue=instance).delete(soft=False) IssueLabel.objects.bulk_create( [ IssueLabel( @@ -498,8 +498,11 @@ class IssueLinkLiteSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): + + asset_url = serializers.CharField(read_only=True) + class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "created_by", @@ -514,14 +517,15 @@ class IssueAttachmentSerializer(BaseSerializer): class IssueAttachmentLiteSerializer(DynamicBaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = [ "id", "asset", "attributes", - "issue_id", + # "issue_id", "updated_at", "updated_by", + "asset_url", ] read_only_fields = fields diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 948608f792..24bc5464e1 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -95,6 +95,7 @@ class ProjectLiteSerializer(BaseSerializer): "identifier", "name", "cover_image", + "cover_image_url", "logo_props", "description", ] @@ -117,6 +118,7 @@ class ProjectListSerializer(DynamicBaseSerializer): member_role = serializers.IntegerField(read_only=True) anchor = serializers.CharField(read_only=True) members = serializers.SerializerMethodField() + cover_image_url = serializers.CharField(read_only=True) def get_members(self, obj): project_members = getattr(obj, "members_list", None) @@ -128,6 +130,7 @@ class ProjectListSerializer(DynamicBaseSerializer): "member_id": member.member_id, "member__display_name": member.member.display_name, "member__avatar": member.member.avatar, + "member__avatar_url": member.member.avatar_url, } for member in project_members ] diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index f99214874a..993f74c827 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -56,12 +56,15 @@ class UserSerializer(BaseSerializer): class UserMeSerializer(BaseSerializer): + class Meta: model = User fields = [ "id", "avatar", "cover_image", + "avatar_url", + "cover_image_url", "date_joined", "display_name", "email", @@ -156,6 +159,7 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", ] @@ -173,6 +177,7 @@ class UserAdminLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", "email", diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 96ee7dce3e..1a2b89bba6 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -22,6 +22,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) + logo_url = serializers.CharField(read_only=True) def validate_slug(self, value): # Check if the slug is restricted @@ -39,6 +40,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): "created_at", "updated_at", "owner", + "logo_url", ] @@ -63,6 +65,7 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer): class WorkspaceMemberMeSerializer(BaseSerializer): + draft_issue_count = serializers.IntegerField(read_only=True) class Meta: model = WorkspaceMember fields = "__all__" diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index 2d84b93e0b..eed379fd7b 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -5,6 +5,13 @@ from plane.app.views import ( FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet, + # V2 Endpoints + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, ) @@ -38,4 +45,49 @@ urlpatterns = [ ), name="file-assets-restore", ), + # V2 Endpoints + path( + "assets/v2/workspaces//", + WorkspaceFileAssetEndpoint.as_view(), + name="workspace-file-assets", + ), + path( + "assets/v2/workspaces///", + WorkspaceFileAssetEndpoint.as_view(), + name="workspace-file-assets", + ), + path( + "assets/v2/user-assets/", + UserAssetsV2Endpoint.as_view(), + name="user-file-assets", + ), + path( + "assets/v2/user-assets//", + UserAssetsV2Endpoint.as_view(), + name="user-file-assets", + ), + path( + "assets/v2/workspaces//restore//", + AssetRestoreEndpoint.as_view(), + name="asset-restore", + ), + path( + "assets/v2/static//", + StaticFileAssetEndpoint.as_view(), + name="static-file-asset", + ), + path( + "assets/v2/workspaces//projects//", + ProjectAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//projects///", + ProjectAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//projects///bulk/", + ProjectBulkAssetEndpoint.as_view(), + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 564725e839..23330e8e11 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -11,7 +11,6 @@ from plane.app.views import ( IssueActivityEndpoint, IssueArchiveViewSet, IssueCommentViewSet, - IssueDraftViewSet, IssueListEndpoint, IssueReactionViewSet, IssueRelationViewSet, @@ -22,6 +21,7 @@ from plane.app.views import ( BulkArchiveIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, + IssueAttachmentV2Endpoint, ) urlpatterns = [ @@ -133,6 +133,18 @@ urlpatterns = [ IssueAttachmentEndpoint.as_view(), name="project-issue-attachments", ), + # V2 Attachments + path( + "assets/v2/workspaces//projects//issues//attachments/", + IssueAttachmentV2Endpoint.as_view(), + name="project-issue-attachments", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//", + IssueAttachmentV2Endpoint.as_view(), + name="project-issue-attachments", + ), + ## Export Issues path( "workspaces//export-issues/", ExportIssuesEndpoint.as_view(), @@ -290,28 +302,6 @@ urlpatterns = [ name="issue-relation", ), ## End Issue Relation - ## Issue Drafts - path( - "workspaces//projects//issue-drafts/", - IssueDraftViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-draft", - ), - path( - "workspaces//projects//issue-drafts//", - IssueDraftViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-draft", - ), path( "workspaces//projects//deleted-issues/", DeletedIssuesListViewSet.as_view(), diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 3f1e000e47..fb6f4c13ac 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -27,6 +27,7 @@ from plane.app.views import ( WorkspaceCyclesEndpoint, WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, + WorkspaceDraftIssueViewSet, ) @@ -254,4 +255,30 @@ urlpatterns = [ WorkspaceFavoriteGroupEndpoint.as_view(), name="workspace-user-favorites-groups", ), + path( + "workspaces//draft-issues/", + WorkspaceDraftIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="workspace-draft-issues", + ), + path( + "workspaces//draft-issues//", + WorkspaceDraftIssueViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="workspace-drafts-issues", + ), + path( + "workspaces//draft-to-issue//", + WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}), + name="workspace-drafts-issues", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 6c4cc12c89..606d05e0d4 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -40,6 +40,8 @@ from .workspace.base import ( ExportWorkspaceUserActivityEndpoint, ) +from .workspace.draft import WorkspaceDraftIssueViewSet + from .workspace.favorite import ( WorkspaceFavoriteEndpoint, WorkspaceFavoriteGroupEndpoint, @@ -108,7 +110,19 @@ from .cycle.archive import ( CycleArchiveUnarchiveEndpoint, ) -from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet +from .asset.base import ( + FileAssetEndpoint, + UserAssetsEndpoint, + FileAssetViewSet, +) +from .asset.v2 import ( + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, +) from .issue.base import ( IssueListEndpoint, IssueViewSet, @@ -126,6 +140,8 @@ from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint from .issue.attachment import ( IssueAttachmentEndpoint, + # V2 + IssueAttachmentV2Endpoint, ) from .issue.comment import ( @@ -133,8 +149,6 @@ from .issue.comment import ( CommentReactionViewSet, ) -from .issue.draft import IssueDraftViewSet - from .issue.label import ( LabelViewSet, BulkCreateIssueLabelsEndpoint, diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 65ba1469c5..ddbde02692 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -1,7 +1,10 @@ # Django imports -from django.db.models import Count, F, Sum +from django.db.models import Count, F, Sum, Q from django.db.models.functions import ExtractMonth from django.utils import timezone +from django.db.models.functions import Concat +from django.db.models import Case, When, Value +from django.db import models # Third party imports from rest_framework import status @@ -118,14 +121,37 @@ class AnalyticsEndpoint(BaseAPIView): if x_axis in ["assignees__id"] or segment in ["assignees__id"]: assignee_details = ( Issue.issue_objects.filter( + Q( + Q(assignees__avatar__isnull=False) + | Q(assignees__avatar_asset__isnull=False) + ), workspace__slug=slug, **filters, - assignees__avatar__isnull=False, + ) + .annotate( + assignees__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(), + ) ) .order_by("assignees__id") .distinct("assignees__id") .values( - "assignees__avatar", + "assignees__avatar_url", "assignees__display_name", "assignees__first_name", "assignees__last_name", @@ -355,7 +381,6 @@ class DefaultAnalyticsEndpoint(BaseAPIView): user_details = [ "created_by__first_name", "created_by__last_name", - "created_by__avatar", "created_by__display_name", "created_by__id", ] @@ -364,13 +389,32 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.exclude(created_by=None) .values(*user_details) .annotate(count=Count("id")) + .annotate( + created_by__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + created_by__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "created_by__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( + created_by__avatar_asset__isnull=True, + then="created_by__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .order_by("-count")[:5] ) user_assignee_details = [ "assignees__first_name", "assignees__last_name", - "assignees__avatar", "assignees__display_name", "assignees__id", ] @@ -379,6 +423,26 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.filter(completed_at__isnull=False) .exclude(assignees=None) .values(*user_assignee_details) + .annotate( + assignees__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(), + ) + ) .annotate(count=Count("id")) .order_by("-count")[:5] ) @@ -387,6 +451,26 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.filter(completed_at__isnull=True) .values(*user_assignee_details) .annotate(count=Count("id")) + .annotate( + assignees__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(), + ) + ) .order_by("-count") ) diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py new file mode 100644 index 0000000000..c307bbd4b3 --- /dev/null +++ b/apiserver/plane/app/views/asset/v2.py @@ -0,0 +1,803 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny + +# Module imports +from ..base import BaseAPIView +from plane.db.models import ( + FileAsset, + Workspace, + Project, + User, +) +from plane.settings.storage import S3Storage +from plane.app.permissions import allow_permission, ROLE +from plane.utils.cache import invalidate_cache_directly +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata + + +class UserAssetsV2Endpoint(BaseAPIView): + """This endpoint is used to upload user profile images.""" + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + if asset is None: + return + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save() + return + + def entity_asset_save(self, asset_id, entity_type, asset, request): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar = "" + # Delete the previous avatar + if user.avatar_asset_id: + self.asset_delete(user.avatar_asset_id) + # Save the new avatar + user.avatar_asset_id = asset_id + user.save() + invalidate_cache_directly( + path="/api/users/me/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image = None + # Delete the previous cover image + if user.cover_image_asset_id: + self.asset_delete(user.cover_image_asset_id) + # Save the new cover image + user.cover_image_asset_id = asset_id + user.save() + invalidate_cache_directly( + path="/api/users/me/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + return + + def entity_asset_delete(self, entity_type, asset, request): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar_asset_id = None + user.save() + invalidate_cache_directly( + path="/api/users/me/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image_asset_id = None + user.save() + invalidate_cache_directly( + path="/api/users/me/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/users/me/settings/", + url_params=False, + user=True, + request=request, + ) + return + return + + def post(self, request): + # get the asset key + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", False) + + # Check if the file size is within the limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Check if the entity type is allowed + if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + user=request.user, + created_by=request.user, + entity_type=entity_type, + ) + + # 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), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + # get the entity and save the asset id for the request field + self.entity_asset_save( + asset_id=asset_id, + entity_type=asset.entity_type, + asset=asset, + request=request, + ) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, asset_id): + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceFileAssetEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + def get_entity_id_field(self, entity_type, entity_id): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return { + "workspace_id": entity_id, + } + + # Project Cover + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return { + "project_id": entity_id, + } + + # User Avatar and Cover + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return { + "user_id": entity_id, + } + + # Issue Attachment and Description + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return { + "issue_id": entity_id, + } + + # Page Description + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return { + "page_id": entity_id, + } + + # Comment Description + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return { + "comment_id": entity_id, + } + return {} + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + # Check if the asset exists + if asset is None: + return + # Mark the asset as deleted + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save() + return + + def entity_asset_save(self, asset_id, entity_type, asset, request): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace = Workspace.objects.filter(id=asset.workspace_id).first() + if workspace is None: + return + # Delete the previous logo + if workspace.logo_asset_id: + self.asset_delete(workspace.logo_asset_id) + # Save the new logo + workspace.logo = "" + workspace.logo_asset_id = asset_id + workspace.save() + invalidate_cache_directly( + path="/api/workspaces/", + url_params=False, + user=False, + request=request, + ) + invalidate_cache_directly( + path="/api/users/me/workspaces/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/instances/", + url_params=False, + user=False, + request=request, + ) + return + + # Project Cover + elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + project = Project.objects.filter(id=asset.workspace_id).first() + if project is None: + return + # Delete the previous cover image + if project.cover_image_asset_id: + self.asset_delete(project.cover_image_asset_id) + # Save the new cover image + project.cover_image = "" + project.cover_image_asset_id = asset_id + project.save() + return + else: + return + + def entity_asset_delete(self, entity_type, asset, request): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace = Workspace.objects.get(id=asset.workspace_id) + if workspace is None: + return + workspace.logo_asset_id = None + workspace.save() + invalidate_cache_directly( + path="/api/workspaces/", + url_params=False, + user=False, + request=request, + ) + invalidate_cache_directly( + path="/api/users/me/workspaces/", + url_params=False, + user=True, + request=request, + ) + invalidate_cache_directly( + path="/api/instances/", + url_params=False, + user=False, + request=request, + ) + return + # Project Cover + elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + project = Project.objects.filter(id=asset.project_id).first() + if project is None: + return + project.cover_image_asset_id = None + project.save() + return + else: + return + + def post(self, request, slug): + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type") + entity_identifier = request.data.get("entity_identifier", False) + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + workspace=workspace, + created_by=request.user, + entity_type=entity_type, + **self.get_entity_id_field( + entity_type=entity_type, entity_id=entity_identifier + ), + ) + + # 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), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + # get the entity and save the asset id for the request field + self.entity_asset_save( + asset_id=asset_id, + entity_type=asset.entity_type, + asset=asset, + request=request, + ) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, asset_id): + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class StaticFileAssetEndpoint(BaseAPIView): + """This endpoint is used to get the signed URL for a static asset.""" + + permission_classes = [ + AllowAny, + ] + + def get(self, request, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the entity type is allowed + if asset.entity_type not in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + FileAsset.EntityTypeContext.WORKSPACE_LOGO, + FileAsset.EntityTypeContext.PROJECT_COVER, + ]: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class AssetRestoreEndpoint(BaseAPIView): + """Endpoint to restore a deleted assets.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def post(self, request, slug, asset_id): + asset = FileAsset.all_objects.get(id=asset_id, workspace__slug=slug) + asset.is_deleted = False + asset.deleted_at = None + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectAssetEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + def get_entity_id_field(self, entity_type, entity_id): + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return { + "workspace_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return { + "project_id": entity_id, + } + + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return { + "user_id": entity_id, + } + + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return { + "issue_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return { + "page_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return { + "comment_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: + return { + "draft_issue_id": entity_id, + } + return {} + + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], + ) + def post(self, request, slug, project_id): + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", "") + entity_identifier = request.data.get("entity_identifier") + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + workspace=workspace, + created_by=request.user, + entity_type=entity_type, + project_id=project_id, + **self.get_entity_id_field(entity_type, entity_identifier), + ) + + # 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), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], + ) + def patch(self, request, slug, project_id, pk): + # get the asset id + asset = FileAsset.objects.get( + id=pk, + ) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(pk)) + + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def delete(self, request, slug, project_id, pk): + # Get the asset + asset = FileAsset.objects.get( + id=pk, + workspace__slug=slug, + project_id=project_id, + ) + # Check deleted assets + asset.is_deleted = True + asset.deleted_at = timezone.now() + # Save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, pk): + # get the asset id + asset = FileAsset.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class ProjectBulkAssetEndpoint(BaseAPIView): + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, entity_id): + asset_ids = request.data.get("asset_ids", []) + + # Check if the asset ids are provided + if not asset_ids: + return Response( + { + "error": "No asset ids provided.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # get the asset id + assets = FileAsset.objects.filter( + id__in=asset_ids, + workspace__slug=slug, + ) + + # Get the first asset + asset = assets.first() + + if not asset: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the asset is uploaded + if asset.entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + assets.update( + project_id=project_id, + ) + + if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: + assets.update( + issue_id=entity_id, + ) + + if ( + asset.entity_type + == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION + ): + assets.update( + comment_id=entity_id, + ) + + if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + assets.update( + page_id=entity_id, + ) + + if ( + asset.entity_type + == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION + ): + assets.update( + draft_issue_id=entity_id, + ) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 25ad8a2eb6..4a0dd14a47 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -1,6 +1,7 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField +from django.db import models from django.db.models import ( Case, CharField, @@ -18,7 +19,7 @@ from django.db.models import ( Sum, FloatField, ) -from django.db.models.functions import Coalesce, Cast +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone # Third party imports @@ -139,7 +140,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): Prefetch( "issue_cycle__issue__assignees", queryset=User.objects.only( - "avatar", "first_name", "id" + "avatar_asset", "first_name", "id" ).distinct(), ) ) @@ -159,6 +160,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -170,6 +172,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -181,6 +184,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -192,6 +196,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -203,6 +208,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -214,6 +220,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -400,8 +407,27 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -494,13 +520,32 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(first_name=F("assignees__first_name")) .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) + .annotate( + 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(), + ) + ) .annotate(display_name=F("assignees__display_name")) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( @@ -604,7 +649,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): pk=cycle_id, project_id=project_id, workspace__slug=slug ) - if cycle.end_date >= timezone.now().date(): + if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index fc04abe35d..e021c4ba1a 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -20,7 +20,8 @@ from django.db.models import ( Sum, FloatField, ) -from django.db.models.functions import Coalesce, Cast +from django.db import models +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder @@ -81,7 +82,7 @@ class CycleViewSet(BaseViewSet): Prefetch( "issue_cycle__issue__assignees", queryset=User.objects.only( - "avatar", "first_name", "id" + "avatar_asset", "first_name", "id" ).distinct(), ) ) @@ -101,6 +102,7 @@ class CycleViewSet(BaseViewSet): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -112,6 +114,7 @@ class CycleViewSet(BaseViewSet): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -187,6 +190,7 @@ class CycleViewSet(BaseViewSet): "completed_issues", "assignee_ids", "status", + "version", "created_by", ) @@ -216,6 +220,7 @@ class CycleViewSet(BaseViewSet): "completed_issues", "assignee_ids", "status", + "version", "created_by", ) return Response(data, status=status.HTTP_200_OK) @@ -255,6 +260,7 @@ class CycleViewSet(BaseViewSet): "external_id", "progress_snapshot", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -306,10 +312,7 @@ class CycleViewSet(BaseViewSet): request_data = request.data - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order for a completed cycle`` request_data = { @@ -347,6 +350,7 @@ class CycleViewSet(BaseViewSet): "external_id", "progress_snapshot", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -412,6 +416,7 @@ class CycleViewSet(BaseViewSet): "progress_snapshot", "sub_issues", "logo_props", + "version", # meta fields "is_favorite", "total_issues", @@ -485,12 +490,9 @@ class CycleViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - # Delete the cycle - cycle.delete() - # Delete the cycle issues - CycleIssue.objects.filter( - cycle_id=self.kwargs.get("pk"), - ).delete() + # TODO: Soft delete the cycle break the onetoone relationship with cycle issue + cycle.delete(soft=False) + # Delete the user favorite cycle UserFavorite.objects.filter( user=request.user, @@ -604,6 +606,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -614,6 +617,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -624,6 +628,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -634,6 +639,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -644,6 +650,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -665,8 +672,27 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -703,7 +729,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar"), + "avatar_url": item.get("avatar_url"), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -780,8 +807,27 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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", @@ -820,7 +866,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): "assignee_id": ( str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar"), + "avatar_url": item.get("avatar_url"), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -925,7 +972,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): if ( new_cycle.end_date is not None - and new_cycle.end_date < timezone.now().date() + and new_cycle.end_date < timezone.now() ): return Response( { @@ -1148,6 +1195,7 @@ class CycleProgressEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) + class CycleAnalyticsEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @@ -1166,6 +1214,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): filter=Q( issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -1198,8 +1247,27 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -1282,8 +1350,27 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + 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( "assignee_id", diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 211f5a88ac..a9a3305990 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -3,7 +3,7 @@ import json # Django imports from django.core import serializers -from django.db.models import F, Func, OuterRef, Q +from django.db.models import F, Func, OuterRef, Q, Case, When from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -22,7 +22,7 @@ from plane.db.models import ( Cycle, CycleIssue, Issue, - IssueAttachment, + FileAsset, IssueLink, ) from plane.utils.grouper import ( @@ -102,7 +102,15 @@ class CycleIssueViewSet(BaseViewSet): "issue_cycle__cycle", ) .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -110,8 +118,9 @@ class CycleIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -246,10 +255,7 @@ class CycleIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, pk=cycle_id ) - if ( - cycle.end_date is not None - and cycle.end_date < timezone.now().date() - ): + if cycle.end_date is not None and cycle.end_date < timezone.now(): return Response( { "error": "The Cycle has already been completed so no new issues can be added" diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 4a760ca3b1..1cb446abb1 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -36,12 +36,10 @@ from plane.db.models import ( DashboardWidget, Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueLink, IssueRelation, Project, - ProjectMember, - User, Widget, WorkspaceMember, ) @@ -58,7 +56,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, workspace__slug=slug, assignees__in=[request.user], - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -85,7 +84,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, workspace__slug=slug, assignees__in=[request.user], - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -110,7 +110,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, created_by_id=request.user.id, - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -136,7 +137,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, assignees__in=[request.user], state__group="completed", - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -197,8 +199,9 @@ def dashboard_assigned_issues(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -215,7 +218,10 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -232,7 +238,9 @@ def dashboard_assigned_issues(self, request, slug): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -360,8 +368,9 @@ def dashboard_created_issues(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -378,7 +387,10 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -395,7 +407,9 @@ def dashboard_created_issues(self, request, slug): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/estimate/base.py b/apiserver/plane/app/views/estimate/base.py index f384880019..80943c05eb 100644 --- a/apiserver/plane/app/views/estimate/base.py +++ b/apiserver/plane/app/views/estimate/base.py @@ -1,5 +1,9 @@ import random import string +import json + +# Django imports +from django.utils import timezone # Third party imports from rest_framework.response import Response @@ -19,6 +23,7 @@ from plane.app.serializers import ( EstimateReadSerializer, ) from plane.utils.cache import invalidate_cache +from plane.bgtasks.issue_activities_task import issue_activity def generate_random_name(length=10): @@ -249,11 +254,66 @@ class EstimatePointEndpoint(BaseViewSet): ) # update all the issues with the new estimate if new_estimate_id: - _ = Issue.objects.filter( + issues = Issue.objects.filter( project_id=project_id, workspace__slug=slug, estimate_point_id=estimate_point_id, - ).update(estimate_point_id=new_estimate_id) + ) + for issue in issues: + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps( + { + "estimate_point": ( + str(new_estimate_id) + if new_estimate_id + else None + ), + } + ), + actor_id=str(request.user.id), + issue_id=issue.id, + project_id=str(project_id), + current_instance=json.dumps( + { + "estimate_point": ( + str(issue.estimate_point_id) + if issue.estimate_point_id + else None + ), + } + ), + epoch=int(timezone.now().timestamp()), + ) + issues.update(estimate_point_id=new_estimate_id) + else: + issues = Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + estimate_point_id=estimate_point_id, + ) + for issue in issues: + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps( + { + "estimate_point": None, + } + ), + actor_id=str(request.user.id), + issue_id=issue.id, + project_id=str(project_id), + current_instance=json.dumps( + { + "estimate_point": ( + str(issue.estimate_point_id) + if issue.estimate_point_id + else None + ), + } + ), + epoch=int(timezone.now().timestamp()), + ) # delete the estimate point old_estimate_point = EstimatePoint.objects.filter( diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index 3bd5332dc2..dcae298d66 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -3,7 +3,7 @@ import json # Django import from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Case, When from django.core.serializers.json import DjangoJSONEncoder from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField @@ -23,7 +23,7 @@ from plane.db.models import ( Issue, State, IssueLink, - IssueAttachment, + FileAsset, Project, ProjectMember, ) @@ -112,7 +112,15 @@ class InboxIssueViewSet(BaseViewSet): ), ) ) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -120,8 +128,9 @@ class InboxIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -140,7 +149,10 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -158,7 +170,8 @@ class InboxIssueViewSet(BaseViewSet): "issue_module__module_id", distinct=True, filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -185,7 +198,8 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue__labels__id", distinct=True, - filter=~Q(issue__labels__id__isnull=True), + filter=~Q(issue__labels__id__isnull=True) + & Q(issue__labels__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ) @@ -297,7 +311,10 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue__labels__id", distinct=True, - filter=~Q(issue__labels__id__isnull=True), + filter=( + ~Q(issue__labels__id__isnull=True) + & Q(issue__labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -305,7 +322,8 @@ class InboxIssueViewSet(BaseViewSet): ArrayAgg( "issue__assignees__id", distinct=True, - filter=~Q(issue__assignees__id__isnull=True), + filter=~Q(issue__assignees__id__isnull=True) + & Q(issue__assignees__member_project__is_active=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -323,7 +341,7 @@ class InboxIssueViewSet(BaseViewSet): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue) def partial_update(self, request, slug, project_id, pk): inbox_id = Inbox.objects.filter( workspace__slug=slug, project_id=project_id @@ -418,7 +436,7 @@ class InboxIssueViewSet(BaseViewSet): ) # Only project admins and members can edit inbox issue attributes - if project_member.role > 5: + if project_member.role > 15: serializer = InboxIssueSerializer( inbox_issue, data=request.data, partial=True ) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 4817ea90e0..283f1ad99a 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -3,14 +3,7 @@ import json # Django imports from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import ( - F, - Func, - OuterRef, - Q, - Prefetch, - Exists, -) +from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Case, When from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -30,7 +23,7 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueSubscriber, IssueReaction, @@ -71,7 +64,15 @@ class IssueArchiveViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -79,8 +80,9 @@ class IssueArchiveViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -236,12 +238,6 @@ class IssueArchiveViewSet(BaseViewSet): ), ) ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) .prefetch_related( Prefetch( "issue_link", diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index 434c72d1d1..1fb230a553 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -1,9 +1,12 @@ # Python imports import json +import uuid # Django imports from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder +from django.conf import settings +from django.http import HttpResponseRedirect # Third Party imports from rest_framework.response import Response @@ -13,21 +16,29 @@ from rest_framework.parsers import MultiPartParser, FormParser # Module imports from .. import BaseAPIView from plane.app.serializers import IssueAttachmentSerializer -from plane.db.models import IssueAttachment +from plane.db.models import FileAsset, Workspace from plane.bgtasks.issue_activities_task import issue_activity from plane.app.permissions import allow_permission, ROLE +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer - model = IssueAttachment + model = FileAsset parser_classes = (MultiPartParser, FormParser) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id, issue_id): serializer = IssueAttachmentSerializer(data=request.data) + workspace = Workspace.objects.get(slug=slug) if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) + serializer.save( + project_id=project_id, + issue_id=issue_id, + workspace_id=workspace.id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) issue_activity.delay( type="attachment.activity.created", requested_data=None, @@ -45,9 +56,9 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment) + @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment = FileAsset.objects.get(pk=pk) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -72,8 +83,179 @@ class IssueAttachmentEndpoint(BaseAPIView): ] ) def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( + issue_attachments = FileAsset.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueAttachmentV2Endpoint(BaseAPIView): + + serializer_class = IssueAttachmentSerializer + model = FileAsset + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, issue_id): + name = request.data.get("name") + type = request.data.get("type", False) + size = int(request.data.get("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}" + + # Get the size limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # 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, + ) + + # 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, + ) + + @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) + 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=request.META.get("HTTP_ORIGIN"), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission( + [ + ROLE.ADMIN, + ROLE.MEMBER, + ROLE.GUEST, + ] + ) + 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) + + @allow_permission( + [ + ROLE.ADMIN, + ROLE.MEMBER, + ROLE.GUEST, + ] + ) + 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=request.META.get("HTTP_ORIGIN"), + ) + + # Update the attachment + issue_attachment.is_uploaded = 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) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index eca14018ff..0488a5471d 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -14,6 +14,8 @@ from django.db.models import ( Q, UUIDField, Value, + When, + Case, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -35,7 +37,7 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueUserProperty, IssueReaction, @@ -83,7 +85,15 @@ class IssueListEndpoint(BaseAPIView): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -91,8 +101,9 @@ class IssueListEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -206,7 +217,15 @@ class IssueViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -214,8 +233,9 @@ class IssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -469,7 +489,10 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -487,7 +510,8 @@ class IssueViewSet(BaseViewSet): "issue_module__module_id", distinct=True, filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -500,12 +524,6 @@ class IssueViewSet(BaseViewSet): ), ) ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) .prefetch_related( Prefetch( "issue_link", @@ -572,7 +590,10 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -589,7 +610,9 @@ class IssueViewSet(BaseViewSet): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -752,7 +775,15 @@ class IssuePaginatedViewSet(BaseViewSet): "workspace", "project", "state", "parent" ) .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -760,8 +791,9 @@ class IssuePaginatedViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -791,7 +823,7 @@ class IssuePaginatedViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): cursor = request.GET.get("cursor", None) - is_description_required = request.GET.get("description", False) + is_description_required = request.GET.get("description", "false") updated_at = request.GET.get("updated_at__gt", None) # required fields @@ -824,7 +856,7 @@ class IssuePaginatedViewSet(BaseViewSet): "sub_issues_count", ] - if is_description_required: + if str(is_description_required).lower() == "true": required_fields.append("description_html") # querying issues @@ -858,7 +890,10 @@ class IssuePaginatedViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -876,7 +911,8 @@ class IssuePaginatedViewSet(BaseViewSet): "issue_module__module_id", distinct=True, filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py deleted file mode 100644 index c5899d9725..0000000000 --- a/apiserver/plane/app/views/issue/draft.py +++ /dev/null @@ -1,410 +0,0 @@ -# Python imports -import json - -# Django imports -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import ( - Exists, - F, - Func, - OuterRef, - Prefetch, - Q, - UUIDField, - Value, -) -from django.db.models.functions import Coalesce -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page - -# Third Party imports -from rest_framework import status -from rest_framework.response import Response - -# Module imports -from plane.app.permissions import ProjectEntityPermission -from plane.app.serializers import ( - IssueCreateSerializer, - IssueDetailSerializer, - IssueFlatSerializer, - IssueSerializer, -) -from plane.bgtasks.issue_activities_task import issue_activity -from plane.db.models import ( - Issue, - IssueAttachment, - IssueLink, - IssueReaction, - IssueSubscriber, - Project, - ProjectMember, -) -from plane.utils.grouper import ( - issue_group_values, - issue_on_results, - issue_queryset_grouper, -) -from plane.utils.issue_filters import issue_filters -from plane.utils.order_queryset import order_issue_queryset -from plane.utils.paginator import ( - GroupedOffsetPaginator, - SubGroupedOffsetPaginator, -) -from .. import BaseViewSet - - -class IssueDraftViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] - serializer_class = IssueFlatSerializer - model = Issue - - def get_queryset(self): - return ( - Issue.objects.filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(is_draft=True) - .filter(deleted_at__isnull=True) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - ).distinct() - - @method_decorator(gzip_page) - def list(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") - - order_by_param = request.GET.get("order_by", "-created_at") - - issue_queryset = self.get_queryset().filter(**filters) - # Issue queryset - issue_queryset, order_by_param = order_issue_queryset( - issue_queryset=issue_queryset, - order_by_param=order_by_param, - ) - - # Group by - group_by = request.GET.get("group_by", False) - sub_group_by = request.GET.get("sub_group_by", False) - - # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, - group_by=group_by, - sub_group_by=sub_group_by, - ) - - if group_by: - # Check group and sub group value paginate - if sub_group_by: - if group_by == sub_group_by: - return Response( - { - "error": "Group by and sub group by cannot have same parameters" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - # group and sub group pagination - return self.paginate( - request=request, - order_by=order_by_param, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, - issues=issues, - sub_group_by=sub_group_by, - ), - paginator_cls=SubGroupedOffsetPaginator, - group_by_fields=issue_group_values( - field=group_by, - slug=slug, - project_id=project_id, - filters=filters, - ), - sub_group_by_fields=issue_group_values( - field=sub_group_by, - slug=slug, - project_id=project_id, - filters=filters, - ), - group_by_field_name=group_by, - sub_group_by_field_name=sub_group_by, - count_filter=Q( - Q(issue_inbox__status=1) - | Q(issue_inbox__status=-1) - | Q(issue_inbox__status=2) - | Q(issue_inbox__isnull=True), - archived_at__isnull=True, - is_draft=False, - ), - ) - # Group Paginate - else: - # Group paginate - return self.paginate( - request=request, - order_by=order_by_param, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, - issues=issues, - sub_group_by=sub_group_by, - ), - paginator_cls=GroupedOffsetPaginator, - group_by_fields=issue_group_values( - field=group_by, - slug=slug, - project_id=project_id, - filters=filters, - ), - group_by_field_name=group_by, - count_filter=Q( - Q(issue_inbox__status=1) - | Q(issue_inbox__status=-1) - | Q(issue_inbox__status=2) - | Q(issue_inbox__isnull=True), - archived_at__isnull=True, - is_draft=False, - ), - ) - else: - # List Paginate - return self.paginate( - order_by=order_by_param, - request=request, - queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), - ) - - def create(self, request, slug, project_id): - project = Project.objects.get(pk=project_id) - - serializer = IssueCreateSerializer( - data=request.data, - context={ - "project_id": project_id, - "workspace_id": project.workspace_id, - "default_assignee_id": project.default_assignee_id, - }, - ) - - if serializer.is_valid(): - serializer.save(is_draft=True) - - # Track the issue - issue_activity.delay( - type="issue_draft.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()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - - issue = ( - issue_queryset_grouper( - queryset=self.get_queryset().filter( - pk=serializer.data["id"] - ), - group_by=None, - sub_group_by=None, - ) - .values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - .first() - ) - return Response(issue, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, slug, project_id, pk): - issue = self.get_queryset().filter(pk=pk).first() - - if not issue: - return Response( - {"error": "Issue does not exist"}, - status=status.HTTP_404_NOT_FOUND, - ) - - serializer = IssueCreateSerializer( - issue, data=request.data, partial=True - ) - - if serializer.is_valid(): - serializer.save() - issue_activity.delay( - type="issue_draft.activity.updated", - requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - IssueSerializer(issue).data, - cls=DjangoJSONEncoder, - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def retrieve(self, request, slug, project_id, pk=None): - issue = ( - self.get_queryset() - .filter(pk=pk) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related( - "issue", "actor" - ), - ) - ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) - .prefetch_related( - Prefetch( - "issue_link", - queryset=IssueLink.objects.select_related("created_by"), - ) - ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=OuterRef("pk"), - subscriber=request.user, - ) - ) - ) - ).first() - - if not issue: - return Response( - {"error": "The required object does not exist."}, - status=status.HTTP_404_NOT_FOUND, - ) - serializer = IssueDetailSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) - - def destroy(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - 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, - ) - issue.delete() - issue_activity.delay( - type="issue_draft.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={}, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index e69614747e..83385d83d7 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -3,7 +3,17 @@ import json # Django imports from django.utils import timezone -from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField +from django.db.models import ( + Q, + OuterRef, + F, + Func, + UUIDField, + Value, + CharField, + Case, + When, +) from django.core.serializers.json import DjangoJSONEncoder from django.db.models.functions import Coalesce from django.contrib.postgres.aggregates import ArrayAgg @@ -24,7 +34,7 @@ from plane.db.models import ( Project, IssueRelation, Issue, - IssueAttachment, + FileAsset, IssueLink, ) from plane.bgtasks.issue_activities_task import issue_activity @@ -83,7 +93,15 @@ class IssueRelationViewSet(BaseViewSet): Issue.issue_objects.filter(workspace__slug=slug) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -91,8 +109,9 @@ class IssueRelationViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -111,7 +130,10 @@ class IssueRelationViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 9496f17512..700d6db5b3 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -10,6 +10,8 @@ from django.db.models import ( Q, Value, UUIDField, + Case, + When, ) from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -28,7 +30,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.db.models import ( Issue, IssueLink, - IssueAttachment, + FileAsset, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.user_timezone_converter import user_timezone_converter @@ -48,7 +50,15 @@ class SubIssuesEndpoint(BaseAPIView): ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -56,8 +66,9 @@ class SubIssuesEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -76,7 +87,10 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -93,7 +107,9 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index b38d83487c..f9d23cb4e5 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -14,9 +14,12 @@ from django.db.models import ( Value, Sum, FloatField, + Case, + When, ) -from django.db.models.functions import Coalesce, Cast +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone +from django.db import models # Third party imports from rest_framework import status @@ -364,12 +367,31 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) + .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( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( @@ -437,7 +459,9 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) .order_by("label_name") ) - data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"][ + "assignees" + ] = assignee_distribution data["estimate_distribution"]["labels"] = label_distribution if modules and modules.start_date and modules.target_date: @@ -461,12 +485,31 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) + .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( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index d09848fd94..ba1ae840fb 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -18,8 +18,11 @@ from django.db.models import ( Value, Sum, FloatField, + Case, + When, ) -from django.db.models.functions import Coalesce, Cast +from django.db import models +from django.db.models.functions import Coalesce, Cast, Concat from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone @@ -30,6 +33,7 @@ from rest_framework.response import Response # Module imports from plane.app.permissions import ( ProjectEntityPermission, + ProjectLitePermission, allow_permission, ROLE, ) @@ -317,13 +321,12 @@ class ModuleViewSet(BaseViewSet): .order_by("-is_favorite", "-created_at") ) - allow_permission( + @allow_permission( [ ROLE.ADMIN, ROLE.MEMBER, ] ) - def create(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) serializer = ModuleWriteSerializer( @@ -386,8 +389,7 @@ class ModuleViewSet(BaseViewSet): return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) if self.fields: @@ -435,13 +437,7 @@ class ModuleViewSet(BaseViewSet): ) return Response(modules, status=status.HTTP_200_OK) - allow_permission( - [ - ROLE.ADMIN, - ROLE.MEMBER, - ] - ) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def retrieve(self, request, slug, project_id, pk): queryset = ( self.get_queryset() @@ -488,12 +484,31 @@ class ModuleViewSet(BaseViewSet): .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) + .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( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( @@ -585,12 +600,31 @@ class ModuleViewSet(BaseViewSet): .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) - .annotate(avatar=F("assignees__avatar")) + .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( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( @@ -672,7 +706,13 @@ class ModuleViewSet(BaseViewSet): "labels": label_distribution, "completion_chart": {}, } - if modules and modules.start_date and modules.target_date: + + if ( + modules + and modules.start_date + and modules.target_date + and modules.total_issues > 0 + ): data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, @@ -838,6 +878,9 @@ class ModuleLinkViewSet(BaseViewSet): class ModuleFavoriteViewSet(BaseViewSet): model = UserFavorite + permission_classes = [ + ProjectLitePermission, + ] def get_queryset(self): return self.filter_queryset( diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index eb63890d20..f58e477567 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -6,6 +6,8 @@ from django.db.models import ( Func, OuterRef, Q, + Case, + When, ) # Django Imports @@ -24,7 +26,7 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, ModuleIssue, Project, @@ -65,7 +67,15 @@ class ModuleIssueViewSet(BaseViewSet): ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -73,8 +83,9 @@ class ModuleIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index bb4814e473..5e56cc7036 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -18,7 +18,7 @@ from django.db.models.functions import Coalesce 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, @@ -35,10 +35,7 @@ from plane.db.models import ( Project, ) from plane.utils.error_codes import ERROR_CODES - -# Module imports 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 diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 8bdff03e62..4afc747c35 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -53,6 +53,7 @@ from plane.db.models import ( from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.exception_logger import log_exception class ProjectViewSet(BaseViewSet): @@ -413,9 +414,20 @@ class ProjectViewSet(BaseViewSet): status=status.HTTP_410_GONE, ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") 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) @@ -497,6 +509,44 @@ class ProjectViewSet(BaseViewSet): status=status.HTTP_410_GONE, ) + 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) + project.delete() + + # 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): @@ -671,18 +721,22 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): "Prefix": "static/project-cover/", } - 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']}" - ) + 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) + 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): diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 861aa4292e..59cfcecd57 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -9,6 +9,8 @@ from django.db.models import ( Q, UUIDField, Value, + Case, + When, ) from django.db.models.functions import Coalesce from django.utils.decorators import method_decorator @@ -29,7 +31,7 @@ from plane.app.serializers import ( ) from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueView, Workspace, @@ -205,7 +207,15 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -213,8 +223,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -233,7 +244,10 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -251,7 +265,8 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): "issue_module__module_id", distinct=True, filter=~Q(issue_module__module_id__isnull=True) - & Q(issue_module__module__archived_at__isnull=True), + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -270,7 +285,15 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) ) # check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user @@ -431,8 +454,7 @@ class IssueViewViewSet(BaseViewSet): .distinct() ) - allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset() project = Project.objects.get(id=project_id) @@ -457,8 +479,7 @@ class IssueViewViewSet(BaseViewSet): ).data return Response(views, status=status.HTTP_200_OK) - allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def retrieve(self, request, slug, project_id, pk): issue_view = ( self.get_queryset().filter(pk=pk, project_id=project_id).first() @@ -498,8 +519,7 @@ class IssueViewViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - allow_permission(allowed_roles=[], creator=True, model=IssueView) - + @allow_permission(allowed_roles=[], creator=True, model=IssueView) def partial_update(self, request, slug, project_id, pk): with transaction.atomic(): issue_view = IssueView.objects.select_for_update().get( @@ -532,8 +552,7 @@ class IssueViewViewSet(BaseViewSet): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView) - + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView) def destroy(self, request, slug, project_id, pk): project_view = IssueView.objects.get( pk=pk, @@ -578,8 +597,7 @@ class IssueViewFavoriteViewSet(BaseViewSet): .select_related("view") ) - allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): _ = UserFavorite.objects.create( user=request.user, @@ -589,8 +607,7 @@ class IssueViewFavoriteViewSet(BaseViewSet): ) return Response(status=status.HTTP_204_NO_CONTENT) - allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, view_id): view_favorite = UserFavorite.objects.get( project=project_id, diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index f642416e3b..5dfbafc41e 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -43,6 +43,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -53,6 +54,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -63,6 +65,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -73,6 +76,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) @@ -83,6 +87,7 @@ class WorkspaceCyclesEndpoint(BaseAPIView): issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, + issue_cycle__issue__deleted_at__isnull=True, ), ) ) diff --git a/apiserver/plane/app/views/workspace/draft.py b/apiserver/plane/app/views/workspace/draft.py new file mode 100644 index 0000000000..ae7db2a401 --- /dev/null +++ b/apiserver/plane/app/views/workspace/draft.py @@ -0,0 +1,354 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone +from django.core import serializers +from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import ( + F, + Q, + UUIDField, + Value, + Case, + When, +) +from django.db.models.functions import Coalesce +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page + +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.app.permissions import allow_permission, ROLE +from plane.app.serializers import ( + IssueCreateSerializer, + DraftIssueCreateSerializer, + DraftIssueSerializer, + DraftIssueDetailSerializer, +) +from plane.db.models import ( + Issue, + DraftIssue, + CycleIssue, + ModuleIssue, + DraftIssueModule, + DraftIssueCycle, + Workspace, + FileAsset, +) +from .. import BaseViewSet +from plane.bgtasks.issue_activities_task import issue_activity +from plane.utils.issue_filters import issue_filters + + +class WorkspaceDraftIssueViewSet(BaseViewSet): + model = DraftIssue + + def get_queryset(self): + return ( + DraftIssue.objects.filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related( + "assignees", "labels", "draft_issue_module__module" + ) + .annotate(cycle_id=F("draft_issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + draft_issue_cycle__cycle__deleted_at__isnull=True, + then=F("draft_issue_cycle__cycle_id"), + ), + default=None, + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "draft_issue_module__module_id", + distinct=True, + filter=~Q(draft_issue_module__module_id__isnull=True) + & Q( + draft_issue_module__module__archived_at__isnull=True + ) + & Q( + draft_issue_module__module__deleted_at__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + @method_decorator(gzip_page) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + filters = issue_filters(request.query_params, "GET") + issues = ( + self.get_queryset() + .filter(created_by=request.user) + .order_by("-created_at") + ) + + issues = issues.filter(**filters) + # List Paginate + return self.paginate( + request=request, + queryset=(issues), + on_results=lambda issues: DraftIssueSerializer( + issues, + many=True, + ).data, + ) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + + serializer = DraftIssueCreateSerializer( + data=request.data, + context={ + "workspace_id": workspace.id, + "project_id": request.data.get("project_id", None), + }, + ) + if serializer.is_valid(): + serializer.save() + issue = ( + self.get_queryset() + .filter(pk=serializer.data.get("id")) + .values( + "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", + ) + .first() + ) + + return Response(issue, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], + creator=True, + model=Issue, + level="WORKSPACE", + ) + def partial_update(self, request, slug, pk): + issue = ( + self.get_queryset().filter(pk=pk, created_by=request.user).first() + ) + + if not issue: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = DraftIssueCreateSerializer( + issue, + data=request.data, + partial=True, + context={ + "project_id": request.data.get("project_id", None), + "cycle_id": request.data.get("cycle_id", "not_provided"), + }, + ) + + if serializer.is_valid(): + serializer.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission( + allowed_roles=[ROLE.ADMIN], + creator=True, + model=Issue, + level="WORKSPACE", + ) + def retrieve(self, request, slug, pk=None): + issue = ( + self.get_queryset().filter(pk=pk, created_by=request.user).first() + ) + + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = DraftIssueDetailSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[ROLE.ADMIN], + creator=True, + model=DraftIssue, + level="WORKSPACE", + ) + def destroy(self, request, slug, pk=None): + draft_issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk) + draft_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], + level="WORKSPACE", + ) + def create_draft_to_issue(self, request, slug, draft_id): + draft_issue = self.get_queryset().filter(pk=draft_id).first() + + if not draft_issue.project_id: + return Response( + {"error": "Project is required to create an issue."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueCreateSerializer( + data=request.data, + context={ + "project_id": draft_issue.project_id, + "workspace_id": draft_issue.project.workspace_id, + "default_assignee_id": draft_issue.project.default_assignee_id, + }, + ) + + if serializer.is_valid(): + serializer.save() + + 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(draft_issue.project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + if request.data.get("cycle_id", None): + created_records = CycleIssue.objects.create( + cycle_id=request.data.get("cycle_id", None), + issue_id=serializer.data.get("id", None), + project_id=draft_issue.project_id, + workspace_id=draft_issue.workspace_id, + created_by_id=draft_issue.created_by_id, + updated_by_id=draft_issue.updated_by_id, + ) + # Capture Issue Activity + issue_activity.delay( + type="cycle.activity.created", + requested_data=None, + 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": None, + "created_cycle_issues": serializers.serialize( + "json", [created_records] + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + if request.data.get("module_ids", []): + # bulk create the module + ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + module_id=module, + issue_id=serializer.data.get("id", None), + workspace_id=draft_issue.workspace_id, + project_id=draft_issue.project_id, + created_by_id=draft_issue.created_by_id, + updated_by_id=draft_issue.updated_by_id, + ) + for module in request.data.get("module_ids", []) + ], + batch_size=10, + ) + # Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(request.user.id), + issue_id=serializer.data.get("id", None), + project_id=draft_issue.project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for module in request.data.get("module_ids", []) + ] + + # Update file assets + file_assets = FileAsset.objects.filter(draft_issue_id=draft_id) + file_assets.update( + issue_id=serializer.data.get("id", None), + entity_type=FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + draft_issue_id=None, + ) + + # delete the draft issue + draft_issue.delete() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index 0a2f1539f4..c71df21ac4 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -3,7 +3,11 @@ from django.db.models import ( CharField, Count, Q, + OuterRef, + Subquery, + IntegerField, ) +from django.db.models.functions import Coalesce from django.db.models.functions import Cast # Third party modules @@ -34,6 +38,7 @@ from plane.db.models import ( User, Workspace, WorkspaceMember, + DraftIssue, ) from plane.utils.cache import cache_response, invalidate_cache @@ -283,10 +288,26 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView): class WorkspaceMemberUserEndpoint(BaseAPIView): def get(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - member=request.user, - workspace__slug=slug, - is_active=True, + draft_issue_count = ( + DraftIssue.objects.filter( + created_by=request.user, + workspace_id=OuterRef("workspace_id"), + ) + .values("workspace_id") + .annotate(count=Count("id")) + .values("count") + ) + + workspace_member = ( + WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True + ) + .annotate( + draft_issue_count=Coalesce( + Subquery(draft_issue_count, output_field=IntegerField()), 0 + ) + ) + .first() ) serializer = WorkspaceMemberMeSerializer(workspace_member) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 5c173f2021..fae917af29 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -40,7 +40,7 @@ from plane.db.models import ( CycleIssue, Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueLink, IssueSubscriber, Project, @@ -120,7 +120,15 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .filter(**filters) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -128,8 +136,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -359,8 +368,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): "email": user_data.email, "first_name": user_data.first_name, "last_name": user_data.last_name, - "avatar": user_data.avatar, - "cover_image": user_data.cover_image, + "avatar_url": user_data.avatar_url, + "cover_image_url": user_data.cover_image_url, "date_joined": user_data.date_joined, "user_timezone": user_data.user_timezone, "display_name": user_data.display_name, @@ -504,7 +513,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): upcoming_cycles = CycleIssue.objects.filter( workspace__slug=slug, - cycle__start_date__gt=timezone.now().date(), + cycle__start_date__gt=timezone.now(), issue__assignees__in=[ user_id, ], @@ -512,8 +521,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): present_cycle = CycleIssue.objects.filter( workspace__slug=slug, - cycle__start_date__lt=timezone.now().date(), - cycle__end_date__gt=timezone.now().date(), + cycle__start_date__lt=timezone.now(), + cycle__end_date__gt=timezone.now(), issue__assignees__in=[ user_id, ], diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index e6788df79a..7d78b89d06 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -10,6 +10,9 @@ from celery import shared_task from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.db.models import Q, Case, Value, When +from django.db import models +from django.db.models.functions import Concat # Module imports from plane.db.models import Issue @@ -84,12 +87,37 @@ def get_assignee_details(slug, filters): """Fetch assignee details if required.""" return ( Issue.issue_objects.filter( - workspace__slug=slug, **filters, assignees__avatar__isnull=False + Q( + Q(assignees__avatar__isnull=False) + | Q(assignees__avatar_asset__isnull=False) + ), + workspace__slug=slug, + **filters, + ) + .annotate( + assignees__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(), + ) ) .distinct("assignees__id") .order_by("assignees__id") .values( - "assignees__avatar", + "assignees__avatar_url", "assignees__display_name", "assignees__first_name", "assignees__last_name", diff --git a/apiserver/plane/bgtasks/deletion_task.py b/apiserver/plane/bgtasks/deletion_task.py index d8272591a9..b0b2a6eb26 100644 --- a/apiserver/plane/bgtasks/deletion_task.py +++ b/apiserver/plane/bgtasks/deletion_task.py @@ -2,6 +2,7 @@ from django.utils import timezone from django.apps import apps from django.conf import settings +from django.db import models from django.core.exceptions import ObjectDoesNotExist # Third party imports @@ -18,17 +19,25 @@ def soft_delete_related_objects( for field in related_fields: if field.one_to_many or field.one_to_one: try: - if field.one_to_many: - related_objects = getattr(instance, field.name).all() - elif field.one_to_one: - related_object = getattr(instance, field.name) - related_objects = ( - [related_object] if related_object is not None else [] - ) - for obj in related_objects: - if obj: - obj.deleted_at = timezone.now() - obj.save(using=using) + # Check if the field has CASCADE on delete + if ( + not hasattr(field.remote_field, "on_delete") + or field.remote_field.on_delete == models.CASCADE + ): + if field.one_to_many: + related_objects = getattr(instance, field.name).all() + elif field.one_to_one: + related_object = getattr(instance, field.name) + related_objects = ( + [related_object] + if related_object is not None + else [] + ) + + for obj in related_objects: + if obj: + obj.deleted_at = timezone.now() + obj.save(using=using) except ObjectDoesNotExist: pass @@ -154,8 +163,7 @@ def hard_delete(): if hasattr(model, "deleted_at"): # Get all instances where 'deleted_at' is greater than 30 days ago _ = model.all_objects.filter( - deleted_at__lt=timezone.now() - - timezone.timedelta(days=days) + deleted_at__lt=timezone.now() - timezone.timedelta(days=days) ).delete() return diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 11ec91eb4c..f2db0de59c 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -224,7 +224,7 @@ def send_email_notification( { "actor_comments": comment, "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -241,7 +241,7 @@ def send_email_notification( { "actor_comments": mention, "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -257,7 +257,7 @@ def send_email_notification( template_data.append( { "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index e671608d9d..b88aad3271 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -105,7 +105,6 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): ExpiresIn=expires_in, ) else: - # If endpoint url is present, use it if settings.AWS_S3_ENDPOINT_URL: s3 = boto3.client( @@ -129,7 +128,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): zip_file, settings.AWS_STORAGE_BUCKET_NAME, file_name, - ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, + ExtraArgs={"ContentType": "application/zip"}, ) # Generate presigned url for the uploaded file diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py index e372355efb..e05ed6d374 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -1,4 +1,5 @@ # Python imports +import os from datetime import timedelta # Django imports @@ -13,16 +14,14 @@ from plane.db.models import FileAsset @shared_task -def delete_file_asset(): - # file assets to delete - file_assets_to_delete = FileAsset.objects.filter( - Q(is_deleted=True) - & Q(updated_at__lte=timezone.now() - timedelta(days=7)) - ) - - # Delete the file from storage and the file object from the database - for file_asset in file_assets_to_delete: - # Delete the file from storage - file_asset.asset.delete(save=False) - # Delete the file object - file_asset.delete() +def delete_unuploaded_file_asset(): + """This task deletes unuploaded file assets older than a certain number of days.""" + FileAsset.objects.filter( + Q( + created_at__lt=timezone.now() + - timedelta( + days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7")) + ) + ) + & Q(is_uploaded=False) + ).delete() diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index 8ecf7845db..0cee9baef3 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -465,7 +465,7 @@ def track_estimate_points( IssueActivity( issue_id=issue_id, actor_id=actor_id, - verb="updated", + verb="removed" if new_estimate is None else "updated", old_identifier=( current_instance.get("estimate_point") if current_instance.get("estimate_point") is not None @@ -1700,16 +1700,12 @@ def issue_activity( event=( "issue_comment" if activity.field == "comment" - else "inbox_issue" - if inbox - else "issue" + else "inbox_issue" if inbox else "issue" ), event_id=( activity.issue_comment_id if activity.field == "comment" - else inbox - if inbox - else activity.issue_id + else inbox if inbox else activity.issue_id ), verb=activity.verb, field=( diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 8e648c16b0..e7ca16a984 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -42,14 +42,12 @@ def archive_old_issues(): ), Q(issue_cycle__isnull=True) | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False) ), Q(issue_module__isnull=True) | ( - Q( - issue_module__module__target_date__lt=timezone.now().date() - ) + Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False) ), ).filter( @@ -122,14 +120,12 @@ def close_old_issues(): ), Q(issue_cycle__isnull=True) | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now().date()) + Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False) ), Q(issue_module__isnull=True) | ( - Q( - issue_module__module__target_date__lt=timezone.now().date() - ) + Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False) ), ).filter( diff --git a/apiserver/plane/bgtasks/storage_metadata_task.py b/apiserver/plane/bgtasks/storage_metadata_task.py new file mode 100644 index 0000000000..dc52b6ef37 --- /dev/null +++ b/apiserver/plane/bgtasks/storage_metadata_task.py @@ -0,0 +1,28 @@ +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import FileAsset +from plane.settings.storage import S3Storage +from plane.utils.exception_logger import log_exception + + +@shared_task +def get_asset_object_metadata(asset_id): + try: + # Get the asset + asset = FileAsset.objects.get(pk=asset_id) + # Create an instance of the S3 storage + storage = S3Storage() + # Get the storage + asset.storage_metadata = storage.get_object_metadata( + object_name=asset.asset.name + ) + # Save the asset + asset.save() + return + except FileAsset.DoesNotExist: + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 4d65125568..865ef95fae 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -25,7 +25,7 @@ app.conf.beat_schedule = { "schedule": crontab(hour=0, minute=0), }, "check-every-day-to-delete-file-asset": { - "task": "plane.bgtasks.file_asset_task.delete_file_asset", + "task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset", "schedule": crontab(hour=0, minute=0), }, "check-every-five-minutes-to-send-email-notifications": { @@ -40,6 +40,10 @@ app.conf.beat_schedule = { "task": "plane.bgtasks.deletion_task.hard_delete", "schedule": crontab(hour=0, minute=0), }, + "run-every-6-hours-for-instance-trace": { + "task": "plane.license.bgtasks.tracer.instance_traces", + "schedule": crontab(hour="*/6"), + }, } # Load task modules from all registered Django app configs. diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py index bdd0b7014d..9313b6b1c9 100644 --- a/apiserver/plane/db/management/commands/create_bucket.py +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -1,67 +1,45 @@ # Python imports +import os import boto3 -import json from botocore.exceptions import ClientError # Django imports from django.core.management import BaseCommand -from django.conf import settings class Command(BaseCommand): help = "Create the default bucket for the instance" - def set_bucket_public_policy(self, s3_client, bucket_name): - public_policy = { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"], - } - ], - } - - try: - s3_client.put_bucket_policy( - Bucket=bucket_name, Policy=json.dumps(public_policy) - ) - self.stdout.write( - self.style.SUCCESS( - f"Public read access policy set for bucket '{bucket_name}'." - ) - ) - except ClientError as e: - self.stdout.write( - self.style.ERROR( - f"Error setting public read access policy: {e}" - ) - ) - def handle(self, *args, **options): # Create a session using the credentials from Django settings try: - session = boto3.session.Session( - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + s3_client = boto3.client( + "s3", + endpoint_url=os.environ.get( + "AWS_S3_ENDPOINT_URL" + ), # MinIO endpoint + aws_access_key_id=os.environ.get( + "AWS_ACCESS_KEY_ID" + ), # MinIO access key + aws_secret_access_key=os.environ.get( + "AWS_SECRET_ACCESS_KEY" + ), # MinIO secret key + region_name=os.environ.get("AWS_REGION"), # MinIO region + config=boto3.session.Config(signature_version="s3v4"), ) - # Create an S3 client using the session - s3_client = session.client( - "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL - ) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - + # Get the bucket name from the environment + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") self.stdout.write(self.style.NOTICE("Checking bucket...")) - # Check if the bucket exists s3_client.head_bucket(Bucket=bucket_name) - - self.set_bucket_public_policy(s3_client, bucket_name) + # If the bucket exists, print a success message + self.stdout.write( + self.style.SUCCESS(f"Bucket '{bucket_name}' exists.") + ) + return except ClientError as e: error_code = int(e.response["Error"]["Code"]) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") if error_code == 404: # Bucket does not exist, create it self.stdout.write( @@ -76,13 +54,16 @@ class Command(BaseCommand): f"Bucket '{bucket_name}' created successfully." ) ) - self.set_bucket_public_policy(s3_client, bucket_name) + + # Handle the exception if the bucket creation fails except ClientError as create_error: self.stdout.write( self.style.ERROR( f"Failed to create bucket: {create_error}" ) ) + + # Handle the exception if access to the bucket is forbidden elif error_code == 403: # Access to the bucket is forbidden self.stdout.write( diff --git a/apiserver/plane/db/management/commands/update_bucket.py b/apiserver/plane/db/management/commands/update_bucket.py new file mode 100644 index 0000000000..96027ac26d --- /dev/null +++ b/apiserver/plane/db/management/commands/update_bucket.py @@ -0,0 +1,209 @@ +# Python imports +import os +import boto3 +from botocore.exceptions import ClientError +import json + +# Django imports +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def get_s3_client(self): + s3_client = boto3.client( + "s3", + endpoint_url=os.environ.get( + "AWS_S3_ENDPOINT_URL" + ), # MinIO endpoint + aws_access_key_id=os.environ.get( + "AWS_ACCESS_KEY_ID" + ), # MinIO access key + aws_secret_access_key=os.environ.get( + "AWS_SECRET_ACCESS_KEY" + ), # MinIO secret key + region_name=os.environ.get("AWS_REGION"), # MinIO region + config=boto3.session.Config(signature_version="s3v4"), + ) + return s3_client + + # Check if the access key has the required permissions + def check_s3_permissions(self, bucket_name): + s3_client = self.get_s3_client() + permissions = { + "s3:GetObject": False, + "s3:ListBucket": False, + "s3:PutBucketPolicy": False, + "s3:PutObject": False, + } + + # 1. Test s3:ListBucket (attempt to list the bucket contents) + try: + s3_client.list_objects_v2(Bucket=bucket_name) + permissions["s3:ListBucket"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("ListBucket permission denied.") + else: + self.stdout.write(f"Error in ListBucket: {e}") + + # 2. Test s3:GetObject (attempt to get a specific object) + try: + response = s3_client.list_objects_v2(Bucket=bucket_name) + if "Contents" in response: + test_object_key = response["Contents"][0]["Key"] + s3_client.get_object(Bucket=bucket_name, Key=test_object_key) + permissions["s3:GetObject"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("GetObject permission denied.") + else: + self.stdout.write(f"Error in GetObject: {e}") + + # 3. Test s3:PutObject (attempt to upload an object) + try: + s3_client.put_object( + Bucket=bucket_name, + Key="test_permission_check.txt", + Body=b"Test", + ) + self.stdout.write("PutObject permission granted.") + permissions["s3:PutObject"] = True + # Clean up + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("PutObject permission denied.") + else: + self.stdout.write(f"Error in PutObject: {e}") + + # Clean up + try: + s3_client.delete_object( + Bucket=bucket_name, Key="test_permission_check.txt" + ) + except ClientError: + self.stdout.write("Coudn't delete test object") + + # 4. Test s3:PutBucketPolicy (attempt to put a bucket policy) + try: + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": f"arn:aws:s3:::{bucket_name}/*", + } + ], + } + s3_client.put_bucket_policy( + Bucket=bucket_name, Policy=json.dumps(policy) + ) + permissions["s3:PutBucketPolicy"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("PutBucketPolicy permission denied.") + else: + self.stdout.write(f"Error in PutBucketPolicy: {e}") + + return permissions + + def generate_bucket_policy(self, bucket_name): + s3_client = self.get_s3_client() + response = s3_client.list_objects_v2(Bucket=bucket_name) + public_object_resource = [] + if "Contents" in response: + for obj in response["Contents"]: + object_key = obj["Key"] + public_object_resource.append( + f"arn:aws:s3:::{bucket_name}/{object_key}" + ) + bucket_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": public_object_resource, + } + ], + } + return bucket_policy + + def make_objects_public(self, bucket_name): + # Initialize S3 client + s3_client = self.get_s3_client() + # Get the bucket policy + bucket_policy = self.generate_bucket_policy(bucket_name) + # Apply the policy to the bucket + s3_client.put_bucket_policy( + Bucket=bucket_name, Policy=json.dumps(bucket_policy) + ) + # Print a success message + self.stdout.write( + "Bucket is private, but existing objects remain public." + ) + return + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + try: + # Check if the bucket exists + s3_client = self.get_s3_client() + # Get the bucket name from the environment + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") + self.stdout.write(self.style.NOTICE("Checking bucket...")) + # Check if the bucket exists + s3_client.head_bucket(Bucket=bucket_name) + + # If the bucket exists, print a success message + self.stdout.write( + self.style.SUCCESS(f"Bucket '{bucket_name}' exists.") + ) + + # Check the permissions of the access key + permissions = self.check_s3_permissions(bucket_name) + + if all(permissions.values()): + self.stdout.write( + self.style.SUCCESS( + "Access key has the required permissions." + ) + ) + # Making the existing objects public + self.make_objects_public(bucket_name) + + # If the access key does not have PutBucketPolicy permission + # write the bucket policy to a file + if ( + all( + { + k: v + for k, v in permissions.items() + if k != "s3:PutBucketPolicy" + }.values() + ) + and not permissions["s3:PutBucketPolicy"] + ): + self.stdout.write( + self.style.WARNING( + "Access key does not have PutBucketPolicy permission." + ) + ) + # Writing to a file + with open("permissions.json", "w") as f: + f.write( + json.dumps(self.generate_bucket_policy(bucket_name)) + ) + self.stdout.write( + self.style.WARNING( + "Permissions have been written to permissions.json." + ) + ) + return + except Exception as ex: + # Handle any other exception + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) diff --git a/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py b/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py new file mode 100644 index 0000000000..ee70f66152 --- /dev/null +++ b/apiserver/plane/db/migrations/0077_draftissue_cycle_user_timezone_project_user_timezone_and_more.py @@ -0,0 +1,2036 @@ +# Generated by Django 4.2.15 on 2024-09-24 08:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid +from django.db.models import Prefetch + + +def migrate_draft_issues(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + DraftIssue = apps.get_model("db", "DraftIssue") + IssueAssignee = apps.get_model("db", "IssueAssignee") + DraftIssueAssignee = apps.get_model("db", "DraftIssueAssignee") + IssueLabel = apps.get_model("db", "IssueLabel") + DraftIssueLabel = apps.get_model("db", "DraftIssueLabel") + ModuleIssue = apps.get_model("db", "ModuleIssue") + DraftIssueModule = apps.get_model("db", "DraftIssueModule") + DraftIssueCycle = apps.get_model("db", "DraftIssueCycle") + + # Fetch all draft issues with their related assignees and labels + issues = ( + Issue.objects.filter(is_draft=True) + .select_related("issue_cycle__cycle") + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.select_related("assignee"), + ), + Prefetch( + "label_issue", + queryset=IssueLabel.objects.select_related("label"), + ), + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.select_related("module"), + ), + ) + ) + + draft_issues = [] + draft_issue_cycle = [] + draft_issue_labels = [] + draft_issue_modules = [] + draft_issue_assignees = [] + # issue_ids_to_delete = [] + + for issue in issues: + draft_issue = DraftIssue( + parent_id=issue.parent_id, + state_id=issue.state_id, + estimate_point_id=issue.estimate_point_id, + name=issue.name, + description=issue.description, + description_html=issue.description_html, + description_stripped=issue.description_stripped, + description_binary=issue.description_binary, + priority=issue.priority, + start_date=issue.start_date, + target_date=issue.target_date, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + created_by_id=issue.created_by_id, + updated_by_id=issue.updated_by_id, + ) + draft_issues.append(draft_issue) + + for assignee in issue.issue_assignee.all(): + draft_issue_assignees.append( + DraftIssueAssignee( + draft_issue=draft_issue, + assignee=assignee.assignee, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + # Prepare labels for bulk insert + for label in issue.label_issue.all(): + draft_issue_labels.append( + DraftIssueLabel( + draft_issue=draft_issue, + label=label.label, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + for module_issue in issue.issue_module.all(): + draft_issue_modules.append( + DraftIssueModule( + draft_issue=draft_issue, + module=module_issue.module, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + if hasattr(issue, "issue_cycle") and issue.issue_cycle: + draft_issue_cycle.append( + DraftIssueCycle( + draft_issue=draft_issue, + cycle=issue.issue_cycle.cycle, + workspace_id=issue.workspace_id, + project_id=issue.project_id, + ) + ) + + # issue_ids_to_delete.append(issue.id) + + # Bulk create draft issues + DraftIssue.objects.bulk_create(draft_issues) + + # Bulk create draft assignees and labels + DraftIssueLabel.objects.bulk_create(draft_issue_labels) + DraftIssueAssignee.objects.bulk_create(draft_issue_assignees) + + # Bulk create draft modules + DraftIssueCycle.objects.bulk_create(draft_issue_cycle) + DraftIssueModule.objects.bulk_create(draft_issue_modules) + + # Delete original issues + # Issue.objects.filter(id__in=issue_ids_to_delete).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0076_alter_projectmember_role_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DraftIssue", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "name", + models.CharField( + blank=True, + max_length=255, + null=True, + 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( + choices=[ + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ], + default="none", + max_length=30, + verbose_name="Issue Priority", + ), + ), + ("start_date", models.DateField(blank=True, null=True)), + ("target_date", models.DateField(blank=True, null=True)), + ("sort_order", models.FloatField(default=65535)), + ("completed_at", models.DateTimeField(null=True)), + ( + "external_source", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "external_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ], + options={ + "verbose_name": "DraftIssue", + "verbose_name_plural": "DraftIssues", + "db_table": "draft_issues", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="cycle", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AddField( + model_name="project", + name="timezone", + field=models.CharField( + choices=[ + ("Africa/Abidjan", "Africa/Abidjan"), + ("Africa/Accra", "Africa/Accra"), + ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), + ("Africa/Algiers", "Africa/Algiers"), + ("Africa/Asmara", "Africa/Asmara"), + ("Africa/Asmera", "Africa/Asmera"), + ("Africa/Bamako", "Africa/Bamako"), + ("Africa/Bangui", "Africa/Bangui"), + ("Africa/Banjul", "Africa/Banjul"), + ("Africa/Bissau", "Africa/Bissau"), + ("Africa/Blantyre", "Africa/Blantyre"), + ("Africa/Brazzaville", "Africa/Brazzaville"), + ("Africa/Bujumbura", "Africa/Bujumbura"), + ("Africa/Cairo", "Africa/Cairo"), + ("Africa/Casablanca", "Africa/Casablanca"), + ("Africa/Ceuta", "Africa/Ceuta"), + ("Africa/Conakry", "Africa/Conakry"), + ("Africa/Dakar", "Africa/Dakar"), + ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), + ("Africa/Djibouti", "Africa/Djibouti"), + ("Africa/Douala", "Africa/Douala"), + ("Africa/El_Aaiun", "Africa/El_Aaiun"), + ("Africa/Freetown", "Africa/Freetown"), + ("Africa/Gaborone", "Africa/Gaborone"), + ("Africa/Harare", "Africa/Harare"), + ("Africa/Johannesburg", "Africa/Johannesburg"), + ("Africa/Juba", "Africa/Juba"), + ("Africa/Kampala", "Africa/Kampala"), + ("Africa/Khartoum", "Africa/Khartoum"), + ("Africa/Kigali", "Africa/Kigali"), + ("Africa/Kinshasa", "Africa/Kinshasa"), + ("Africa/Lagos", "Africa/Lagos"), + ("Africa/Libreville", "Africa/Libreville"), + ("Africa/Lome", "Africa/Lome"), + ("Africa/Luanda", "Africa/Luanda"), + ("Africa/Lubumbashi", "Africa/Lubumbashi"), + ("Africa/Lusaka", "Africa/Lusaka"), + ("Africa/Malabo", "Africa/Malabo"), + ("Africa/Maputo", "Africa/Maputo"), + ("Africa/Maseru", "Africa/Maseru"), + ("Africa/Mbabane", "Africa/Mbabane"), + ("Africa/Mogadishu", "Africa/Mogadishu"), + ("Africa/Monrovia", "Africa/Monrovia"), + ("Africa/Nairobi", "Africa/Nairobi"), + ("Africa/Ndjamena", "Africa/Ndjamena"), + ("Africa/Niamey", "Africa/Niamey"), + ("Africa/Nouakchott", "Africa/Nouakchott"), + ("Africa/Ouagadougou", "Africa/Ouagadougou"), + ("Africa/Porto-Novo", "Africa/Porto-Novo"), + ("Africa/Sao_Tome", "Africa/Sao_Tome"), + ("Africa/Timbuktu", "Africa/Timbuktu"), + ("Africa/Tripoli", "Africa/Tripoli"), + ("Africa/Tunis", "Africa/Tunis"), + ("Africa/Windhoek", "Africa/Windhoek"), + ("America/Adak", "America/Adak"), + ("America/Anchorage", "America/Anchorage"), + ("America/Anguilla", "America/Anguilla"), + ("America/Antigua", "America/Antigua"), + ("America/Araguaina", "America/Araguaina"), + ( + "America/Argentina/Buenos_Aires", + "America/Argentina/Buenos_Aires", + ), + ( + "America/Argentina/Catamarca", + "America/Argentina/Catamarca", + ), + ( + "America/Argentina/ComodRivadavia", + "America/Argentina/ComodRivadavia", + ), + ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), + ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), + ( + "America/Argentina/La_Rioja", + "America/Argentina/La_Rioja", + ), + ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), + ( + "America/Argentina/Rio_Gallegos", + "America/Argentina/Rio_Gallegos", + ), + ("America/Argentina/Salta", "America/Argentina/Salta"), + ( + "America/Argentina/San_Juan", + "America/Argentina/San_Juan", + ), + ( + "America/Argentina/San_Luis", + "America/Argentina/San_Luis", + ), + ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), + ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), + ("America/Aruba", "America/Aruba"), + ("America/Asuncion", "America/Asuncion"), + ("America/Atikokan", "America/Atikokan"), + ("America/Atka", "America/Atka"), + ("America/Bahia", "America/Bahia"), + ("America/Bahia_Banderas", "America/Bahia_Banderas"), + ("America/Barbados", "America/Barbados"), + ("America/Belem", "America/Belem"), + ("America/Belize", "America/Belize"), + ("America/Blanc-Sablon", "America/Blanc-Sablon"), + ("America/Boa_Vista", "America/Boa_Vista"), + ("America/Bogota", "America/Bogota"), + ("America/Boise", "America/Boise"), + ("America/Buenos_Aires", "America/Buenos_Aires"), + ("America/Cambridge_Bay", "America/Cambridge_Bay"), + ("America/Campo_Grande", "America/Campo_Grande"), + ("America/Cancun", "America/Cancun"), + ("America/Caracas", "America/Caracas"), + ("America/Catamarca", "America/Catamarca"), + ("America/Cayenne", "America/Cayenne"), + ("America/Cayman", "America/Cayman"), + ("America/Chicago", "America/Chicago"), + ("America/Chihuahua", "America/Chihuahua"), + ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), + ("America/Coral_Harbour", "America/Coral_Harbour"), + ("America/Cordoba", "America/Cordoba"), + ("America/Costa_Rica", "America/Costa_Rica"), + ("America/Creston", "America/Creston"), + ("America/Cuiaba", "America/Cuiaba"), + ("America/Curacao", "America/Curacao"), + ("America/Danmarkshavn", "America/Danmarkshavn"), + ("America/Dawson", "America/Dawson"), + ("America/Dawson_Creek", "America/Dawson_Creek"), + ("America/Denver", "America/Denver"), + ("America/Detroit", "America/Detroit"), + ("America/Dominica", "America/Dominica"), + ("America/Edmonton", "America/Edmonton"), + ("America/Eirunepe", "America/Eirunepe"), + ("America/El_Salvador", "America/El_Salvador"), + ("America/Ensenada", "America/Ensenada"), + ("America/Fort_Nelson", "America/Fort_Nelson"), + ("America/Fort_Wayne", "America/Fort_Wayne"), + ("America/Fortaleza", "America/Fortaleza"), + ("America/Glace_Bay", "America/Glace_Bay"), + ("America/Godthab", "America/Godthab"), + ("America/Goose_Bay", "America/Goose_Bay"), + ("America/Grand_Turk", "America/Grand_Turk"), + ("America/Grenada", "America/Grenada"), + ("America/Guadeloupe", "America/Guadeloupe"), + ("America/Guatemala", "America/Guatemala"), + ("America/Guayaquil", "America/Guayaquil"), + ("America/Guyana", "America/Guyana"), + ("America/Halifax", "America/Halifax"), + ("America/Havana", "America/Havana"), + ("America/Hermosillo", "America/Hermosillo"), + ( + "America/Indiana/Indianapolis", + "America/Indiana/Indianapolis", + ), + ("America/Indiana/Knox", "America/Indiana/Knox"), + ("America/Indiana/Marengo", "America/Indiana/Marengo"), + ( + "America/Indiana/Petersburg", + "America/Indiana/Petersburg", + ), + ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), + ("America/Indiana/Vevay", "America/Indiana/Vevay"), + ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), + ("America/Indiana/Winamac", "America/Indiana/Winamac"), + ("America/Indianapolis", "America/Indianapolis"), + ("America/Inuvik", "America/Inuvik"), + ("America/Iqaluit", "America/Iqaluit"), + ("America/Jamaica", "America/Jamaica"), + ("America/Jujuy", "America/Jujuy"), + ("America/Juneau", "America/Juneau"), + ( + "America/Kentucky/Louisville", + "America/Kentucky/Louisville", + ), + ( + "America/Kentucky/Monticello", + "America/Kentucky/Monticello", + ), + ("America/Knox_IN", "America/Knox_IN"), + ("America/Kralendijk", "America/Kralendijk"), + ("America/La_Paz", "America/La_Paz"), + ("America/Lima", "America/Lima"), + ("America/Los_Angeles", "America/Los_Angeles"), + ("America/Louisville", "America/Louisville"), + ("America/Lower_Princes", "America/Lower_Princes"), + ("America/Maceio", "America/Maceio"), + ("America/Managua", "America/Managua"), + ("America/Manaus", "America/Manaus"), + ("America/Marigot", "America/Marigot"), + ("America/Martinique", "America/Martinique"), + ("America/Matamoros", "America/Matamoros"), + ("America/Mazatlan", "America/Mazatlan"), + ("America/Mendoza", "America/Mendoza"), + ("America/Menominee", "America/Menominee"), + ("America/Merida", "America/Merida"), + ("America/Metlakatla", "America/Metlakatla"), + ("America/Mexico_City", "America/Mexico_City"), + ("America/Miquelon", "America/Miquelon"), + ("America/Moncton", "America/Moncton"), + ("America/Monterrey", "America/Monterrey"), + ("America/Montevideo", "America/Montevideo"), + ("America/Montreal", "America/Montreal"), + ("America/Montserrat", "America/Montserrat"), + ("America/Nassau", "America/Nassau"), + ("America/New_York", "America/New_York"), + ("America/Nipigon", "America/Nipigon"), + ("America/Nome", "America/Nome"), + ("America/Noronha", "America/Noronha"), + ( + "America/North_Dakota/Beulah", + "America/North_Dakota/Beulah", + ), + ( + "America/North_Dakota/Center", + "America/North_Dakota/Center", + ), + ( + "America/North_Dakota/New_Salem", + "America/North_Dakota/New_Salem", + ), + ("America/Nuuk", "America/Nuuk"), + ("America/Ojinaga", "America/Ojinaga"), + ("America/Panama", "America/Panama"), + ("America/Pangnirtung", "America/Pangnirtung"), + ("America/Paramaribo", "America/Paramaribo"), + ("America/Phoenix", "America/Phoenix"), + ("America/Port-au-Prince", "America/Port-au-Prince"), + ("America/Port_of_Spain", "America/Port_of_Spain"), + ("America/Porto_Acre", "America/Porto_Acre"), + ("America/Porto_Velho", "America/Porto_Velho"), + ("America/Puerto_Rico", "America/Puerto_Rico"), + ("America/Punta_Arenas", "America/Punta_Arenas"), + ("America/Rainy_River", "America/Rainy_River"), + ("America/Rankin_Inlet", "America/Rankin_Inlet"), + ("America/Recife", "America/Recife"), + ("America/Regina", "America/Regina"), + ("America/Resolute", "America/Resolute"), + ("America/Rio_Branco", "America/Rio_Branco"), + ("America/Rosario", "America/Rosario"), + ("America/Santa_Isabel", "America/Santa_Isabel"), + ("America/Santarem", "America/Santarem"), + ("America/Santiago", "America/Santiago"), + ("America/Santo_Domingo", "America/Santo_Domingo"), + ("America/Sao_Paulo", "America/Sao_Paulo"), + ("America/Scoresbysund", "America/Scoresbysund"), + ("America/Shiprock", "America/Shiprock"), + ("America/Sitka", "America/Sitka"), + ("America/St_Barthelemy", "America/St_Barthelemy"), + ("America/St_Johns", "America/St_Johns"), + ("America/St_Kitts", "America/St_Kitts"), + ("America/St_Lucia", "America/St_Lucia"), + ("America/St_Thomas", "America/St_Thomas"), + ("America/St_Vincent", "America/St_Vincent"), + ("America/Swift_Current", "America/Swift_Current"), + ("America/Tegucigalpa", "America/Tegucigalpa"), + ("America/Thule", "America/Thule"), + ("America/Thunder_Bay", "America/Thunder_Bay"), + ("America/Tijuana", "America/Tijuana"), + ("America/Toronto", "America/Toronto"), + ("America/Tortola", "America/Tortola"), + ("America/Vancouver", "America/Vancouver"), + ("America/Virgin", "America/Virgin"), + ("America/Whitehorse", "America/Whitehorse"), + ("America/Winnipeg", "America/Winnipeg"), + ("America/Yakutat", "America/Yakutat"), + ("America/Yellowknife", "America/Yellowknife"), + ("Antarctica/Casey", "Antarctica/Casey"), + ("Antarctica/Davis", "Antarctica/Davis"), + ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), + ("Antarctica/Macquarie", "Antarctica/Macquarie"), + ("Antarctica/Mawson", "Antarctica/Mawson"), + ("Antarctica/McMurdo", "Antarctica/McMurdo"), + ("Antarctica/Palmer", "Antarctica/Palmer"), + ("Antarctica/Rothera", "Antarctica/Rothera"), + ("Antarctica/South_Pole", "Antarctica/South_Pole"), + ("Antarctica/Syowa", "Antarctica/Syowa"), + ("Antarctica/Troll", "Antarctica/Troll"), + ("Antarctica/Vostok", "Antarctica/Vostok"), + ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), + ("Asia/Aden", "Asia/Aden"), + ("Asia/Almaty", "Asia/Almaty"), + ("Asia/Amman", "Asia/Amman"), + ("Asia/Anadyr", "Asia/Anadyr"), + ("Asia/Aqtau", "Asia/Aqtau"), + ("Asia/Aqtobe", "Asia/Aqtobe"), + ("Asia/Ashgabat", "Asia/Ashgabat"), + ("Asia/Ashkhabad", "Asia/Ashkhabad"), + ("Asia/Atyrau", "Asia/Atyrau"), + ("Asia/Baghdad", "Asia/Baghdad"), + ("Asia/Bahrain", "Asia/Bahrain"), + ("Asia/Baku", "Asia/Baku"), + ("Asia/Bangkok", "Asia/Bangkok"), + ("Asia/Barnaul", "Asia/Barnaul"), + ("Asia/Beirut", "Asia/Beirut"), + ("Asia/Bishkek", "Asia/Bishkek"), + ("Asia/Brunei", "Asia/Brunei"), + ("Asia/Calcutta", "Asia/Calcutta"), + ("Asia/Chita", "Asia/Chita"), + ("Asia/Choibalsan", "Asia/Choibalsan"), + ("Asia/Chongqing", "Asia/Chongqing"), + ("Asia/Chungking", "Asia/Chungking"), + ("Asia/Colombo", "Asia/Colombo"), + ("Asia/Dacca", "Asia/Dacca"), + ("Asia/Damascus", "Asia/Damascus"), + ("Asia/Dhaka", "Asia/Dhaka"), + ("Asia/Dili", "Asia/Dili"), + ("Asia/Dubai", "Asia/Dubai"), + ("Asia/Dushanbe", "Asia/Dushanbe"), + ("Asia/Famagusta", "Asia/Famagusta"), + ("Asia/Gaza", "Asia/Gaza"), + ("Asia/Harbin", "Asia/Harbin"), + ("Asia/Hebron", "Asia/Hebron"), + ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), + ("Asia/Hong_Kong", "Asia/Hong_Kong"), + ("Asia/Hovd", "Asia/Hovd"), + ("Asia/Irkutsk", "Asia/Irkutsk"), + ("Asia/Istanbul", "Asia/Istanbul"), + ("Asia/Jakarta", "Asia/Jakarta"), + ("Asia/Jayapura", "Asia/Jayapura"), + ("Asia/Jerusalem", "Asia/Jerusalem"), + ("Asia/Kabul", "Asia/Kabul"), + ("Asia/Kamchatka", "Asia/Kamchatka"), + ("Asia/Karachi", "Asia/Karachi"), + ("Asia/Kashgar", "Asia/Kashgar"), + ("Asia/Kathmandu", "Asia/Kathmandu"), + ("Asia/Katmandu", "Asia/Katmandu"), + ("Asia/Khandyga", "Asia/Khandyga"), + ("Asia/Kolkata", "Asia/Kolkata"), + ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), + ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), + ("Asia/Kuching", "Asia/Kuching"), + ("Asia/Kuwait", "Asia/Kuwait"), + ("Asia/Macao", "Asia/Macao"), + ("Asia/Macau", "Asia/Macau"), + ("Asia/Magadan", "Asia/Magadan"), + ("Asia/Makassar", "Asia/Makassar"), + ("Asia/Manila", "Asia/Manila"), + ("Asia/Muscat", "Asia/Muscat"), + ("Asia/Nicosia", "Asia/Nicosia"), + ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), + ("Asia/Novosibirsk", "Asia/Novosibirsk"), + ("Asia/Omsk", "Asia/Omsk"), + ("Asia/Oral", "Asia/Oral"), + ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), + ("Asia/Pontianak", "Asia/Pontianak"), + ("Asia/Pyongyang", "Asia/Pyongyang"), + ("Asia/Qatar", "Asia/Qatar"), + ("Asia/Qostanay", "Asia/Qostanay"), + ("Asia/Qyzylorda", "Asia/Qyzylorda"), + ("Asia/Rangoon", "Asia/Rangoon"), + ("Asia/Riyadh", "Asia/Riyadh"), + ("Asia/Saigon", "Asia/Saigon"), + ("Asia/Sakhalin", "Asia/Sakhalin"), + ("Asia/Samarkand", "Asia/Samarkand"), + ("Asia/Seoul", "Asia/Seoul"), + ("Asia/Shanghai", "Asia/Shanghai"), + ("Asia/Singapore", "Asia/Singapore"), + ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), + ("Asia/Taipei", "Asia/Taipei"), + ("Asia/Tashkent", "Asia/Tashkent"), + ("Asia/Tbilisi", "Asia/Tbilisi"), + ("Asia/Tehran", "Asia/Tehran"), + ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), + ("Asia/Thimbu", "Asia/Thimbu"), + ("Asia/Thimphu", "Asia/Thimphu"), + ("Asia/Tokyo", "Asia/Tokyo"), + ("Asia/Tomsk", "Asia/Tomsk"), + ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), + ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), + ("Asia/Urumqi", "Asia/Urumqi"), + ("Asia/Ust-Nera", "Asia/Ust-Nera"), + ("Asia/Vientiane", "Asia/Vientiane"), + ("Asia/Vladivostok", "Asia/Vladivostok"), + ("Asia/Yakutsk", "Asia/Yakutsk"), + ("Asia/Yangon", "Asia/Yangon"), + ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), + ("Asia/Yerevan", "Asia/Yerevan"), + ("Atlantic/Azores", "Atlantic/Azores"), + ("Atlantic/Bermuda", "Atlantic/Bermuda"), + ("Atlantic/Canary", "Atlantic/Canary"), + ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), + ("Atlantic/Faeroe", "Atlantic/Faeroe"), + ("Atlantic/Faroe", "Atlantic/Faroe"), + ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), + ("Atlantic/Madeira", "Atlantic/Madeira"), + ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), + ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), + ("Atlantic/St_Helena", "Atlantic/St_Helena"), + ("Atlantic/Stanley", "Atlantic/Stanley"), + ("Australia/ACT", "Australia/ACT"), + ("Australia/Adelaide", "Australia/Adelaide"), + ("Australia/Brisbane", "Australia/Brisbane"), + ("Australia/Broken_Hill", "Australia/Broken_Hill"), + ("Australia/Canberra", "Australia/Canberra"), + ("Australia/Currie", "Australia/Currie"), + ("Australia/Darwin", "Australia/Darwin"), + ("Australia/Eucla", "Australia/Eucla"), + ("Australia/Hobart", "Australia/Hobart"), + ("Australia/LHI", "Australia/LHI"), + ("Australia/Lindeman", "Australia/Lindeman"), + ("Australia/Lord_Howe", "Australia/Lord_Howe"), + ("Australia/Melbourne", "Australia/Melbourne"), + ("Australia/NSW", "Australia/NSW"), + ("Australia/North", "Australia/North"), + ("Australia/Perth", "Australia/Perth"), + ("Australia/Queensland", "Australia/Queensland"), + ("Australia/South", "Australia/South"), + ("Australia/Sydney", "Australia/Sydney"), + ("Australia/Tasmania", "Australia/Tasmania"), + ("Australia/Victoria", "Australia/Victoria"), + ("Australia/West", "Australia/West"), + ("Australia/Yancowinna", "Australia/Yancowinna"), + ("Brazil/Acre", "Brazil/Acre"), + ("Brazil/DeNoronha", "Brazil/DeNoronha"), + ("Brazil/East", "Brazil/East"), + ("Brazil/West", "Brazil/West"), + ("CET", "CET"), + ("CST6CDT", "CST6CDT"), + ("Canada/Atlantic", "Canada/Atlantic"), + ("Canada/Central", "Canada/Central"), + ("Canada/Eastern", "Canada/Eastern"), + ("Canada/Mountain", "Canada/Mountain"), + ("Canada/Newfoundland", "Canada/Newfoundland"), + ("Canada/Pacific", "Canada/Pacific"), + ("Canada/Saskatchewan", "Canada/Saskatchewan"), + ("Canada/Yukon", "Canada/Yukon"), + ("Chile/Continental", "Chile/Continental"), + ("Chile/EasterIsland", "Chile/EasterIsland"), + ("Cuba", "Cuba"), + ("EET", "EET"), + ("EST", "EST"), + ("EST5EDT", "EST5EDT"), + ("Egypt", "Egypt"), + ("Eire", "Eire"), + ("Etc/GMT", "Etc/GMT"), + ("Etc/GMT+0", "Etc/GMT+0"), + ("Etc/GMT+1", "Etc/GMT+1"), + ("Etc/GMT+10", "Etc/GMT+10"), + ("Etc/GMT+11", "Etc/GMT+11"), + ("Etc/GMT+12", "Etc/GMT+12"), + ("Etc/GMT+2", "Etc/GMT+2"), + ("Etc/GMT+3", "Etc/GMT+3"), + ("Etc/GMT+4", "Etc/GMT+4"), + ("Etc/GMT+5", "Etc/GMT+5"), + ("Etc/GMT+6", "Etc/GMT+6"), + ("Etc/GMT+7", "Etc/GMT+7"), + ("Etc/GMT+8", "Etc/GMT+8"), + ("Etc/GMT+9", "Etc/GMT+9"), + ("Etc/GMT-0", "Etc/GMT-0"), + ("Etc/GMT-1", "Etc/GMT-1"), + ("Etc/GMT-10", "Etc/GMT-10"), + ("Etc/GMT-11", "Etc/GMT-11"), + ("Etc/GMT-12", "Etc/GMT-12"), + ("Etc/GMT-13", "Etc/GMT-13"), + ("Etc/GMT-14", "Etc/GMT-14"), + ("Etc/GMT-2", "Etc/GMT-2"), + ("Etc/GMT-3", "Etc/GMT-3"), + ("Etc/GMT-4", "Etc/GMT-4"), + ("Etc/GMT-5", "Etc/GMT-5"), + ("Etc/GMT-6", "Etc/GMT-6"), + ("Etc/GMT-7", "Etc/GMT-7"), + ("Etc/GMT-8", "Etc/GMT-8"), + ("Etc/GMT-9", "Etc/GMT-9"), + ("Etc/GMT0", "Etc/GMT0"), + ("Etc/Greenwich", "Etc/Greenwich"), + ("Etc/UCT", "Etc/UCT"), + ("Etc/UTC", "Etc/UTC"), + ("Etc/Universal", "Etc/Universal"), + ("Etc/Zulu", "Etc/Zulu"), + ("Europe/Amsterdam", "Europe/Amsterdam"), + ("Europe/Andorra", "Europe/Andorra"), + ("Europe/Astrakhan", "Europe/Astrakhan"), + ("Europe/Athens", "Europe/Athens"), + ("Europe/Belfast", "Europe/Belfast"), + ("Europe/Belgrade", "Europe/Belgrade"), + ("Europe/Berlin", "Europe/Berlin"), + ("Europe/Bratislava", "Europe/Bratislava"), + ("Europe/Brussels", "Europe/Brussels"), + ("Europe/Bucharest", "Europe/Bucharest"), + ("Europe/Budapest", "Europe/Budapest"), + ("Europe/Busingen", "Europe/Busingen"), + ("Europe/Chisinau", "Europe/Chisinau"), + ("Europe/Copenhagen", "Europe/Copenhagen"), + ("Europe/Dublin", "Europe/Dublin"), + ("Europe/Gibraltar", "Europe/Gibraltar"), + ("Europe/Guernsey", "Europe/Guernsey"), + ("Europe/Helsinki", "Europe/Helsinki"), + ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), + ("Europe/Istanbul", "Europe/Istanbul"), + ("Europe/Jersey", "Europe/Jersey"), + ("Europe/Kaliningrad", "Europe/Kaliningrad"), + ("Europe/Kiev", "Europe/Kiev"), + ("Europe/Kirov", "Europe/Kirov"), + ("Europe/Kyiv", "Europe/Kyiv"), + ("Europe/Lisbon", "Europe/Lisbon"), + ("Europe/Ljubljana", "Europe/Ljubljana"), + ("Europe/London", "Europe/London"), + ("Europe/Luxembourg", "Europe/Luxembourg"), + ("Europe/Madrid", "Europe/Madrid"), + ("Europe/Malta", "Europe/Malta"), + ("Europe/Mariehamn", "Europe/Mariehamn"), + ("Europe/Minsk", "Europe/Minsk"), + ("Europe/Monaco", "Europe/Monaco"), + ("Europe/Moscow", "Europe/Moscow"), + ("Europe/Nicosia", "Europe/Nicosia"), + ("Europe/Oslo", "Europe/Oslo"), + ("Europe/Paris", "Europe/Paris"), + ("Europe/Podgorica", "Europe/Podgorica"), + ("Europe/Prague", "Europe/Prague"), + ("Europe/Riga", "Europe/Riga"), + ("Europe/Rome", "Europe/Rome"), + ("Europe/Samara", "Europe/Samara"), + ("Europe/San_Marino", "Europe/San_Marino"), + ("Europe/Sarajevo", "Europe/Sarajevo"), + ("Europe/Saratov", "Europe/Saratov"), + ("Europe/Simferopol", "Europe/Simferopol"), + ("Europe/Skopje", "Europe/Skopje"), + ("Europe/Sofia", "Europe/Sofia"), + ("Europe/Stockholm", "Europe/Stockholm"), + ("Europe/Tallinn", "Europe/Tallinn"), + ("Europe/Tirane", "Europe/Tirane"), + ("Europe/Tiraspol", "Europe/Tiraspol"), + ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), + ("Europe/Uzhgorod", "Europe/Uzhgorod"), + ("Europe/Vaduz", "Europe/Vaduz"), + ("Europe/Vatican", "Europe/Vatican"), + ("Europe/Vienna", "Europe/Vienna"), + ("Europe/Vilnius", "Europe/Vilnius"), + ("Europe/Volgograd", "Europe/Volgograd"), + ("Europe/Warsaw", "Europe/Warsaw"), + ("Europe/Zagreb", "Europe/Zagreb"), + ("Europe/Zaporozhye", "Europe/Zaporozhye"), + ("Europe/Zurich", "Europe/Zurich"), + ("GB", "GB"), + ("GB-Eire", "GB-Eire"), + ("GMT", "GMT"), + ("GMT+0", "GMT+0"), + ("GMT-0", "GMT-0"), + ("GMT0", "GMT0"), + ("Greenwich", "Greenwich"), + ("HST", "HST"), + ("Hongkong", "Hongkong"), + ("Iceland", "Iceland"), + ("Indian/Antananarivo", "Indian/Antananarivo"), + ("Indian/Chagos", "Indian/Chagos"), + ("Indian/Christmas", "Indian/Christmas"), + ("Indian/Cocos", "Indian/Cocos"), + ("Indian/Comoro", "Indian/Comoro"), + ("Indian/Kerguelen", "Indian/Kerguelen"), + ("Indian/Mahe", "Indian/Mahe"), + ("Indian/Maldives", "Indian/Maldives"), + ("Indian/Mauritius", "Indian/Mauritius"), + ("Indian/Mayotte", "Indian/Mayotte"), + ("Indian/Reunion", "Indian/Reunion"), + ("Iran", "Iran"), + ("Israel", "Israel"), + ("Jamaica", "Jamaica"), + ("Japan", "Japan"), + ("Kwajalein", "Kwajalein"), + ("Libya", "Libya"), + ("MET", "MET"), + ("MST", "MST"), + ("MST7MDT", "MST7MDT"), + ("Mexico/BajaNorte", "Mexico/BajaNorte"), + ("Mexico/BajaSur", "Mexico/BajaSur"), + ("Mexico/General", "Mexico/General"), + ("NZ", "NZ"), + ("NZ-CHAT", "NZ-CHAT"), + ("Navajo", "Navajo"), + ("PRC", "PRC"), + ("PST8PDT", "PST8PDT"), + ("Pacific/Apia", "Pacific/Apia"), + ("Pacific/Auckland", "Pacific/Auckland"), + ("Pacific/Bougainville", "Pacific/Bougainville"), + ("Pacific/Chatham", "Pacific/Chatham"), + ("Pacific/Chuuk", "Pacific/Chuuk"), + ("Pacific/Easter", "Pacific/Easter"), + ("Pacific/Efate", "Pacific/Efate"), + ("Pacific/Enderbury", "Pacific/Enderbury"), + ("Pacific/Fakaofo", "Pacific/Fakaofo"), + ("Pacific/Fiji", "Pacific/Fiji"), + ("Pacific/Funafuti", "Pacific/Funafuti"), + ("Pacific/Galapagos", "Pacific/Galapagos"), + ("Pacific/Gambier", "Pacific/Gambier"), + ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), + ("Pacific/Guam", "Pacific/Guam"), + ("Pacific/Honolulu", "Pacific/Honolulu"), + ("Pacific/Johnston", "Pacific/Johnston"), + ("Pacific/Kanton", "Pacific/Kanton"), + ("Pacific/Kiritimati", "Pacific/Kiritimati"), + ("Pacific/Kosrae", "Pacific/Kosrae"), + ("Pacific/Kwajalein", "Pacific/Kwajalein"), + ("Pacific/Majuro", "Pacific/Majuro"), + ("Pacific/Marquesas", "Pacific/Marquesas"), + ("Pacific/Midway", "Pacific/Midway"), + ("Pacific/Nauru", "Pacific/Nauru"), + ("Pacific/Niue", "Pacific/Niue"), + ("Pacific/Norfolk", "Pacific/Norfolk"), + ("Pacific/Noumea", "Pacific/Noumea"), + ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), + ("Pacific/Palau", "Pacific/Palau"), + ("Pacific/Pitcairn", "Pacific/Pitcairn"), + ("Pacific/Pohnpei", "Pacific/Pohnpei"), + ("Pacific/Ponape", "Pacific/Ponape"), + ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), + ("Pacific/Rarotonga", "Pacific/Rarotonga"), + ("Pacific/Saipan", "Pacific/Saipan"), + ("Pacific/Samoa", "Pacific/Samoa"), + ("Pacific/Tahiti", "Pacific/Tahiti"), + ("Pacific/Tarawa", "Pacific/Tarawa"), + ("Pacific/Tongatapu", "Pacific/Tongatapu"), + ("Pacific/Truk", "Pacific/Truk"), + ("Pacific/Wake", "Pacific/Wake"), + ("Pacific/Wallis", "Pacific/Wallis"), + ("Pacific/Yap", "Pacific/Yap"), + ("Poland", "Poland"), + ("Portugal", "Portugal"), + ("ROC", "ROC"), + ("ROK", "ROK"), + ("Singapore", "Singapore"), + ("Turkey", "Turkey"), + ("UCT", "UCT"), + ("US/Alaska", "US/Alaska"), + ("US/Aleutian", "US/Aleutian"), + ("US/Arizona", "US/Arizona"), + ("US/Central", "US/Central"), + ("US/East-Indiana", "US/East-Indiana"), + ("US/Eastern", "US/Eastern"), + ("US/Hawaii", "US/Hawaii"), + ("US/Indiana-Starke", "US/Indiana-Starke"), + ("US/Michigan", "US/Michigan"), + ("US/Mountain", "US/Mountain"), + ("US/Pacific", "US/Pacific"), + ("US/Samoa", "US/Samoa"), + ("UTC", "UTC"), + ("Universal", "Universal"), + ("W-SU", "W-SU"), + ("WET", "WET"), + ("Zulu", "Zulu"), + ], + default="UTC", + max_length=255, + ), + ), + migrations.AlterField( + model_name="cycle", + name="end_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="End Date" + ), + ), + migrations.AlterField( + model_name="cycle", + name="start_date", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Start Date" + ), + ), + migrations.CreateModel( + name="DraftIssueModule", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_module", + to="db.draftissue", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_module", + to="db.module", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Module", + "verbose_name_plural": "Draft Issue Modules", + "db_table": "draft_issue_modules", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueLabel", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_label_issue", + to="db.draftissue", + ), + ), + ( + "label", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_label_issue", + to="db.label", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Label", + "verbose_name_plural": "Draft Issue Labels", + "db_table": "draft_issue_labels", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueCycle", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "cycle", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.cycle", + ), + ), + ( + "draft_issue", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_cycle", + to="db.draftissue", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Cycle", + "verbose_name_plural": "Draft Issue Cycles", + "db_table": "draft_issue_cycles", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="DraftIssueAssignee", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "assignee", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_assignee", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "draft_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_issue_assignee", + to="db.draftissue", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Draft Issue Assignee", + "verbose_name_plural": "Draft Issue Assignees", + "db_table": "draft_issue_assignees", + "ordering": ("-created_at",), + }, + ), + migrations.AddField( + model_name="draftissue", + name="assignees", + field=models.ManyToManyField( + blank=True, + related_name="draft_assignee", + through="db.DraftIssueAssignee", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="draftissue", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + migrations.AddField( + model_name="draftissue", + name="estimate_point", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="draft_issue_estimates", + to="db.estimatepoint", + ), + ), + migrations.AddField( + model_name="draftissue", + name="labels", + field=models.ManyToManyField( + blank=True, + related_name="draft_labels", + through="db.DraftIssueLabel", + to="db.label", + ), + ), + migrations.AddField( + model_name="draftissue", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="draft_parent_issue", + to="db.issue", + ), + ), + migrations.AddField( + model_name="draftissue", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + migrations.AddField( + model_name="draftissue", + name="state", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="state_draft_issue", + to="db.state", + ), + ), + migrations.AddField( + model_name="draftissue", + name="type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="draft_issue_type", + to="db.issuetype", + ), + ), + migrations.AddField( + model_name="draftissue", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + migrations.AddField( + model_name="draftissue", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + migrations.AddConstraint( + model_name="draftissuemodule", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "module"), + name="module_draft_issue_unique_issue_module_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="draftissuemodule", + unique_together={("draft_issue", "module", "deleted_at")}, + ), + migrations.AddConstraint( + model_name="draftissueassignee", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("draft_issue", "assignee"), + name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="draftissueassignee", + unique_together={("draft_issue", "assignee", "deleted_at")}, + ), + migrations.AddField( + model_name="cycle", + name="version", + field=models.IntegerField(default=1), + ), + migrations.RunPython(migrate_draft_issues), + ] diff --git a/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py b/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py new file mode 100644 index 0000000000..3839f4e73d --- /dev/null +++ b/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py @@ -0,0 +1,179 @@ +# Generated by Django 4.2.15 on 2024-10-09 06:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.asset + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "db", + "0077_draftissue_cycle_user_timezone_project_user_timezone_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="fileasset", + name="comment", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.issuecomment", + ), + ), + migrations.AddField( + model_name="fileasset", + name="entity_type", + field=models.CharField( + blank=True, + choices=[ + ("ISSUE_ATTACHMENT", "Issue Attachment"), + ("ISSUE_DESCRIPTION", "Issue Description"), + ("COMMENT_DESCRIPTION", "Comment Description"), + ("PAGE_DESCRIPTION", "Page Description"), + ("USER_COVER", "User Cover"), + ("USER_AVATAR", "User Avatar"), + ("WORKSPACE_LOGO", "Workspace Logo"), + ("PROJECT_COVER", "Project Cover"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="fileasset", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="is_uploaded", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="fileasset", + name="issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.issue", + ), + ), + migrations.AddField( + model_name="fileasset", + name="page", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.page", + ), + ), + migrations.AddField( + model_name="fileasset", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.project", + ), + ), + migrations.AddField( + model_name="fileasset", + name="size", + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name="fileasset", + name="storage_metadata", + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="project", + name="cover_image_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_cover_image", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="user", + name="avatar_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_avatar", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="user", + name="cover_image_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_cover_image", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="workspace", + name="logo_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_logo", + to="db.fileasset", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="asset", + field=models.FileField( + max_length=800, upload_to=plane.db.models.asset.get_upload_path + ), + ), + migrations.AlterField( + model_name="integration", + name="avatar_url", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="project", + name="cover_image", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="workspace", + name="logo", + field=models.TextField(blank=True, null=True, verbose_name="Logo"), + ), + ] diff --git a/apiserver/plane/db/migrations/0079_auto_20241009_0619.py b/apiserver/plane/db/migrations/0079_auto_20241009_0619.py new file mode 100644 index 0000000000..e3fc904a7a --- /dev/null +++ b/apiserver/plane/db/migrations/0079_auto_20241009_0619.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.15 on 2024-10-09 06:19 + +from django.db import migrations + + +def move_attachment_to_fileasset(apps, schema_editor): + FileAsset = apps.get_model("db", "FileAsset") + IssueAttachment = apps.get_model("db", "IssueAttachment") + + bulk_issue_attachment = [] + for issue_attachment in IssueAttachment.objects.values( + "issue_id", + "project_id", + "workspace_id", + "asset", + "attributes", + "external_source", + "external_id", + "deleted_at", + "created_by_id", + "updated_by_id", + ): + bulk_issue_attachment.append( + FileAsset( + issue_id=issue_attachment["issue_id"], + entity_type="ISSUE_ATTACHMENT", + project_id=issue_attachment["project_id"], + workspace_id=issue_attachment["workspace_id"], + attributes=issue_attachment["attributes"], + asset=issue_attachment["asset"], + external_source=issue_attachment["external_source"], + external_id=issue_attachment["external_id"], + deleted_at=issue_attachment["deleted_at"], + created_by_id=issue_attachment["created_by_id"], + updated_by_id=issue_attachment["updated_by_id"], + size=issue_attachment["attributes"].get("size", 0), + ) + ) + + FileAsset.objects.bulk_create(bulk_issue_attachment, batch_size=1000) + + +def mark_existing_file_uploads(apps, schema_editor): + FileAsset = apps.get_model("db", "FileAsset") + # Mark all existing file uploads as uploaded + FileAsset.objects.update(is_uploaded=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0078_fileasset_comment_fileasset_entity_type_and_more"), + ] + + operations = [ + migrations.RunPython( + move_attachment_to_fileasset, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + mark_existing_file_uploads, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py b/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py new file mode 100644 index 0000000000..f511301930 --- /dev/null +++ b/apiserver/plane/db/migrations/0080_fileasset_draft_issue_alter_fileasset_entity_type.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.15 on 2024-10-12 18:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0079_auto_20241009_0619"), + ] + + operations = [ + migrations.AddField( + model_name="fileasset", + name="draft_issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.draftissue", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="entity_type", + field=models.CharField( + blank=True, + choices=[ + ("ISSUE_ATTACHMENT", "Issue Attachment"), + ("ISSUE_DESCRIPTION", "Issue Description"), + ("COMMENT_DESCRIPTION", "Comment Description"), + ("PAGE_DESCRIPTION", "Page Description"), + ("USER_COVER", "User Cover"), + ("USER_AVATAR", "User Avatar"), + ("WORKSPACE_LOGO", "Workspace Logo"), + ("PROJECT_COVER", "Project Cover"), + ("DRAFT_ISSUE_ATTACHMENT", "Draft Issue Attachment"), + ("DRAFT_ISSUE_DESCRIPTION", "Draft Issue Description"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py b/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py new file mode 100644 index 0000000000..984f25444c --- /dev/null +++ b/apiserver/plane/db/migrations/0081_remove_globalview_created_by_and_more.py @@ -0,0 +1,187 @@ +# Generated by Django 4.2.16 on 2024-10-15 11:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0080_fileasset_draft_issue_alter_fileasset_entity_type"), + ] + + operations = [ + migrations.RemoveField( + model_name="globalview", + name="created_by", + ), + migrations.RemoveField( + model_name="globalview", + name="updated_by", + ), + migrations.RemoveField( + model_name="globalview", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="issueviewfavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="project", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="user", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="view", + ), + migrations.RemoveField( + model_name="issueviewfavorite", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="modulefavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="modulefavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="module", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="project", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="user", + ), + migrations.RemoveField( + model_name="modulefavorite", + name="workspace", + ), + migrations.RemoveField( + model_name="pageblock", + name="created_by", + ), + migrations.RemoveField( + model_name="pageblock", + name="issue", + ), + migrations.RemoveField( + model_name="pageblock", + name="page", + ), + migrations.RemoveField( + model_name="pageblock", + name="project", + ), + migrations.RemoveField( + model_name="pageblock", + name="updated_by", + ), + migrations.RemoveField( + model_name="pageblock", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="pagefavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="pagefavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="page", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="project", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="user", + ), + migrations.RemoveField( + model_name="pagefavorite", + name="workspace", + ), + migrations.AlterUniqueTogether( + name="projectfavorite", + unique_together=None, + ), + migrations.RemoveField( + model_name="projectfavorite", + name="created_by", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="project", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="updated_by", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="user", + ), + migrations.RemoveField( + model_name="projectfavorite", + name="workspace", + ), + migrations.AddField( + model_name="issuetype", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuetype", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.DeleteModel( + name="CycleFavorite", + ), + migrations.DeleteModel( + name="GlobalView", + ), + migrations.DeleteModel( + name="IssueViewFavorite", + ), + migrations.DeleteModel( + name="ModuleFavorite", + ), + migrations.DeleteModel( + name="PageBlock", + ), + migrations.DeleteModel( + name="PageFavorite", + ), + migrations.DeleteModel( + name="ProjectFavorite", + ), + ] diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index 0203eb8ce8..4d4cee978e 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -43,9 +43,19 @@ class UserAuditModel(models.Model): abstract = True +class SoftDeletionQuerySet(models.QuerySet): + def delete(self, soft=True): + if soft: + return self.update(deleted_at=timezone.now()) + else: + return super().delete() + + class SoftDeletionManager(models.Manager): def get_queryset(self): - return super().get_queryset().filter(deleted_at__isnull=True) + return SoftDeletionQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=True + ) class SoftDeleteModel(models.Model): diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index e7def641d5..deade98827 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -2,9 +2,10 @@ from .analytic import AnalyticView from .api import APIActivityLog, APIToken from .asset import FileAsset from .base import BaseModel -from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties +from .cycle import Cycle, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget from .deploy_board import DeployBoard +from .draft import DraftIssue, DraftIssueAssignee, DraftIssueLabel, DraftIssueModule, DraftIssueCycle from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer @@ -23,7 +24,6 @@ from .issue import ( Issue, IssueActivity, IssueAssignee, - IssueAttachment, IssueBlocker, IssueComment, IssueLabel, @@ -39,7 +39,6 @@ from .issue import ( ) from .module import ( Module, - ModuleFavorite, ModuleIssue, ModuleLink, ModuleMember, @@ -52,7 +51,6 @@ from .notification import ( ) from .page import ( Page, - PageFavorite, PageLabel, PageLog, ProjectPage, @@ -61,7 +59,6 @@ from .page import ( from .project import ( Project, ProjectBaseModel, - ProjectFavorite, ProjectIdentifier, ProjectMember, ProjectMemberInvite, @@ -72,7 +69,7 @@ from .session import Session from .social_connection import SocialLoginConnection from .state import State from .user import Account, Profile, User -from .view import IssueView, IssueViewFavorite +from .view import IssueView from .webhook import Webhook, WebhookLog from .workspace import ( Team, @@ -87,7 +84,7 @@ from .workspace import ( from .importer import Importer -from .page import Page, PageLog, PageFavorite, PageLabel +from .page import Page, PageLog, PageLabel from .estimate import Estimate, EstimatePoint diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index a11ba89a4d..e230d3aecf 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -5,14 +5,13 @@ from uuid import uuid4 from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.core.validators import FileExtensionValidator # Module import from .base import BaseModel def get_upload_path(instance, filename): - filename = filename[:50] + if instance.workspace_id is not None: return f"{instance.workspace.id}/{uuid4().hex}-{filename}" return f"user-{uuid4().hex}-{filename}" @@ -28,13 +27,28 @@ class FileAsset(BaseModel): A file asset. """ + class EntityTypeContext(models.TextChoices): + ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT" + ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION" + COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION" + PAGE_DESCRIPTION = "PAGE_DESCRIPTION" + USER_COVER = "USER_COVER" + USER_AVATAR = "USER_AVATAR" + WORKSPACE_LOGO = "WORKSPACE_LOGO" + PROJECT_COVER = "PROJECT_COVER" + DRAFT_ISSUE_ATTACHMENT = "DRAFT_ISSUE_ATTACHMENT" + DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION" + attributes = models.JSONField(default=dict) asset = models.FileField( upload_to=get_upload_path, - validators=[ - FileExtensionValidator(allowed_extensions=["jpg", "jpeg", "png"]), - file_size, - ], + max_length=800, + ) + user = models.ForeignKey( + "db.User", + on_delete=models.CASCADE, + null=True, + related_name="assets", ) workspace = models.ForeignKey( "db.Workspace", @@ -42,8 +56,49 @@ class FileAsset(BaseModel): null=True, related_name="assets", ) + draft_issue = models.ForeignKey( + "db.DraftIssue", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + project = models.ForeignKey( + "db.Project", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + issue = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + page = models.ForeignKey( + "db.Page", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + entity_type = models.CharField( + max_length=255, + choices=EntityTypeContext.choices, + null=True, + blank=True, + ) is_deleted = models.BooleanField(default=False) is_archived = models.BooleanField(default=False) + external_id = models.CharField(max_length=255, null=True, blank=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + size = models.FloatField(default=0) + is_uploaded = models.BooleanField(default=False) + storage_metadata = models.JSONField(default=dict, null=True, blank=True) class Meta: verbose_name = "File Asset" @@ -53,3 +108,26 @@ class FileAsset(BaseModel): def __str__(self): return str(self.asset) + + @property + def asset_url(self): + if ( + self.entity_type == self.EntityTypeContext.WORKSPACE_LOGO + or self.entity_type == self.EntityTypeContext.USER_AVATAR + or self.entity_type == self.EntityTypeContext.USER_COVER + or self.entity_type == self.EntityTypeContext.PROJECT_COVER + ): + return f"/api/assets/v2/static/{self.id}/" + + if self.entity_type == self.EntityTypeContext.ISSUE_ATTACHMENT: + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/" + + if self.entity_type in [ + self.EntityTypeContext.ISSUE_DESCRIPTION, + self.EntityTypeContext.COMMENT_DESCRIPTION, + self.EntityTypeContext.PAGE_DESCRIPTION, + self.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION, + ]: + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/" + + return None diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index b3ce49e01a..f9f9eece96 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -1,3 +1,6 @@ +# Python imports +import pytz + # Django imports from django.conf import settings from django.db import models @@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel): description = models.TextField( verbose_name="Cycle Description", blank=True ) - start_date = models.DateField( + start_date = models.DateTimeField( verbose_name="Start Date", blank=True, null=True ) - end_date = models.DateField(verbose_name="End Date", blank=True, null=True) + end_date = models.DateTimeField( + verbose_name="End Date", blank=True, null=True + ) owned_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -71,6 +76,12 @@ class Cycle(ProjectBaseModel): progress_snapshot = models.JSONField(default=dict) archived_at = models.DateTimeField(null=True) logo_props = models.JSONField(default=dict) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + timezone = models.CharField( + max_length=255, default="UTC", choices=TIMEZONE_CHOICES + ) + version = models.IntegerField(default=1) class Meta: verbose_name = "Cycle" @@ -116,33 +127,6 @@ class CycleIssue(ProjectBaseModel): return f"{self.cycle}" -# DEPRECATED TODO: - Remove in next release -class CycleFavorite(ProjectBaseModel): - """_summary_ - CycleFavorite (model): To store all the cycle favorite of the user - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="cycle_favorites", - ) - cycle = models.ForeignKey( - "db.Cycle", on_delete=models.CASCADE, related_name="cycle_favorites" - ) - - class Meta: - unique_together = ["cycle", "user"] - verbose_name = "Cycle Favorite" - verbose_name_plural = "Cycle Favorites" - db_table = "cycle_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the cycle""" - return f"{self.user.email} <{self.cycle.name}>" - - class CycleUserProperties(ProjectBaseModel): cycle = models.ForeignKey( "db.Cycle", diff --git a/apiserver/plane/db/models/draft.py b/apiserver/plane/db/models/draft.py new file mode 100644 index 0000000000..671b89ff1f --- /dev/null +++ b/apiserver/plane/db/models/draft.py @@ -0,0 +1,253 @@ +# Django imports +from django.conf import settings +from django.db import models +from django.utils import timezone + +# Module imports +from plane.utils.html_processor import strip_tags + +from .workspace import WorkspaceBaseModel + + +class DraftIssue(WorkspaceBaseModel): + PRIORITY_CHOICES = ( + ("urgent", "Urgent"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("none", "None"), + ) + parent = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="draft_parent_issue", + ) + state = models.ForeignKey( + "db.State", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="state_draft_issue", + ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="draft_issue_estimates", + null=True, + blank=True, + ) + name = models.CharField( + max_length=255, verbose_name="Issue Name", blank=True, null=True + ) + 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="draft_assignee", + through="DraftIssueAssignee", + through_fields=("draft_issue", "assignee"), + ) + labels = models.ManyToManyField( + "db.Label", + blank=True, + related_name="draft_labels", + through="DraftIssueLabel", + ) + sort_order = models.FloatField(default=65535) + completed_at = models.DateTimeField(null=True) + 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="draft_issue_type", + null=True, + blank=True, + ) + + class Meta: + verbose_name = "DraftIssue" + verbose_name_plural = "DraftIssues" + db_table = "draft_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: + # 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 = DraftIssue.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(DraftIssue, self).save(*args, **kwargs) + + 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(DraftIssue, self).save(*args, **kwargs) + + def __str__(self): + """Return name of the draft issue""" + return f"{self.name} <{self.project.name}>" + + +class DraftIssueAssignee(WorkspaceBaseModel): + draft_issue = models.ForeignKey( + DraftIssue, + on_delete=models.CASCADE, + related_name="draft_issue_assignee", + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="draft_issue_assignee", + ) + + class Meta: + unique_together = ["draft_issue", "assignee", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "assignee"], + condition=models.Q(deleted_at__isnull=True), + name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Assignee" + verbose_name_plural = "Draft Issue Assignees" + db_table = "draft_issue_assignees" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.draft_issue.name} {self.assignee.email}" + + +class DraftIssueLabel(WorkspaceBaseModel): + draft_issue = models.ForeignKey( + "db.DraftIssue", + on_delete=models.CASCADE, + related_name="draft_label_issue", + ) + label = models.ForeignKey( + "db.Label", on_delete=models.CASCADE, related_name="draft_label_issue" + ) + + class Meta: + verbose_name = "Draft Issue Label" + verbose_name_plural = "Draft Issue Labels" + db_table = "draft_issue_labels" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.draft_issue.name} {self.label.name}" + + +class DraftIssueModule(WorkspaceBaseModel): + module = models.ForeignKey( + "db.Module", + on_delete=models.CASCADE, + related_name="draft_issue_module", + ) + draft_issue = models.ForeignKey( + "db.DraftIssue", + on_delete=models.CASCADE, + related_name="draft_issue_module", + ) + + class Meta: + unique_together = ["draft_issue", "module", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft_issue", "module"], + condition=models.Q(deleted_at__isnull=True), + name="module_draft_issue_unique_issue_module_when_deleted_at_null", + ) + ] + verbose_name = "Draft Issue Module" + verbose_name_plural = "Draft Issue Modules" + db_table = "draft_issue_modules" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.module.name} {self.draft_issue.name}" + + +class DraftIssueCycle(WorkspaceBaseModel): + """ + Draft Issue Cycles + """ + + draft_issue = models.OneToOneField( + "db.DraftIssue", + on_delete=models.CASCADE, + related_name="draft_issue_cycle", + ) + cycle = models.ForeignKey( + "db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle" + ) + + class Meta: + verbose_name = "Draft Issue Cycle" + verbose_name_plural = "Draft Issue Cycles" + db_table = "draft_issue_cycles" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.cycle}" diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py index 0c68adfd2e..3c296895fa 100644 --- a/apiserver/plane/db/models/integration/base.py +++ b/apiserver/plane/db/models/integration/base.py @@ -29,7 +29,7 @@ class Integration(AuditModel): redirect_url = models.TextField(blank=True) metadata = models.JSONField(default=dict) verified = models.BooleanField(default=False) - avatar_url = models.URLField(blank=True, null=True) + avatar_url = models.TextField(blank=True, null=True) def __str__(self): """Return provider of the integration""" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index cafa732c52..7ff9af46e9 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -12,6 +12,7 @@ from django.db.models import Q # Module imports from plane.utils.html_processor import strip_tags +from plane.db.mixins import SoftDeletionManager from .project import ProjectBaseModel @@ -79,7 +80,7 @@ def get_default_display_properties(): # TODO: Handle identifiers for Bulk Inserts - nk -class IssueManager(models.Manager): +class IssueManager(SoftDeletionManager): def get_queryset(self): return ( super() @@ -90,7 +91,6 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__status=2) | models.Q(issue_inbox__isnull=True) ) - .filter(deleted_at__isnull=True) .filter(state__is_triage=False) .exclude(archived_at__isnull=False) .exclude(project__archived_at__isnull=False) @@ -172,7 +172,6 @@ class Issue(ProjectBaseModel): blank=True, ) - objects = models.Manager() issue_objects = IssueManager() class Meta: diff --git a/apiserver/plane/db/models/issue_type.py b/apiserver/plane/db/models/issue_type.py index f62cf54b56..cfe19434bf 100644 --- a/apiserver/plane/db/models/issue_type.py +++ b/apiserver/plane/db/models/issue_type.py @@ -19,6 +19,8 @@ class IssueType(BaseModel): is_default = models.BooleanField(default=False) is_active = models.BooleanField(default=True) level = models.PositiveIntegerField(default=0) + 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 Type" diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 7c1fff53e2..6238fbd218 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -100,9 +100,9 @@ class Module(ProjectBaseModel): unique_together = ["name", "project", "deleted_at"] constraints = [ models.UniqueConstraint( - fields=['name', 'project'], + fields=["name", "project"], condition=Q(deleted_at__isnull=True), - name='module_unique_name_project_when_deleted_at_null' + name="module_unique_name_project_when_deleted_at_null", ) ] verbose_name = "Module" @@ -191,33 +191,6 @@ class ModuleLink(ProjectBaseModel): return f"{self.module.name} {self.url}" -# DEPRECATED TODO: - Remove in next release -class ModuleFavorite(ProjectBaseModel): - """_summary_ - ModuleFavorite (model): To store all the module favorite of the user - """ - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="module_favorites", - ) - module = models.ForeignKey( - "db.Module", on_delete=models.CASCADE, related_name="module_favorites" - ) - - class Meta: - unique_together = ["module", "user"] - verbose_name = "Module Favorite" - verbose_name_plural = "Module Favorites" - db_table = "module_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the module""" - return f"{self.user.email} <{self.module.name}>" - - class ModuleUserProperties(ProjectBaseModel): module = models.ForeignKey( "db.Module", diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 5a7f3b001a..45280e2da1 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -119,86 +119,6 @@ class PageLog(BaseModel): return f"{self.page.name} {self.entity_name}" -# DEPRECATED TODO: - Remove in next release -class PageBlock(ProjectBaseModel): - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="blocks" - ) - name = models.CharField(max_length=255) - description = models.JSONField(default=dict, blank=True) - description_html = models.TextField(blank=True, default="

") - description_stripped = models.TextField(blank=True, null=True) - issue = models.ForeignKey( - "db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True - ) - completed_at = models.DateTimeField(null=True) - sort_order = models.FloatField(default=65535) - sync = models.BooleanField(default=True) - - def save(self, *args, **kwargs): - if self._state.adding: - largest_sort_order = PageBlock.objects.filter( - project=self.project, page=self.page - ).aggregate(largest=models.Max("sort_order"))["largest"] - if largest_sort_order is not None: - self.sort_order = largest_sort_order + 10000 - - # 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) - ) - - if self.completed_at and self.issue: - try: - from plane.db.models import Issue, State - - completed_state = State.objects.filter( - group="completed", project=self.project - ).first() - if completed_state is not None: - Issue.objects.update( - pk=self.issue_id, state=completed_state - ) - except ImportError: - pass - super(PageBlock, self).save(*args, **kwargs) - - class Meta: - verbose_name = "Page Block" - verbose_name_plural = "Page Blocks" - db_table = "page_blocks" - ordering = ("-created_at",) - - def __str__(self): - """Return page and page block""" - return f"{self.page.name} <{self.name}>" - - -# DEPRECATED TODO: - Remove in next release -class PageFavorite(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="page_favorites", - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="page_favorites" - ) - - class Meta: - unique_together = ["page", "user"] - verbose_name = "Page Favorite" - verbose_name_plural = "Page Favorites" - db_table = "page_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the page""" - return f"{self.user.email} <{self.page.name}>" - - class PageLabel(BaseModel): label = models.ForeignKey( "db.Label", on_delete=models.CASCADE, related_name="page_labels" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index bcc1682273..55c45fc2a4 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -1,4 +1,5 @@ # Python imports +import pytz from uuid import uuid4 # Django imports @@ -7,7 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Q -# Modeule imports +# Module imports from plane.db.mixins import AuditModel # Module imports @@ -98,7 +99,14 @@ class Project(BaseModel): is_time_tracking_enabled = models.BooleanField(default=False) is_issue_type_enabled = models.BooleanField(default=False) guest_view_all_features = models.BooleanField(default=False) - cover_image = models.URLField(blank=True, null=True, max_length=800) + cover_image = models.TextField(blank=True, null=True) + cover_image_asset = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="project_cover_image", + ) estimate = models.ForeignKey( "db.Estimate", on_delete=models.SET_NULL, @@ -119,6 +127,23 @@ class Project(BaseModel): related_name="default_state", ) archived_at = models.DateTimeField(null=True) + # timezone + TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + timezone = models.CharField( + max_length=255, default="UTC", choices=TIMEZONE_CHOICES + ) + + @property + def cover_image_url(self): + # Return cover image url + if self.cover_image_asset: + return self.cover_image_asset.asset_url + + # Return cover image url + if self.cover_image: + return self.cover_image + + return None def __str__(self): """Return name of the project""" @@ -156,7 +181,9 @@ class ProjectBaseModel(BaseModel): Project, on_delete=models.CASCADE, related_name="project_%(class)s" ) workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_%(class)s", ) class Meta: @@ -260,26 +287,6 @@ class ProjectIdentifier(AuditModel): ordering = ("-created_at",) -# DEPRECATED TODO: - Remove in next release -class ProjectFavorite(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="project_favorites", - ) - - class Meta: - unique_together = ["project", "user"] - verbose_name = "Project Favorite" - verbose_name_plural = "Project Favorites" - db_table = "project_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user of the project""" - return f"{self.user.email} <{self.project.name}>" - - def get_anchor(): return uuid4().hex diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 2a88df8b66..d8a25c291f 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -17,6 +17,7 @@ from django.dispatch import receiver from django.utils import timezone # Module imports +from plane.db.models import FileAsset from ..mixins import TimeAuditModel @@ -48,8 +49,24 @@ class User(AbstractBaseUser, PermissionsMixin): 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( @@ -111,6 +128,28 @@ class User(AbstractBaseUser, PermissionsMixin): 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 @@ -182,7 +221,11 @@ class Account(TimeAuditModel): ) provider_account_id = models.CharField(max_length=255) provider = models.CharField( - choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")), + choices=( + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ), ) access_token = models.TextField() access_token_expired_at = models.DateTimeField(null=True) diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 2a5bae5691..ee6ac64bfa 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -52,41 +52,6 @@ def get_default_display_properties(): "updated_on": True, } -# DEPRECATED TODO: - Remove in next release -class GlobalView(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="global_views" - ) - name = models.CharField(max_length=255, verbose_name="View Name") - description = models.TextField(verbose_name="View Description", blank=True) - query = models.JSONField(verbose_name="View Query") - access = models.PositiveSmallIntegerField( - default=1, choices=((0, "Private"), (1, "Public")) - ) - query_data = models.JSONField(default=dict) - sort_order = models.FloatField(default=65535) - logo_props = models.JSONField(default=dict) - - class Meta: - verbose_name = "Global View" - verbose_name_plural = "Global Views" - db_table = "global_views" - ordering = ("-created_at",) - - def save(self, *args, **kwargs): - if self._state.adding: - largest_sort_order = GlobalView.objects.filter( - workspace=self.workspace - ).aggregate(largest=models.Max("sort_order"))["largest"] - if largest_sort_order is not None: - self.sort_order = largest_sort_order + 10000 - - super(GlobalView, self).save(*args, **kwargs) - - def __str__(self): - """Return name of the View""" - return f"{self.name} <{self.workspace.name}>" - class IssueView(WorkspaceBaseModel): name = models.CharField(max_length=255, verbose_name="View Name") @@ -109,7 +74,6 @@ class IssueView(WorkspaceBaseModel): ) is_locked = models.BooleanField(default=False) - class Meta: verbose_name = "Issue View" verbose_name_plural = "Issue Views" @@ -139,26 +103,3 @@ class IssueView(WorkspaceBaseModel): def __str__(self): """Return name of the View""" return f"{self.name} <{self.project.name}>" - - -# DEPRECATED TODO: - Remove in next release -class IssueViewFavorite(ProjectBaseModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name="user_view_favorites", - ) - view = models.ForeignKey( - "db.IssueView", on_delete=models.CASCADE, related_name="view_favorites" - ) - - class Meta: - unique_together = ["view", "user"] - verbose_name = "View Favorite" - verbose_name_plural = "View Favorites" - db_table = "view_favorites" - ordering = ("-created_at",) - - def __str__(self): - """Return user and the view""" - return f"{self.user.email} <{self.view.name}>" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 50dac60966..f316aa2b4d 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -118,7 +118,14 @@ def slug_validator(value): class Workspace(BaseModel): name = models.CharField(max_length=80, verbose_name="Workspace Name") - logo = models.URLField(verbose_name="Logo", blank=True, null=True) + logo = models.TextField(verbose_name="Logo", blank=True, null=True) + logo_asset = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + related_name="workspace_logo", + blank=True, + null=True, + ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -138,6 +145,17 @@ class Workspace(BaseModel): """Return name of the Workspace""" return self.name + @property + def logo_url(self): + # Return the logo asset url if it exists + if self.logo_asset: + return self.logo_asset.asset_url + + # Return the logo url if it exists + if self.logo: + return self.logo + return None + class Meta: verbose_name = "Workspace" verbose_name_plural = "Workspaces" diff --git a/apiserver/plane/license/api/serializers/admin.py b/apiserver/plane/license/api/serializers/admin.py index 848e94ef7d..119460b4bb 100644 --- a/apiserver/plane/license/api/serializers/admin.py +++ b/apiserver/plane/license/api/serializers/admin.py @@ -11,6 +11,7 @@ class InstanceAdminMeSerializer(BaseSerializer): fields = [ "id", "avatar", + "avatar_url", "cover_image", "date_joined", "display_name", diff --git a/apiserver/plane/license/bgtasks/tracer.py b/apiserver/plane/license/bgtasks/tracer.py new file mode 100644 index 0000000000..26efb45ed2 --- /dev/null +++ b/apiserver/plane/license/bgtasks/tracer.py @@ -0,0 +1,97 @@ +# Third party imports +from celery import shared_task +from opentelemetry import trace + +# Module imports +from plane.license.models import Instance +from plane.db.models import ( + User, + Workspace, + Project, + Issue, + Module, + Cycle, + CycleIssue, + ModuleIssue, + Page, + WorkspaceMember, +) + + +@shared_task +def instance_traces(): + # Get the tracer + tracer = trace.get_tracer(__name__) + + # Check if the instance is registered + instance = Instance.objects.first() + + # If instance is None then return + if instance is None: + return + + if instance.is_telemetry_enabled: + # Instance details + with tracer.start_as_current_span("instance_details") as span: + # Count of all models + workspace_count = Workspace.objects.count() + user_count = User.objects.count() + project_count = Project.objects.count() + issue_count = Issue.objects.count() + module_count = Module.objects.count() + cycle_count = Cycle.objects.count() + cycle_issue_count = CycleIssue.objects.count() + module_issue_count = ModuleIssue.objects.count() + page_count = Page.objects.count() + + # Set span attributes + span.set_attribute("instance_id", instance.instance_id) + span.set_attribute("instance_name", instance.instance_name) + span.set_attribute("current_version", instance.current_version) + span.set_attribute("latest_version", instance.latest_version) + span.set_attribute( + "is_telemetry_enabled", instance.is_telemetry_enabled + ) + span.set_attribute("user_count", user_count) + span.set_attribute("workspace_count", workspace_count) + span.set_attribute("project_count", project_count) + span.set_attribute("issue_count", issue_count) + span.set_attribute("module_count", module_count) + span.set_attribute("cycle_count", cycle_count) + span.set_attribute("cycle_issue_count", cycle_issue_count) + span.set_attribute("module_issue_count", module_issue_count) + span.set_attribute("page_count", page_count) + + # Workspace details + for workspace in Workspace.objects.all(): + # Count of all models + project_count = Project.objects.filter(workspace=workspace).count() + issue_count = Issue.objects.filter(workspace=workspace).count() + module_count = Module.objects.filter(workspace=workspace).count() + cycle_count = Cycle.objects.filter(workspace=workspace).count() + cycle_issue_count = CycleIssue.objects.filter( + workspace=workspace + ).count() + module_issue_count = ModuleIssue.objects.filter( + workspace=workspace + ).count() + page_count = Page.objects.filter(workspace=workspace).count() + member_count = WorkspaceMember.objects.filter( + workspace=workspace + ).count() + + # Set span attributes + with tracer.start_as_current_span("workspace_details") as span: + span.set_attribute("instance_id", instance.instance_id) + span.set_attribute("workspace_id", str(workspace.id)) + span.set_attribute("workspace_slug", workspace.slug) + span.set_attribute("project_count", project_count) + span.set_attribute("issue_count", issue_count) + span.set_attribute("module_count", module_count) + span.set_attribute("cycle_count", cycle_count) + span.set_attribute("cycle_issue_count", cycle_issue_count) + span.set_attribute("module_issue_count", module_issue_count) + span.set_attribute("page_count", page_count) + span.set_attribute("member_count", member_count) + + return diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 314d475200..09d13441c6 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -11,19 +11,8 @@ from django.conf import settings from plane.license.models import Instance from plane.db.models import ( User, - Workspace, - Project, - Issue, - Module, - Cycle, - CycleIssue, - ModuleIssue, - Page, ) - -from opentelemetry import trace - -tracer = trace.get_tracer(__name__) +from plane.license.bgtasks.tracer import instance_traces class Command(BaseCommand): @@ -35,16 +24,24 @@ class Command(BaseCommand): "machine_signature", type=str, help="Machine signature" ) + def read_package_json(self): + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + payload = { + "instance_key": settings.INSTANCE_KEY, + "version": data.get("version", 0.1), + "user_count": User.objects.filter(is_bot=False).count(), + } + return payload + def handle(self, *args, **options): # Check if the instance is registered instance = Instance.objects.first() # If instance is None then register this instance if instance is None: - with open("package.json", "r") as file: - # Load JSON content from the file - data = json.load(file) - machine_signature = options.get( "machine_signature", "machine-signature" ) @@ -52,12 +49,7 @@ class Command(BaseCommand): if not machine_signature: raise CommandError("Machine signature is required") - payload = { - "instance_key": settings.INSTANCE_KEY, - "version": data.get("version", 0.1), - "machine_signature": machine_signature, - "user_count": User.objects.filter(is_bot=False).count(), - } + payload = self.read_package_json() instance = Instance.objects.create( instance_name="Plane Community Edition", @@ -74,32 +66,15 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS("Instance already registered") ) + payload = self.read_package_json() + # Update the instance details + instance.last_checked_at = timezone.now() + instance.user_count = payload.get("user_count", 0) + instance.current_version = payload.get("version") + instance.latest_version = payload.get("version") + instance.save() - if instance.is_telemetry_enabled: - with tracer.start_as_current_span("instance_details") as span: - workspace_count = Workspace.objects.count() - user_count = User.objects.count() - project_count = Project.objects.count() - issue_count = Issue.objects.count() - module_count = Module.objects.count() - cycle_count = Cycle.objects.count() - cycle_issue_count = CycleIssue.objects.count() - module_issue_count = ModuleIssue.objects.count() - page_count = Page.objects.count() + # Call the instance traces task + instance_traces.delay() - span.set_attribute("instance_id", instance.instance_id) - span.set_attribute("instance_name", instance.instance_name) - span.set_attribute("current_version", instance.current_version) - span.set_attribute("latest_version", instance.latest_version) - span.set_attribute( - "is_telemetry_enabled", instance.is_telemetry_enabled - ) - span.set_attribute("user_count", user_count) - span.set_attribute("workspace_count", workspace_count) - span.set_attribute("project_count", project_count) - span.set_attribute("issue_count", issue_count) - span.set_attribute("module_count", module_count) - span.set_attribute("cycle_count", cycle_count) - span.set_attribute("cycle_issue_count", cycle_issue_count) - span.set_attribute("module_issue_count", module_issue_count) - span.set_attribute("page_count", page_count) + return diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 2504cc3e36..6e9c98ce1e 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -72,7 +72,6 @@ INSTALLED_APPS = [ "rest_framework", "corsheaders", "django_celery_beat", - "storages", ] # Middlewares @@ -259,7 +258,7 @@ STORAGES = { }, } STORAGES["default"] = { - "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "BACKEND": "plane.settings.storage.S3Storage", } AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") @@ -303,6 +302,7 @@ CELERY_IMPORTS = ( "plane.bgtasks.file_asset_task", "plane.bgtasks.email_notification_task", "plane.bgtasks.api_logs_task", + "plane.license.bgtasks.tracer", # management tasks "plane.bgtasks.dummy_data_task", ) @@ -383,3 +383,61 @@ SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) APP_BASE_URL = os.environ.get("APP_BASE_URL") HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) + +ATTACHMENT_MIME_TYPES = [ + # Images + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + "image/tiff", + "image/bmp", + # Documents + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain", + "application/rtf", + # Audio + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/midi", + "audio/x-midi", + "audio/aac", + "audio/flac", + "audio/x-m4a", + # Video + "video/mp4", + "video/mpeg", + "video/ogg", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "video/x-ms-wmv", + # Archives + "application/zip", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + # 3D Models + "model/gltf-binary", + "model/gltf+json", + "application/octet-stream", # for .obj files, but be cautious + # Fonts + "font/ttf", + "font/otf", + "font/woff", + "font/woff2", + # Other + "text/css", + "text/javascript", + "application/json", + "text/xml", + "application/xml", +] diff --git a/apiserver/plane/settings/storage.py b/apiserver/plane/settings/storage.py new file mode 100644 index 0000000000..ac99077f31 --- /dev/null +++ b/apiserver/plane/settings/storage.py @@ -0,0 +1,158 @@ +# Python imports +import os + +# Third party imports +import boto3 +from botocore.exceptions import ClientError +from urllib.parse import quote + +# Module imports +from plane.utils.exception_logger import log_exception +from storages.backends.s3boto3 import S3Boto3Storage + + +class S3Storage(S3Boto3Storage): + + def url(self, name, parameters=None, expire=None, http_method=None): + return name + + """S3 storage class to generate presigned URLs for S3 objects""" + + def __init__(self, request=None): + # Get the AWS credentials and bucket name from the environment + self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") + # Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key + self.aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + # Use the AWS_S3_BUCKET_NAME environment variable for the bucket name + self.aws_storage_bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") + # Use the AWS_REGION environment variable for the region + self.aws_region = os.environ.get("AWS_REGION") + # Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL + self.aws_s3_endpoint_url = os.environ.get( + "AWS_S3_ENDPOINT_URL" + ) or os.environ.get("MINIO_ENDPOINT_URL") + + if os.environ.get("USE_MINIO") == "1": + # Create an S3 client for MinIO + self.s3_client = boto3.client( + "s3", + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.aws_region, + endpoint_url=( + f"{request.scheme}://{request.get_host()}" + if request + else self.aws_s3_endpoint_url + ), + config=boto3.session.Config(signature_version="s3v4"), + ) + else: + # Create an S3 client + self.s3_client = boto3.client( + "s3", + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.aws_region, + endpoint_url=self.aws_s3_endpoint_url, + config=boto3.session.Config(signature_version="s3v4"), + ) + + def generate_presigned_post( + self, object_name, file_type, file_size, expiration=3600 + ): + """Generate a presigned URL to upload an S3 object""" + fields = { + "Content-Type": file_type, + } + + conditions = [ + {"bucket": self.aws_storage_bucket_name}, + ["content-length-range", 1, file_size], + {"Content-Type": file_type}, + ] + + # Add condition for the object name (key) + if object_name.startswith("${filename}"): + conditions.append( + ["starts-with", "$key", object_name[: -len("${filename}")]] + ) + else: + fields["key"] = object_name + conditions.append({"key": object_name}) + + # Generate the presigned POST URL + try: + # Generate a presigned URL for the S3 object + response = self.s3_client.generate_presigned_post( + Bucket=self.aws_storage_bucket_name, + Key=object_name, + Fields=fields, + Conditions=conditions, + ExpiresIn=expiration, + ) + # Handle errors + except ClientError as e: + print(f"Error generating presigned POST URL: {e}") + return None + + return response + + def _get_content_disposition(self, disposition, filename=None): + """Helper method to generate Content-Disposition header value""" + if filename: + # Encode the filename to handle special characters + encoded_filename = quote(filename) + return f"{disposition}; filename*=UTF-8''{encoded_filename}" + return disposition + + def generate_presigned_url( + self, + object_name, + expiration=3600, + http_method="GET", + disposition="inline", + filename=None, + ): + content_disposition = self._get_content_disposition( + disposition, filename + ) + """Generate a presigned URL to share an S3 object""" + try: + response = self.s3_client.generate_presigned_url( + "get_object", + Params={ + "Bucket": self.aws_storage_bucket_name, + "Key": str(object_name), + "ResponseContentDisposition": content_disposition, + }, + ExpiresIn=expiration, + HttpMethod=http_method, + ) + except ClientError as e: + log_exception(e) + return None + + # The response contains the presigned URL + return response + + def get_object_metadata(self, object_name): + """Get the metadata for an S3 object""" + try: + response = self.s3_client.head_object( + Bucket=self.aws_storage_bucket_name, Key=object_name + ) + except ClientError as e: + log_exception(e) + return None + + return { + "ContentType": response.get("ContentType"), + "ContentLength": response.get("ContentLength"), + "LastModified": ( + response.get("LastModified").isoformat() + if response.get("LastModified") + else None + ), + "ETag": response.get("ETag"), + "Metadata": response.get("Metadata", {}), + } diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index 401e7d7191..cf628e8503 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -22,7 +22,7 @@ from plane.db.models import ( CycleIssue, ModuleIssue, IssueLink, - IssueAttachment, + FileAsset, IssueReaction, CommentReaction, IssueVote, @@ -174,7 +174,7 @@ class IssueLinkSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "created_by", @@ -421,7 +421,7 @@ class IssueCreateSerializer(BaseSerializer): updated_by_id = instance.updated_by_id if assignees is not None: - IssueAssignee.objects.filter(issue=instance).delete() + IssueAssignee.objects.filter(issue=instance).delete(soft=False) IssueAssignee.objects.bulk_create( [ IssueAssignee( @@ -438,7 +438,7 @@ class IssueCreateSerializer(BaseSerializer): ) if labels is not None: - IssueLabel.objects.filter(issue=instance).delete() + IssueLabel.objects.filter(issue=instance).delete(soft=False) IssueLabel.objects.bulk_create( [ IssueLabel( diff --git a/apiserver/plane/space/serializer/user.py b/apiserver/plane/space/serializer/user.py index e206073f73..13cd2e45ee 100644 --- a/apiserver/plane/space/serializer/user.py +++ b/apiserver/plane/space/serializer/user.py @@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", ] diff --git a/apiserver/plane/space/urls/__init__.py b/apiserver/plane/space/urls/__init__.py index 054026b009..418665a0bd 100644 --- a/apiserver/plane/space/urls/__init__.py +++ b/apiserver/plane/space/urls/__init__.py @@ -1,10 +1,12 @@ from .inbox import urlpatterns as inbox_urls from .issue import urlpatterns as issue_urls from .project import urlpatterns as project_urls +from .asset import urlpatterns as asset_urls urlpatterns = [ *inbox_urls, *issue_urls, *project_urls, + *asset_urls, ] diff --git a/apiserver/plane/space/urls/asset.py b/apiserver/plane/space/urls/asset.py new file mode 100644 index 0000000000..2a5c30a221 --- /dev/null +++ b/apiserver/plane/space/urls/asset.py @@ -0,0 +1,32 @@ +# Django imports +from django.urls import path + +# Module imports +from plane.space.views import ( + EntityAssetEndpoint, + AssetRestoreEndpoint, + EntityBulkAssetEndpoint, +) + +urlpatterns = [ + path( + "assets/v2/anchor//", + EntityAssetEndpoint.as_view(), + name="entity-asset", + ), + path( + "assets/v2/anchor///", + EntityAssetEndpoint.as_view(), + name="entity-asset", + ), + path( + "assets/v2/anchor//restore//", + AssetRestoreEndpoint.as_view(), + name="asset-restore", + ), + path( + "assets/v2/anchor///bulk/", + EntityBulkAssetEndpoint.as_view(), + name="entity-bulk-asset", + ), +] diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index 9a3cde7ad5..a1eb16b9c8 100644 --- a/apiserver/plane/space/utils/grouper.py +++ b/apiserver/plane/space/utils/grouper.py @@ -1,8 +1,17 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField -from django.db.models.functions import Coalesce, JSONObject +from django.db.models import ( + Q, + UUIDField, + Value, + F, + Case, + When, + JSONField, + CharField, +) +from django.db.models.functions import Coalesce, JSONObject, Concat # Module imports from plane.db.models import ( @@ -16,6 +25,7 @@ from plane.db.models import ( WorkspaceMember, ) + def issue_queryset_grouper(queryset, group_by, sub_group_by): FIELD_MAPPER = { @@ -98,18 +108,26 @@ def issue_on_results(issues, group_by, sub_group_by): first_name=F("votes__actor__first_name"), last_name=F("votes__actor__last_name"), avatar=F("votes__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F("votes__actor__avatar_asset"), + Value("/"), + ), + ), + default=F("votes__actor__avatar"), + output_field=CharField(), + ), display_name=F("votes__actor__display_name"), - ) + ), ), ), default=None, output_field=JSONField(), ), - filter=Case( - When(votes__isnull=False, then=True), - default=False, - output_field=JSONField(), - ), + filter=Q(votes__isnull=False), distinct=True, ), reaction_items=ArrayAgg( @@ -123,18 +141,30 @@ def issue_on_results(issues, group_by, sub_group_by): first_name=F("issue_reactions__actor__first_name"), last_name=F("issue_reactions__actor__last_name"), avatar=F("issue_reactions__actor__avatar"), - display_name=F("issue_reactions__actor__display_name"), + avatar_url=Case( + When( + issue_reactions__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F( + "issue_reactions__actor__avatar_asset" + ), + Value("/"), + ), + ), + default=F("issue_reactions__actor__avatar"), + output_field=CharField(), + ), + display_name=F( + "issue_reactions__actor__display_name" + ), ), ), ), default=None, output_field=JSONField(), ), - filter=Case( - When(issue_reactions__isnull=False, then=True), - default=False, - output_field=JSONField(), - ), + filter=Q(issue_reactions__isnull=False), distinct=True, ), ).values(*required_fields, "vote_items", "reaction_items") diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py index f5e860d87f..4210c1ec11 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -23,3 +23,9 @@ from .module import ProjectModulesEndpoint from .state import ProjectStatesEndpoint from .label import ProjectLabelsEndpoint + +from .asset import ( + EntityAssetEndpoint, + AssetRestoreEndpoint, + EntityBulkAssetEndpoint, +) diff --git a/apiserver/plane/space/views/asset.py b/apiserver/plane/space/views/asset.py new file mode 100644 index 0000000000..0ffe20ce7e --- /dev/null +++ b/apiserver/plane/space/views/asset.py @@ -0,0 +1,278 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, FileAsset +from plane.settings.storage import S3Storage +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata + + +class EntityAssetEndpoint(BaseAPIView): + + def get_permissions(self): + if self.request.method == "GET": + permission_classes = [ + AllowAny, + ] + else: + permission_classes = [ + IsAuthenticated, + ] + return [permission() for permission in permission_classes] + + def get(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Requested resource could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # get the asset id + asset = FileAsset.objects.get( + workspace_id=deploy_board.workspace_id, + pk=pk, + entity_type__in=[ + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + FileAsset.EntityTypeContext.COMMENT_DESCRIPTION, + ], + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + def post(self, request, anchor): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the asset + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", "") + entity_identifier = request.data.get("entity_identifier") + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{deploy_board.workspace_id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size, + }, + asset=asset_key, + size=size, + workspace=deploy_board.workspace, + created_by=request.user, + entity_type=entity_type, + project_id=deploy_board.project_id, + comment_id=entity_identifier, + ) + + # 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, + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # get the asset id + asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(str(asset.id)) + + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + # Get the asset + asset = FileAsset.objects.get( + id=pk, + workspace=deploy_board.workspace, + project_id=deploy_board.project_id, + ) + # Check deleted assets + asset.is_deleted = True + asset.deleted_at = timezone.now() + # Save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AssetRestoreEndpoint(BaseAPIView): + """Endpoint to restore a deleted assets.""" + + def post(self, request, anchor, asset_id): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the asset + asset = FileAsset.all_objects.get( + id=asset_id, workspace=deploy_board.workspace + ) + asset.is_deleted = False + asset.deleted_at = None + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class EntityBulkAssetEndpoint(BaseAPIView): + """Endpoint to bulk update assets.""" + + def post(self, request, anchor, entity_id): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + asset_ids = request.data.get("asset_ids", []) + + # Check if the asset ids are provided + if not asset_ids: + return Response( + { + "error": "No asset ids provided.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # get the asset id + assets = FileAsset.objects.filter( + id__in=asset_ids, + workspace=deploy_board.workspace, + project_id=deploy_board.project_id, + ) + + asset = assets.first() + + # Check if the asset is uploaded + if not asset: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the entity type is allowed + if ( + asset.entity_type + == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION + ): + # update the attributes + assets.update( + comment_id=entity_id, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 3358ff1d3d..a538a34caa 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -17,7 +17,7 @@ from plane.db.models import ( Issue, State, IssueLink, - IssueAttachment, + FileAsset, DeployBoard, ) from plane.app.serializers import ( @@ -95,8 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index fe7a4e13a3..bd9d499d60 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -19,7 +19,9 @@ from django.db.models import ( Value, OuterRef, Func, + CharField, ) +from django.db.models.functions import Concat # Third Party imports from rest_framework.response import Response @@ -59,7 +61,7 @@ from plane.db.models import ( DeployBoard, IssueVote, ProjectPublicMember, - IssueAttachment, + FileAsset, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters @@ -104,7 +106,15 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): queryset=IssueVote.objects.select_related("actor"), ) ) - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -112,8 +122,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -692,13 +703,24 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + cycle_id=Case( + When( + issue_cycle__cycle__deleted_at__isnull=True, + then=F("issue_cycle__cycle_id"), + ), + default=None, + ) + ) .annotate( label_ids=Coalesce( ArrayAgg( "labels__id", distinct=True, - filter=~Q(labels__id__isnull=True), + filter=( + ~Q(labels__id__isnull=True) + & Q(labels__deleted_at__isnull=True) + ), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -715,7 +737,9 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ArrayAgg( "issue_module__module_id", distinct=True, - filter=~Q(issue_module__module_id__isnull=True), + filter=~Q(issue_module__module_id__isnull=True) + & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -746,6 +770,26 @@ class IssueRetrievePublicEndpoint(BaseAPIView): first_name=F("votes__actor__first_name"), last_name=F("votes__actor__last_name"), avatar=F("votes__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value( + "/api/assets/v2/static/" + ), + F( + "votes__actor__avatar_asset" + ), + Value("/"), + ), + ), + When( + votes__actor__avatar_asset__isnull=True, + then=F("votes__actor__avatar"), + ), + default=Value(None), + output_field=CharField(), + ), display_name=F( "votes__actor__display_name" ), @@ -777,6 +821,26 @@ class IssueRetrievePublicEndpoint(BaseAPIView): "issue_reactions__actor__last_name" ), avatar=F("issue_reactions__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value( + "/api/assets/v2/static/" + ), + F( + "votes__actor__avatar_asset" + ), + Value("/"), + ), + ), + When( + votes__actor__avatar_asset__isnull=True, + then=F("votes__actor__avatar"), + ), + default=Value(None), + output_field=CharField(), + ), display_name=F( "issue_reactions__actor__display_name" ), diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index eda3b30ac9..ea6e51e761 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -138,7 +138,7 @@ def burndown_plot( estimate__type="points", ).exists() if estimate_type and plot_type == "points" and cycle_id: - issue_estimates = Issue.objects.filter( + issue_estimates = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, issue_cycle__cycle_id=cycle_id, @@ -149,7 +149,7 @@ def burndown_plot( total_estimate_points = sum(issue_estimates) if estimate_type and plot_type == "points" and module_id: - issue_estimates = Issue.objects.filter( + issue_estimates = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, issue_module__module_id=module_id, @@ -163,7 +163,7 @@ def burndown_plot( if queryset.end_date and queryset.start_date: # Get all dates between the two dates date_range = [ - queryset.start_date + timedelta(days=x) + (queryset.start_date + timedelta(days=x)).date() for x in range( (queryset.end_date - queryset.start_date).days + 1 ) @@ -203,7 +203,7 @@ def burndown_plot( if module_id: # Get all dates between the two dates date_range = [ - queryset.start_date + timedelta(days=x) + (queryset.start_date + timedelta(days=x)) for x in range( (queryset.target_date - queryset.start_date).days + 1 ) diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index ba52bca03d..fef47e0b08 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -26,12 +26,16 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): annotations_map = { "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), - "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "label_ids": ( + "labels__id", + ~Q(labels__id__isnull=True) & (Q(labels__deleted_at__isnull=True)), + ), "module_ids": ( "issue_module__module_id", ( ~Q(issue_module__module_id__isnull=True) & Q(issue_module__module__archived_at__isnull=True) + & Q(issue_module__module__deleted_at__isnull=True) ), ), } diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 65f0aa7f74..c6712cacb0 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -150,7 +150,6 @@ class OffsetPaginator: raise BadPaginationError("Pagination offset cannot be negative") results = queryset[offset:stop] - if cursor.value != limit: results = results[-(limit + 1) :] @@ -761,7 +760,6 @@ class BasePaginator: ): """Paginate the request""" per_page = self.get_per_page(request, default_per_page, max_per_page) - # Convert the cursor value to integer and float from string input_cursor = None try: diff --git a/apiserver/plane/utils/tracer.py b/apiserver/plane/utils/tracer.py deleted file mode 100644 index 831472cbfd..0000000000 --- a/apiserver/plane/utils/tracer.py +++ /dev/null @@ -1,26 +0,0 @@ -from opentelemetry import trace -from django.conf import settings -from functools import wraps - -tracer = trace.get_tracer(__name__) - - -def trace_operation(operation_name, **attributes): - def wrapper(func): - @wraps(func) - def traced_func(*args, **kwargs): - if settings.TELEMETRY_ENABLED: - with tracer.start_as_current_span(operation_name) as span: - for key, value in attributes.items(): - span.set_attribute(key, value) - result = func(*args, **kwargs) - span.add_event( - "operation_completed", {"result": str(result)} - ) - return result - else: - return func(*args, **kwargs) - - return traced_func - - return wrapper diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 8b96cb9979..fbe6680d43 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,7 +1,7 @@ # base requirements # django -Django==4.2.15 +Django==4.2.16 # rest framework djangorestframework==3.15.2 # postgres diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 635cb88f80..fe47e625f6 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -34,7 +34,7 @@ x-app-env: &app-env - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS - USE_MINIO=${USE_MINIO:-1} - - AWS_REGION=${AWS_REGION:-""} + - AWS_REGION=${AWS_REGION:-} - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"} - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} diff --git a/live/package.json b/live/package.json index 9344475bb2..07d8a053fd 100644 --- a/live/package.json +++ b/live/package.json @@ -1,6 +1,6 @@ { "name": "live", - "version": "0.23.0", + "version": "0.23.1", "description": "", "main": "./src/server.ts", "private": true, diff --git a/live/src/ce/lib/authentication.ts b/live/src/ce/lib/authentication.ts deleted file mode 100644 index 3d5a1ea48e..0000000000 --- a/live/src/ce/lib/authentication.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ConnectionConfiguration } from "@hocuspocus/server"; -// types -import { TDocumentTypes } from "@/core/types/common.js"; - -type TArgs = { - connection: ConnectionConfiguration - cookie: string; - documentType: TDocumentTypes | undefined; - params: URLSearchParams; -} - -export const authenticateUser = async (args: TArgs): Promise => { - const { documentType } = args; - throw Error(`Authentication failed: Invalid document type ${documentType} provided.`); -} \ No newline at end of file diff --git a/live/src/core/hocuspocus-server.ts b/live/src/core/hocuspocus-server.ts index fb30c8f828..0aa411b933 100644 --- a/live/src/core/hocuspocus-server.ts +++ b/live/src/core/hocuspocus-server.ts @@ -12,15 +12,11 @@ export const getHocusPocusServer = async () => { name: serverName, onAuthenticate: async ({ requestHeaders, - requestParameters, - connection, // user id used as token for authentication token, }) => { // request headers const cookie = requestHeaders.cookie?.toString(); - // params - const params = requestParameters; if (!cookie) { throw Error("Credentials not provided"); @@ -28,9 +24,7 @@ export const getHocusPocusServer = async () => { try { await handleAuthentication({ - connection, cookie, - params, token, }); } catch (error) { @@ -38,6 +32,6 @@ export const getHocusPocusServer = async () => { } }, extensions, - debounce: 10000 + debounce: 10000, }); }; diff --git a/live/src/core/lib/authentication.ts b/live/src/core/lib/authentication.ts index dbde17959a..ee01b02090 100644 --- a/live/src/core/lib/authentication.ts +++ b/live/src/core/lib/authentication.ts @@ -1,28 +1,17 @@ -import { ConnectionConfiguration } from "@hocuspocus/server"; // services import { UserService } from "@/core/services/user.service.js"; -// types -import { TDocumentTypes } from "@/core/types/common.js"; -// plane live lib -import { authenticateUser } from "@/plane-live/lib/authentication.js"; // core helpers import { manualLogger } from "@/core/helpers/logger.js"; const userService = new UserService(); type Props = { - connection: ConnectionConfiguration; cookie: string; - params: URLSearchParams; token: string; }; export const handleAuthentication = async (props: Props) => { - const { connection, cookie, params, token } = props; - // params - const documentType = params.get("documentType")?.toString() as - | TDocumentTypes - | undefined; + const { cookie, token } = props; // fetch current user info let response; try { @@ -35,40 +24,6 @@ export const handleAuthentication = async (props: Props) => { throw Error("Authentication failed: Token doesn't match the current user."); } - if (documentType === "project_page") { - // params - const workspaceSlug = params.get("workspaceSlug")?.toString(); - const projectId = params.get("projectId")?.toString(); - if (!workspaceSlug || !projectId) { - throw Error( - "Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing." - ); - } - // fetch current user's project membership info - try { - const projectMembershipInfo = await userService.getUserProjectMembership( - workspaceSlug, - projectId, - cookie - ); - const projectRole = projectMembershipInfo.role; - // make the connection read only for roles lower than a member - if (projectRole < 15) { - connection.readOnly = true; - } - } catch (error) { - manualLogger.error("Failed to fetch project membership info:", error); - throw error; - } - } else { - await authenticateUser({ - connection, - cookie, - documentType, - params, - }); - } - return { user: { id: response.id, diff --git a/live/src/core/services/user.service.ts b/live/src/core/services/user.service.ts index 09412aa532..39d200919a 100644 --- a/live/src/core/services/user.service.ts +++ b/live/src/core/services/user.service.ts @@ -1,5 +1,5 @@ // types -import type { IProjectMember, IUser } from "@plane/types"; +import type { IUser } from "@plane/types"; // services import { API_BASE_URL, APIService } from "@/core/services/api.service.js"; @@ -25,37 +25,4 @@ export class UserService extends APIService { throw error; }); } - - async getUserWorkspaceMembership( - workspaceSlug: string, - cookie: string - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`, - { - headers: { - Cookie: cookie, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getUserProjectMembership( - workspaceSlug: string, - projectId: string, - cookie: string - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`, - { - headers: { - Cookie: cookie, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } } diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev index 7c1c4397dd..7b94982104 100644 --- a/nginx/nginx.conf.dev +++ b/nginx/nginx.conf.dev @@ -60,12 +60,12 @@ http { proxy_pass http://space:3002/spaces/; } - location /${BUCKET_NAME}/ { + 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://plane-minio:9000/uploads/; + proxy_pass http://plane-minio:9000/${BUCKET_NAME}; } } } diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index e719a0f15d..819c00f21d 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -68,12 +68,12 @@ http { proxy_pass http://space:3000/spaces/; } - location /${BUCKET_NAME}/ { + 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://plane-minio:9000/uploads/; + proxy_pass http://plane-minio:9000/${BUCKET_NAME}; } } } diff --git a/package.json b/package.json index 27a5389d2a..372fdc937f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.23.0", + "version": "0.23.1", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/constants/package.json b/packages/constants/package.json index 55306a6efd..cdf51bbaf1 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@plane/constants", - "version": "0.23.0", + "version": "0.23.1", "private": true, "main": "./index.ts" } diff --git a/packages/editor/package.json b/packages/editor/package.json index de1c6379fd..a60f1ada94 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor", - "version": "0.23.0", + "version": "0.23.1", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx index 93900700b2..2809fcee4e 100644 --- a/packages/editor/src/ce/extensions/document-extensions.tsx +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -1,6 +1,6 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { Extensions } from "@tiptap/core"; -import { SlashCommand } from "@/extensions"; +import { SlashCommands } from "@/extensions"; // plane editor types import { TIssueEmbedConfig } from "@/plane-editor/types"; // types @@ -14,7 +14,7 @@ type Props = { }; export const DocumentEditorAdditionalExtensions = (_props: Props) => { - const extensions: Extensions = [SlashCommand()]; + const extensions: Extensions = [SlashCommands()]; return extensions; }; diff --git a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx index 35b833bf9a..aa925abece 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx @@ -18,6 +18,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, + fileHandler, forwardedRef, handleEditorReady, id, @@ -38,6 +39,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ editorClassName, extensions, + fileHandler, forwardedRef, handleEditorReady, id, diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 73a600e2b5..8544157aa0 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types"; interface IDocumentReadOnlyEditor { id: string; @@ -19,6 +19,7 @@ interface IDocumentReadOnlyEditor { displayConfig?: TDisplayConfig; editorClassName?: string; embedHandler: any; + fileHandler: Pick; tabIndex?: number; handleEditorReady?: (value: boolean) => void; mentionHandler: { @@ -33,6 +34,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, + fileHandler, id, forwardedRef, handleEditorReady, @@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const editor = useReadOnlyEditor({ editorClassName, extensions, + fileHandler, forwardedRef, handleEditorReady, initialValue, diff --git a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx index fc0911bee6..e06826a289 100644 --- a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -14,14 +14,16 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { containerClassName, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", + fileHandler, + forwardedRef, id, initialValue, - forwardedRef, mentionHandler, } = props; const editor = useReadOnlyEditor({ editorClassName, + fileHandler, forwardedRef, initialValue, mentionHandler, diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index fe4d2d5137..53f766ee21 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react"; import { EditorWrapper } from "@/components/editors"; import { EditorBubbleMenu } from "@/components/menus"; // extensions -import { SideMenuExtension, SlashCommand } from "@/extensions"; +import { SideMenuExtension, SlashCommands } from "@/extensions"; // types import { EditorRefApi, IRichTextEditor } from "@/types"; @@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => { const { dragDropEnabled } = props; const getExtensions = useCallback(() => { - const extensions = [SlashCommand()]; + const extensions = [SlashCommands()]; extensions.push( SideMenuExtension({ diff --git a/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx new file mode 100644 index 0000000000..b48070775e --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/color-selector.tsx @@ -0,0 +1,106 @@ +import { Dispatch, FC, SetStateAction } from "react"; +import { Editor } from "@tiptap/react"; +import { ALargeSmall, Ban } from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { cn } from "@/helpers/common"; +import { BackgroundColorItem, TextColorItem } from "../menu-items"; + +type Props = { + editor: Editor; + isOpen: boolean; + setIsOpen: Dispatch>; +}; + +export const BubbleMenuColorSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive(c.key)); + const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive(c.key)); + + return ( +
+ + {isOpen && ( +
+
+

Text colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+
+ )} +
+ ); +}; diff --git a/packages/editor/src/core/components/menus/bubble-menu/index.ts b/packages/editor/src/core/components/menus/bubble-menu/index.ts index 71a98bada0..526feed3d1 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/index.ts +++ b/packages/editor/src/core/components/menus/bubble-menu/index.ts @@ -1,3 +1,4 @@ +export * from "./color-selector"; export * from "./link-selector"; export * from "./node-selector"; export * from "./root"; diff --git a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index 20335e8abb..eaa20ed26b 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,6 +1,6 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/core"; -import { Check, Trash } from "lucide-react"; +import { Check, Link, Trash } from "lucide-react"; // helpers import { cn, isValidHttpUrl } from "@/helpers/common"; import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; @@ -11,7 +11,9 @@ type Props = { setIsOpen: Dispatch>; }; -export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const BubbleMenuLinkSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + // refs const inputRef = useRef(null); const onLinkSubmit = useCallback(() => { @@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) }); return ( -
+
{isOpen && (
>; }; -export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { - const items: BubbleMenuItem[] = [ +export const BubbleMenuNodeSelector: FC = (props) => { + const { editor, isOpen, setIsOpen } = props; + + const items: EditorMenuItem[] = [ TextItem(editor), HeadingOneItem(editor), HeadingTwoItem(editor), @@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) CodeItem(editor), ]; - const activeItem = items.filter((item) => item.isActive()).pop() ?? { + const activeItem = items.filter((item) => item.isActive("")).pop() ?? { name: "Multiple", }; @@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) setIsOpen(!isOpen); e.stopPropagation(); }} - className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5" + className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors" > {activeItem?.name} - + - {isOpen && (
{items.map((item) => ( diff --git a/packages/editor/src/core/components/menus/bubble-menu/root.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx index ec72f15408..0f789dd8a1 100644 --- a/packages/editor/src/core/components/menus/bubble-menu/root.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,12 +1,13 @@ import { FC, useEffect, useState } from "react"; import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; -import { LucideIcon } from "lucide-react"; // components import { BoldItem, + BubbleMenuColorSelector, BubbleMenuLinkSelector, BubbleMenuNodeSelector, CodeItem, + EditorMenuItem, ItalicItem, StrikeThroughItem, UnderLineItem, @@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele // helpers import { cn } from "@/helpers/common"; -export interface BubbleMenuItem { - key: string; - name: string; - isActive: () => boolean; - command: () => void; - icon: LucideIcon; -} - type EditorBubbleMenuProps = Omit; export const EditorBubbleMenu: FC = (props: any) => { - const items: BubbleMenuItem[] = [ - ...(props.editor.isActive("code") - ? [] - : [ - BoldItem(props.editor), - ItalicItem(props.editor), - UnderLineItem(props.editor), - StrikeThroughItem(props.editor), - ]), - CodeItem(props.editor), - ]; + // states + const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); + const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); + const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); + const [isSelecting, setIsSelecting] = useState(false); + + const items: EditorMenuItem[] = props.editor.isActive("code") + ? [CodeItem(props.editor)] + : [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)]; const bubbleMenuProps: EditorBubbleMenuProps = { ...props, shouldShow: ({ state, editor }) => { const { selection } = state; - const { empty } = selection; if ( @@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC = (props: any) => { onHidden: () => { setIsNodeSelectorOpen(false); setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); }, }, }; - const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); - const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); - - const [isSelecting, setIsSelecting] = useState(false); - useEffect(() => { function handleMouseDown() { function handleMouseMove() { @@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC = (props: any) => { return ( - {isSelecting ? null : ( + {!isSelecting && ( <> - {!props.editor.isActive("table") && ( - { - setIsNodeSelectorOpen(!isNodeSelectorOpen); - setIsLinkSelectorOpen(false); - }} - /> - )} - {!props.editor.isActive("code") && ( - { - setIsLinkSelectorOpen(!isLinkSelectorOpen); - setIsNodeSelectorOpen(false); - }} - /> - )} -
+
+ {!props.editor.isActive("table") && ( + { + setIsNodeSelectorOpen((prev) => !prev); + setIsLinkSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsLinkSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsColorSelectorOpen(false); + }} + /> + )} +
+
+ {!props.editor.isActive("code") && ( + { + setIsColorSelectorOpen((prev) => !prev); + setIsNodeSelectorOpen(false); + setIsLinkSelectorOpen(false); + }} + /> + )} +
+
{items.map((item) => ( ))}
diff --git a/packages/editor/src/core/components/menus/menu-items.ts b/packages/editor/src/core/components/menus/menu-items.ts index cf10081f1e..5c420832ac 100644 --- a/packages/editor/src/core/components/menus/menu-items.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -20,12 +20,14 @@ import { Heading6, CaseSensitive, LucideIcon, + Palette, } from "lucide-react"; // helpers import { insertImage, insertTableCommand, setText, + toggleBackgroundColor, toggleBlockquote, toggleBold, toggleBulletList, @@ -40,18 +42,26 @@ import { toggleOrderedList, toggleStrike, toggleTaskList, + toggleTextColor, toggleUnderline, } from "@/helpers/editor-commands"; // types -import { TEditorCommands } from "@/types"; +import { TColorEditorCommands, TNonColorEditorCommands } from "@/types"; -export interface EditorMenuItem { - key: TEditorCommands; +export type EditorMenuItem = { name: string; - isActive: () => boolean; - command: () => void; + command: (...args: any) => void; icon: LucideIcon; -} +} & ( + | { + key: TNonColorEditorCommands; + isActive: () => boolean; + } + | { + key: TColorEditorCommands; + isActive: (color: string | undefined) => boolean; + } +); export const TextItem = (editor: Editor): EditorMenuItem => ({ key: "text", @@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) => icon: ImageIcon, }) as const; -export function getEditorMenuItems(editor: Editor | null) { - if (!editor) { - return []; - } +export const TextColorItem = (editor: Editor): EditorMenuItem => ({ + key: "text-color", + name: "Color", + isActive: (color) => editor.isActive("customColor", { color }), + command: (color: string) => toggleTextColor(color, editor), + icon: Palette, +}); + +export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({ + key: "background-color", + name: "Background color", + isActive: (color) => editor.isActive("customColor", { backgroundColor: color }), + command: (color: string) => toggleBackgroundColor(color, editor), + icon: Palette, +}); + +export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => { + if (!editor) return []; + return [ TextItem(editor), HeadingOneItem(editor), @@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) { QuoteItem(editor), TableItem(editor), ImageItem(editor), + TextColorItem(editor), + BackgroundColorItem(editor), ]; -} +}; diff --git a/packages/editor/src/core/constants/common.ts b/packages/editor/src/core/constants/common.ts new file mode 100644 index 0000000000..7f4f7f66f3 --- /dev/null +++ b/packages/editor/src/core/constants/common.ts @@ -0,0 +1,61 @@ +export const COLORS_LIST: { + key: string; + label: string; + textColor: string; + backgroundColor: string; +}[] = [ + { + key: "gray", + label: "Gray", + textColor: "var(--editor-colors-gray-text)", + backgroundColor: "var(--editor-colors-gray-background)", + }, + { + key: "peach", + label: "Peach", + textColor: "var(--editor-colors-peach-text)", + backgroundColor: "var(--editor-colors-peach-background)", + }, + { + key: "pink", + label: "Pink", + textColor: "var(--editor-colors-pink-text)", + backgroundColor: "var(--editor-colors-pink-background)", + }, + { + key: "orange", + label: "Orange", + textColor: "var(--editor-colors-orange-text)", + backgroundColor: "var(--editor-colors-orange-background)", + }, + { + key: "green", + label: "Green", + textColor: "var(--editor-colors-green-text)", + backgroundColor: "var(--editor-colors-green-background)", + }, + { + key: "light-blue", + label: "Light blue", + textColor: "var(--editor-colors-light-blue-text)", + backgroundColor: "var(--editor-colors-light-blue-background)", + }, + { + key: "dark-blue", + label: "Dark blue", + textColor: "var(--editor-colors-dark-blue-text)", + backgroundColor: "var(--editor-colors-dark-blue-background)", + }, + { + key: "purple", + label: "Purple", + textColor: "var(--editor-colors-purple-text)", + backgroundColor: "var(--editor-colors-purple-background)", + }, + // { + // key: "pink-blue-gradient", + // label: "Pink blue gradient", + // textColor: "var(--editor-colors-pink-blue-gradient-text)", + // backgroundColor: "var(--editor-colors-pink-blue-gradient-background)", + // }, +]; diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 1cedd51396..10d4df2026 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -16,6 +16,7 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props import { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; +import { CustomColorExtension } from "./custom-color"; export const CoreEditorExtensionsWithoutProps = [ StarterKit.configure({ @@ -83,6 +84,7 @@ export const CoreEditorExtensionsWithoutProps = [ TableCell, TableRow, CustomMentionWithoutProps(), + CustomColorExtension, ]; export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/custom-color.ts b/packages/editor/src/core/extensions/custom-color.ts new file mode 100644 index 0000000000..dc966816c5 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-color.ts @@ -0,0 +1,133 @@ +import { Mark, mergeAttributes } from "@tiptap/core"; +// constants +import { COLORS_LIST } from "@/constants/common"; + +declare module "@tiptap/core" { + interface Commands { + color: { + /** + * Set the text color + * @param {string} color The color to set + * @example editor.commands.setTextColor('red') + */ + setTextColor: (color: string) => ReturnType; + + /** + * Unset the text color + * @example editor.commands.unsetTextColor() + */ + unsetTextColor: () => ReturnType; + /** + * Set the background color + * @param {string} backgroundColor The color to set + * @example editor.commands.setBackgroundColor('red') + */ + setBackgroundColor: (backgroundColor: string) => ReturnType; + + /** + * Unset the background color + * @example editor.commands.unsetBackgroundColorColor() + */ + unsetBackgroundColor: () => ReturnType; + }; + } +} + +export const CustomColorExtension = Mark.create({ + name: "customColor", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + color: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"), + renderHTML: (attributes: { color: string }) => { + const { color } = attributes; + if (!color) { + return {}; + } + + let elementAttributes: Record = { + "data-text-color": color, + }; + + if (!COLORS_LIST.find((c) => c.key === color)) { + elementAttributes = { + ...elementAttributes, + style: `color: ${color}`, + }; + } + + return elementAttributes; + }, + }, + backgroundColor: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"), + renderHTML: (attributes: { backgroundColor: string }) => { + const { backgroundColor } = attributes; + if (!backgroundColor) { + return {}; + } + + let elementAttributes: Record = { + "data-background-color": backgroundColor, + }; + + if (!COLORS_LIST.find((c) => c.key === backgroundColor)) { + elementAttributes = { + ...elementAttributes, + style: `background-color: ${backgroundColor}`, + }; + } + + return elementAttributes; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "span", + getAttrs: (node) => node.getAttribute("data-text-color") && null, + }, + { + tag: "span", + getAttrs: (node) => node.getAttribute("data-background-color") && null, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setTextColor: + (color: string) => + ({ chain }) => + chain().setMark(this.name, { color }).run(), + unsetTextColor: + () => + ({ chain }) => + chain().setMark(this.name, { color: null }).run(), + setBackgroundColor: + (backgroundColor: string) => + ({ chain }) => + chain().setMark(this.name, { backgroundColor }).run(), + unsetBackgroundColor: + () => + ({ chain }) => + chain().setMark(this.name, { backgroundColor: null }).run(), + }; + }, +}); diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index 42b51de5fb..65d9a38433 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -42,6 +42,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & { setFailedToLoadImage: (isError: boolean) => void; editorContainer: HTMLDivElement | null; setEditorContainer: (editorContainer: HTMLDivElement | null) => void; + src: string; }; export const CustomImageBlock: React.FC = (props) => { @@ -55,14 +56,15 @@ export const CustomImageBlock: React.FC = (props) => { getPos, editor, editorContainer, + src: remoteImageSrc, setEditorContainer, } = props; - const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs; + const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs; // states const [size, setSize] = useState({ - width: ensurePixelString(width, "35%"), - height: ensurePixelString(height, "auto"), - aspectRatio: aspectRatio || 1, + width: ensurePixelString(nodeWidth, "35%"), + height: ensurePixelString(nodeHeight, "auto"), + aspectRatio: nodeAspectRatio || null, }); const [isResizing, setIsResizing] = useState(false); const [initialResizeComplete, setInitialResizeComplete] = useState(false); @@ -70,6 +72,19 @@ export const CustomImageBlock: React.FC = (props) => { const containerRef = useRef(null); const containerRect = useRef(null); const imageRef = useRef(null); + const [hasErroredOnFirstLoad, setHasErroredOnFirstLoad] = useState(false); + const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false); + + const updateAttributesSafely = useCallback( + (attributes: Partial, errorMessage: string) => { + try { + updateAttributes(attributes); + } catch (error) { + console.error(`${errorMessage}:`, error); + } + }, + [updateAttributes] + ); const handleImageLoad = useCallback(() => { const img = imageRef.current; @@ -91,40 +106,50 @@ export const CustomImageBlock: React.FC = (props) => { } setEditorContainer(closestEditorContainer); - const aspectRatio = img.naturalWidth / img.naturalHeight; + const aspectRatioCalculated = img.naturalWidth / img.naturalHeight; - if (width === "35%") { + if (nodeWidth === "35%") { const editorWidth = closestEditorContainer.clientWidth; const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); - const initialHeight = initialWidth / aspectRatio; + const initialHeight = initialWidth / aspectRatioCalculated; const initialComputedSize = { width: `${Math.round(initialWidth)}px` satisfies Pixel, height: `${Math.round(initialHeight)}px` satisfies Pixel, - aspectRatio: aspectRatio, + aspectRatio: aspectRatioCalculated, }; setSize(initialComputedSize); - updateAttributes(initialComputedSize); + updateAttributesSafely( + initialComputedSize, + "Failed to update attributes while initializing an image for the first time:" + ); } else { // as the aspect ratio in not stored for old images, we need to update the attrs - setSize((prevSize) => { - const newSize = { ...prevSize, aspectRatio }; - updateAttributes(newSize); - return newSize; - }); + // or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs + if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) { + setSize((prevSize) => { + const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated }; + updateAttributesSafely( + newSize, + "Failed to update attributes while initializing images with width but no aspect ratio:" + ); + return newSize; + }); + } } setInitialResizeComplete(true); - }, [width, updateAttributes, editorContainer]); + }, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]); // for real time resizing useLayoutEffect(() => { setSize((prevSize) => ({ ...prevSize, - width: ensurePixelString(width), - height: ensurePixelString(height), + width: ensurePixelString(nodeWidth), + height: ensurePixelString(nodeHeight), + aspectRatio: nodeAspectRatio, })); - }, [width, height]); + }, [nodeWidth, nodeHeight, nodeAspectRatio]); const handleResize = useCallback( (e: MouseEvent | TouchEvent) => { @@ -137,12 +162,12 @@ export const CustomImageBlock: React.FC = (props) => { setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` })); }, - [size] + [size.aspectRatio] ); const handleResizeEnd = useCallback(() => { setIsResizing(false); - updateAttributes(size); + updateAttributesSafely(size, "Failed to update attributes at the end of resizing:"); }, [size, updateAttributes]); const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { @@ -160,11 +185,15 @@ export const CustomImageBlock: React.FC = (props) => { window.addEventListener("mousemove", handleResize); window.addEventListener("mouseup", handleResizeEnd); window.addEventListener("mouseleave", handleResizeEnd); + window.addEventListener("touchmove", handleResize); + window.addEventListener("touchend", handleResizeEnd); return () => { window.removeEventListener("mousemove", handleResize); window.removeEventListener("mouseup", handleResizeEnd); window.removeEventListener("mouseleave", handleResizeEnd); + window.removeEventListener("touchmove", handleResize); + window.removeEventListener("touchend", handleResizeEnd); }; } }, [isResizing, handleResize, handleResizeEnd]); @@ -181,11 +210,13 @@ export const CustomImageBlock: React.FC = (props) => { // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete - const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete; - // show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) - const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete; + const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete || hasErroredOnFirstLoad; + // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageUtils = remoteImageSrc && initialResizeComplete; + // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete; // show the preview image from the file system if the remote image's src is not set - const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem; + const displayedImageSrc = remoteImageSrc || imageFromFileSystem; return (
= (props) => { onMouseDown={handleImageMouseDown} style={{ width: size.width, - aspectRatio: size.aspectRatio, + ...(size.aspectRatio && { aspectRatio: size.aspectRatio }), }} > {showImageLoader && ( @@ -207,9 +238,26 @@ export const CustomImageBlock: React.FC = (props) => { ref={imageRef} src={displayedImageSrc} onLoad={handleImageLoad} - onError={(e) => { - console.error("Error loading image", e); - setFailedToLoadImage(true); + onError={async (e) => { + // for old image extension this command doesn't exist or if the image failed to load for the first time + if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) { + setFailedToLoadImage(true); + return; + } + + try { + setHasErroredOnFirstLoad(true); + // this is a type error from tiptap, don't remove await until it's fixed + await editor?.commands.restoreImage?.(node.attrs.src); + imageRef.current.src = remoteImageSrc; + } catch { + // if the image failed to even restore, then show the error state + setFailedToLoadImage(true); + console.error("Error while loading image", e); + } finally { + setHasErroredOnFirstLoad(false); + setHasTriedRestoringImageOnce(true); + } }} width={size.width} className={cn("image-component block rounded-md", { @@ -220,7 +268,7 @@ export const CustomImageBlock: React.FC = (props) => { })} style={{ width: size.width, - aspectRatio: size.aspectRatio, + ...(size.aspectRatio && { aspectRatio: size.aspectRatio }), }} /> {showImageUtils && ( @@ -239,7 +287,7 @@ export const CustomImageBlock: React.FC = (props) => { {selected && displayedImageSrc === remoteImageSrc && (
)} - {showImageUtils && ( + {showImageResizer && ( <>
= (props) => { } )} onMouseDown={handleResizeStart} + onTouchStart={handleResizeStart} /> )} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx index c37bcd29cd..bdb8280c5b 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -1,21 +1,23 @@ import { useEffect, useRef, useState } from "react"; -import { Node as ProsemirrorNode } from "@tiptap/pm/model"; -import { Editor, NodeViewWrapper } from "@tiptap/react"; +import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; // extensions import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; -export type CustomImageNodeViewProps = { +export type CustomImageComponentProps = { getPos: () => number; editor: Editor; - node: ProsemirrorNode & { + node: NodeViewProps["node"] & { attrs: ImageAttributes; }; - updateAttributes: (attrs: Record) => void; + updateAttributes: (attrs: ImageAttributes) => void; selected: boolean; }; +export type CustomImageNodeViewProps = NodeViewProps & CustomImageComponentProps; + export const CustomImageNode = (props: CustomImageNodeViewProps) => { const { getPos, editor, node, updateAttributes, selected } = props; + const { src: remoteImageSrc } = node.attrs; const [isUploaded, setIsUploaded] = useState(false); const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined); @@ -37,14 +39,13 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { // the image is already uploaded if the image-component node has src attribute // and we need to remove the blob from our file system useEffect(() => { - const remoteImageSrc = node.attrs.src; if (remoteImageSrc) { setIsUploaded(true); setImageFromFileSystem(undefined); } else { setIsUploaded(false); } - }, [node.attrs.src]); + }, [remoteImageSrc]); return ( @@ -54,6 +55,8 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { imageFromFileSystem={imageFromFileSystem} editorContainer={editorContainer} editor={editor} + // @ts-expect-error function not expected here, but will still work + src={editor?.commands?.getImageSource?.(remoteImageSrc)} getPos={getPos} node={node} setEditorContainer={setEditorContainer} @@ -67,6 +70,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { failedToLoadImage={failedToLoadImage} getPos={getPos} loadImageFromFileSystem={setImageFromFileSystem} + maxFileSize={editor.storage.imageComponent.maxFileSize} node={node} setIsUploaded={setIsUploaded} selected={selected} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index 89cf36ca52..36f1361ee8 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -1,44 +1,36 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; -import { Node as ProsemirrorNode } from "@tiptap/pm/model"; -import { Editor } from "@tiptap/core"; import { ImageIcon } from "lucide-react"; // helpers import { cn } from "@/helpers/common"; // hooks -import { useUploader, useDropZone } from "@/hooks/use-file-upload"; -// plugins -import { isFileValid } from "@/plugins/image"; +import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; // extensions -import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; +import { type CustomImageComponentProps, getImageComponentImageFileMap } from "@/extensions/custom-image"; -export const CustomImageUploader = (props: { - failedToLoadImage: boolean; - editor: Editor; - selected: boolean; +type CustomImageUploaderProps = CustomImageComponentProps & { + maxFileSize: number; loadImageFromFileSystem: (file: string) => void; + failedToLoadImage: boolean; setIsUploaded: (isUploaded: boolean) => void; - node: ProsemirrorNode & { - attrs: ImageAttributes; - }; - updateAttributes: (attrs: Record) => void; - getPos: () => number; -}) => { +}; + +export const CustomImageUploader = (props: CustomImageUploaderProps) => { const { - selected, - failedToLoadImage, editor, + failedToLoadImage, + getPos, loadImageFromFileSystem, + maxFileSize, node, + selected, setIsUploaded, updateAttributes, - getPos, } = props; - // ref + // refs const fileInputRef = useRef(null); - const hasTriggeredFilePickerRef = useRef(false); - const imageEntityId = node.attrs.id; - + const { id: imageEntityId } = node.attrs; + // derived values const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]); const onUpload = useCallback( @@ -73,8 +65,18 @@ export const CustomImageUploader = (props: { [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] ); // hooks - const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem }); - const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile }); + const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ + editor, + loadImageFromFileSystem, + maxFileSize, + onUpload, + }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + editor, + maxFileSize, + pos: getPos(), + uploader: uploadFile, + }); // the meta data of the image component const meta = useMemo( @@ -82,9 +84,6 @@ export const CustomImageUploader = (props: { [imageComponentImageFileMap, imageEntityId] ); - // if the image component is dropped, we check if it has an existing file - const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]); - // after the image component is mounted we start the upload process based on // it's uploaded useEffect(() => { @@ -100,27 +99,26 @@ export const CustomImageUploader = (props: { } }, [meta, uploadFile, imageComponentImageFileMap]); - // check if the image is dropped and set the local image as the existing file - useEffect(() => { - if (existingFile) { - uploadFile(existingFile); - } - }, [existingFile, uploadFile]); - const onFileChange = useCallback( - (e: ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - if (isFileValid(file)) { - uploadFile(file); - } + async (e: ChangeEvent) => { + e.preventDefault(); + const filesList = e.target.files; + if (!filesList) { + return; } + await uploadFirstImageAndInsertRemaining({ + editor, + filesList, + maxFileSize, + pos: getPos(), + uploader: uploadFile, + }); }, - [uploadFile] + [uploadFile, editor, getPos] ); const getDisplayMessage = useCallback(() => { - const isUploading = isImageBeingUploaded || existingFile; + const isUploading = isImageBeingUploaded; if (failedToLoadImage) { return "Error loading image"; } @@ -134,13 +132,14 @@ export const CustomImageUploader = (props: { } return "Add an image"; - }, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]); + }, [draggedInside, failedToLoadImage, isImageBeingUploaded]); return (
{ - if (!failedToLoadImage) { + if (!failedToLoadImage && editor.isEditable) { fileInputRef.current?.click(); } }} @@ -167,6 +166,7 @@ export const CustomImageUploader = (props: { type="file" accept=".jpg,.jpeg,.png,.webp" onChange={onFileChange} + multiple />
); diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 939d97668f..2c5e2bb8d4 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -22,6 +22,8 @@ declare module "@tiptap/core" { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (file: File) => () => Promise | undefined; + restoreImage: (src: string) => () => Promise; + getImageSource?: (path: string) => () => string; }; } } @@ -36,7 +38,13 @@ export interface UploadImageExtensionStorage { export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; export const CustomImageExtension = (props: TFileHandler) => { - const { upload, delete: deleteImage, restore: restoreImage } = props; + const { + getAssetSrc, + upload, + delete: deleteImageFn, + restore: restoreImageFn, + validation: { maxFileSize }, + } = props; return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", @@ -78,23 +86,6 @@ export const CustomImageExtension = (props: TFileHandler) => { return ["image-component", mergeAttributes(HTMLAttributes)]; }, - onCreate(this) { - const imageSources = new Set(); - this.editor.state.doc.descendants((node) => { - if (node.type.name === this.name) { - imageSources.add(node.attrs.src); - } - }); - imageSources.forEach(async (src) => { - try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); - } catch (error) { - console.error("Error restoring image: ", error); - } - }); - }, - addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -104,16 +95,35 @@ export const CustomImageExtension = (props: TFileHandler) => { addProseMirrorPlugins() { return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), + TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), ]; }, + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name) { + if (!node.attrs.src?.startsWith("http")) return; + + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + await restoreImageFn(src); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + addStorage() { return { fileMap: new Map(), deletedImageSet: new Map(), uploadInProgress: false, + maxFileSize, }; }, @@ -123,7 +133,13 @@ export const CustomImageExtension = (props: TFileHandler) => { (props: { file?: File; pos?: number; event: "insert" | "drop" }) => ({ commands }) => { // Early return if there's an invalid file being dropped - if (props?.file && !isFileValid(props.file)) { + if ( + props?.file && + !isFileValid({ + file: props.file, + maxFileSize, + }) + ) { return false; } @@ -166,6 +182,10 @@ export const CustomImageExtension = (props: TFileHandler) => { const fileUrl = await upload(file); return fileUrl; }, + restoreImage: (src: string) => async () => { + await restoreImageFn(src); + }, + getImageSource: (path: string) => () => getAssetSrc(path), }; }, diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index f7db8d6b0c..76edacbd0c 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -3,9 +3,13 @@ import { Image } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // components import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; +// types +import { TFileHandler } from "@/types"; -export const CustomReadOnlyImageExtension = () => - Image.extend, UploadImageExtensionStorage>({ +export const CustomReadOnlyImageExtension = (props: Pick) => { + const { getAssetSrc } = props; + + return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", selectable: false, group: "block", @@ -51,7 +55,14 @@ export const CustomReadOnlyImageExtension = () => }; }, + addCommands() { + return { + getImageSource: (path: string) => () => getAssetSrc(path), + }; + }, + addNodeView() { return ReactNodeViewRenderer(CustomImageNode); }, }); +}; diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx index 8d66a5f9f5..2044f03bf5 100644 --- a/packages/editor/src/core/extensions/drop.tsx +++ b/packages/editor/src/core/extensions/drop.tsx @@ -21,7 +21,7 @@ export const DropHandlerExtension = () => if (imageFiles.length > 0) { const pos = view.state.selection.from; - insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" }); + insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); } return true; } @@ -41,7 +41,7 @@ export const DropHandlerExtension = () => if (coordinates) { const pos = coordinates.pos; - insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" }); + insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); } return true; } @@ -54,7 +54,7 @@ export const DropHandlerExtension = () => }, }); -const insertImages = async ({ +export const insertImagesSafely = async ({ editor, files, initialPos, @@ -72,13 +72,6 @@ const insertImages = async ({ const docSize = editor.state.doc.content.size; pos = Math.min(pos, docSize); - // Check if the position has a non-empty node - const nodeAtPos = editor.state.doc.nodeAt(pos); - if (nodeAtPos && nodeAtPos.content.size > 0) { - // Move to the end of the current node - pos += nodeAtPos.nodeSize; - } - try { // Insert the image at the current position editor.commands.insertImageComponent({ file, pos, event }); diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 34787bd6a6..47361819fe 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -12,6 +12,7 @@ import { CustomCodeBlockExtension, CustomCodeInlineExtension, CustomCodeMarkPlugin, + CustomColorExtension, CustomHorizontalRule, CustomImageExtension, CustomKeymap, @@ -30,16 +31,11 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; +import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types"; type TArguments = { enableHistory: boolean; - fileConfig: { - deleteFile: DeleteImage; - restoreFile: RestoreImage; - cancelUploadImage?: () => void; - uploadFile: UploadImage; - }; + fileHandler: TFileHandler; mentionConfig: { mentionSuggestions?: () => Promise; mentionHighlights?: () => Promise; @@ -48,123 +44,120 @@ type TArguments = { tabIndex?: number; }; -export const CoreEditorExtensions = ({ - enableHistory, - fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile }, - mentionConfig, - placeholder, - tabIndex, -}: TArguments) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc pl-7 space-y-2", +export const CoreEditorExtensions = (args: TArguments) => { + const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args; + + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal pl-7 space-y-2", + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, }, - }, - listItem: { - HTMLAttributes: { - class: "not-prose space-y-2", + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, }, - }, - code: false, - codeBlock: false, - horizontalRule: false, - blockquote: false, - dropcursor: { - class: "text-custom-text-300", - }, - ...(enableHistory ? {} : { history: false }), - }), - CustomQuoteExtension, - DropHandlerExtension(), - CustomHorizontalRule.configure({ - HTMLAttributes: { - class: "my-4 border-custom-border-400", - }, - }), - CustomKeymap, - ListKeymap({ tabIndex }), - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - CustomTypographyExtension, - ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomImageExtension({ - delete: deleteFile, - restore: restoreFile, - upload: uploadFile, - cancel: cancelUploadImage ?? (() => {}), - }), - TiptapUnderline, - TextStyle, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2 space-y-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "relative", - }, - nested: true, - }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeMarkPlugin, - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformPastedText: true, - breaks: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - CustomMention({ - mentionSuggestions: mentionConfig.mentionSuggestions, - mentionHighlights: mentionConfig.mentionHighlights, - readonly: false, - }), - Placeholder.configure({ - placeholder: ({ editor, node }) => { - if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: { + class: "text-custom-text-300", + }, + ...(enableHistory ? {} : { history: false }), + }), + CustomQuoteExtension, + DropHandlerExtension(), + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, + }), + CustomKeymap, + ListKeymap({ tabIndex }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ImageExtension(fileHandler).configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + CustomImageExtension(fileHandler), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeMarkPlugin, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformPastedText: true, + breaks: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + CustomMention({ + mentionSuggestions: mentionConfig.mentionSuggestions, + mentionHighlights: mentionConfig.mentionHighlights, + readonly: false, + }), + Placeholder.configure({ + placeholder: ({ editor, node }) => { + if (node.type.name === "heading") return `Heading ${node.attrs.level}`; - if (editor.storage.imageComponent.uploadInProgress) return ""; + if (editor.storage.imageComponent.uploadInProgress) return ""; - const shouldHidePlaceholder = - editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + const shouldHidePlaceholder = + editor.isActive("table") || + editor.isActive("codeBlock") || + editor.isActive("image") || + editor.isActive("imageComponent"); - if (shouldHidePlaceholder) return ""; + if (shouldHidePlaceholder) return ""; - if (placeholder) { - if (typeof placeholder === "string") return placeholder; - else return placeholder(editor.isFocused, editor.getHTML()); - } + if (placeholder) { + if (typeof placeholder === "string") return placeholder; + else return placeholder(editor.isFocused, editor.getHTML()); + } - return "Press '/' for commands..."; - }, - includeChildren: true, - }), - CharacterCount, -]; + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + CharacterCount, + CustomColorExtension, + ]; +}; diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 1f15846a1a..f7666bfe24 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -5,22 +5,30 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par // plugins import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types -import { DeleteImage, RestoreImage } from "@/types"; +import { TFileHandler } from "@/types"; // extensions import { CustomImageNode } from "@/extensions"; -export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => - ImageExt.extend({ +export const ImageExtension = (fileHandler: TFileHandler) => { + const { + getAssetSrc, + delete: deleteImageFn, + restore: restoreImageFn, + validation: { maxFileSize }, + } = fileHandler; + + return ImageExt.extend({ addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, + addProseMirrorPlugins() { return [ - TrackImageDeletionPlugin(this.editor, deleteImage, this.name), - TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name), + TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name), ]; }, @@ -28,13 +36,14 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm const imageSources = new Set(); this.editor.state.doc.descendants((node) => { if (node.type.name === this.name) { + if (!node.attrs.src?.startsWith("http")) return; + imageSources.add(node.attrs.src); } }); imageSources.forEach(async (src) => { try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImageFn(src); } catch (error) { console.error("Error restoring image: ", error); } @@ -46,6 +55,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm return { deletedImageSet: new Map(), uploadInProgress: false, + maxFileSize, }; }, @@ -58,6 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm height: { default: null, }, + aspectRatio: { + default: null, + }, + }; + }, + + addCommands() { + return { + getImageSource: (path: string) => () => getAssetSrc(path), }; }, @@ -66,3 +85,4 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm return ReactNodeViewRenderer(CustomImageNode); }, }); +}; diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index bd9ca3c820..52e277a77d 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -14,6 +14,9 @@ export const ImageExtensionWithoutProps = () => height: { default: null, }, + aspectRatio: { + default: null, + }, }; }, diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 1605174b32..c884a43ee7 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -2,20 +2,36 @@ import Image from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // extensions import { CustomImageNode } from "@/extensions"; +// types +import { TFileHandler } from "@/types"; -export const ReadOnlyImageExtension = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - }; - }, - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, -}); +export const ReadOnlyImageExtension = (props: Pick) => { + const { getAssetSrc } = props; + + return Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, + + addCommands() { + return { + getImageSource: (path: string) => () => getAssetSrc(path), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 9209f9480f..5fe19760f2 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -6,10 +6,12 @@ export * from "./custom-list-keymap"; export * from "./image"; export * from "./issue-embed"; export * from "./mentions"; +export * from "./slash-commands"; export * from "./table"; export * from "./typography"; export * from "./core-without-props"; export * from "./custom-code-inline"; +export * from "./custom-color"; export * from "./drop"; export * from "./enter-key-extension"; export * from "./extensions"; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 1c0a9add7a..cd3bbb38f4 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -21,93 +21,108 @@ import { CustomMention, HeadingListExtension, CustomReadOnlyImageExtension, + CustomColorExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { IMentionHighlight } from "@/types"; +import { IMentionHighlight, TFileHandler } from "@/types"; -export const CoreReadOnlyEditorExtensions = (mentionConfig: { - mentionHighlights?: () => Promise; -}) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc pl-7 space-y-2", +type Props = { + fileHandler: Pick; + mentionConfig: { + mentionHighlights?: () => Promise; + }; +}; + +export const CoreReadOnlyEditorExtensions = (props: Props) => { + const { fileHandler, mentionConfig } = props; + + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal pl-7 space-y-2", + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, }, - }, - listItem: { - HTMLAttributes: { - class: "not-prose space-y-2", + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, }, - }, - code: false, - codeBlock: false, - horizontalRule: false, - blockquote: false, - dropcursor: false, - gapcursor: false, - }), - CustomQuoteExtension, - CustomHorizontalRule.configure({ - HTMLAttributes: { - class: "my-4 border-custom-border-400", - }, - }), - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - CustomTypographyExtension, - ReadOnlyImageExtension.configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomReadOnlyImageExtension(), - TiptapUnderline, - TextStyle, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2 space-y-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "relative pointer-events-none", - }, - nested: true, - }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformCopiedText: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - CustomMention({ - mentionHighlights: mentionConfig.mentionHighlights, - readonly: true, - }), - CharacterCount, - HeadingListExtension, -]; + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: false, + gapcursor: false, + }), + CustomQuoteExtension, + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, + }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ReadOnlyImageExtension({ + getAssetSrc: fileHandler.getAssetSrc, + }).configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + CustomReadOnlyImageExtension({ + getAssetSrc: fileHandler.getAssetSrc, + }), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative pointer-events-none", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + CustomMention({ + mentionHighlights: mentionConfig.mentionHighlights, + readonly: true, + }), + CharacterCount, + CustomColorExtension, + HeadingListExtension, + ]; +}; diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx index 616e315e20..5ab6fbdf5b 100644 --- a/packages/editor/src/core/extensions/side-menu.tsx +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -42,7 +42,7 @@ export const SideMenuExtension = (props: Props) => { ai: aiEnabled, dragDrop: dragDropEnabled, }, - scrollThreshold: { up: 300, down: 100 }, + scrollThreshold: { up: 200, down: 100 }, }), ]; }, diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx deleted file mode 100644 index 2be8d89d96..0000000000 --- a/packages/editor/src/core/extensions/slash-commands.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; -import { Editor, Range, Extension } from "@tiptap/core"; -import { ReactRenderer } from "@tiptap/react"; -import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; -import tippy from "tippy.js"; -import { - CaseSensitive, - Code2, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - ImageIcon, - List, - ListOrdered, - ListTodo, - MinusSquare, - Quote, - Table, -} from "lucide-react"; -// helpers -import { cn } from "@/helpers/common"; -import { - insertTableCommand, - toggleBlockquote, - toggleBulletList, - toggleOrderedList, - toggleTaskList, - toggleHeadingOne, - toggleHeadingTwo, - toggleHeadingThree, - toggleHeadingFour, - toggleHeadingFive, - toggleHeadingSix, - insertImage, -} from "@/helpers/editor-commands"; -// types -import { CommandProps, ISlashCommandItem } from "@/types"; - -interface CommandItemProps { - key: string; - title: string; - description: string; - icon: ReactNode; -} - -export type SlashCommandOptions = { - suggestion: Omit; -}; - -const Command = Extension.create({ - name: "slash-command", - addOptions() { - return { - suggestion: { - char: "/", - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { - props.command({ editor, range }); - }, - allow({ editor }: { editor: Editor }) { - const { selection } = editor.state; - - const parentNode = selection.$from.node(selection.$from.depth); - const blockType = parentNode.type.name; - - if (blockType === "codeBlock") { - return false; - } - - if (editor.isActive("table")) { - return false; - } - - return true; - }, - }, - }; - }, - addProseMirrorPlugins() { - return [ - Suggestion({ - editor: this.editor, - ...this.options.suggestion, - }), - ]; - }, -}); - -const getSuggestionItems = - (additionalOptions?: Array) => - ({ query }: { query: string }) => { - let slashCommands: ISlashCommandItem[] = [ - { - key: "text", - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], - icon: , - command: ({ editor, range }: CommandProps) => { - if (range) { - editor.chain().focus().deleteRange(range).clearNodes().run(); - } - editor.chain().focus().clearNodes().run(); - }, - }, - { - key: "h1", - title: "Heading 1", - description: "Big section heading.", - searchTerms: ["title", "big", "large"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingOne(editor, range); - }, - }, - { - key: "h2", - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingTwo(editor, range); - }, - }, - { - key: "h3", - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingThree(editor, range); - }, - }, - { - key: "h4", - title: "Heading 4", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFour(editor, range); - }, - }, - { - key: "h5", - title: "Heading 5", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingFive(editor, range); - }, - }, - { - key: "h6", - title: "Heading 6", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleHeadingSix(editor, range); - }, - }, - { - key: "to-do-list", - title: "To do", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleTaskList(editor, range); - }, - }, - { - key: "bulleted-list", - title: "Bullet list", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleBulletList(editor, range); - }, - }, - { - key: "numbered-list", - title: "Numbered list", - description: "Create a list with numbering.", - searchTerms: ["ordered"], - icon: , - command: ({ editor, range }: CommandProps) => { - toggleOrderedList(editor, range); - }, - }, - { - key: "table", - title: "Table", - description: "Create a table", - searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertTableCommand(editor, range); - }, - }, - { - key: "quote", - title: "Quote", - description: "Capture a quote.", - searchTerms: ["blockquote"], - icon: , - command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range), - }, - { - key: "code", - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], - icon: , - command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), - }, - { - key: "image", - title: "Image", - icon: , - description: "Insert an image", - searchTerms: ["img", "photo", "picture", "media", "upload"], - command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), - }, - { - key: "divider", - title: "Divider", - description: "Visually divide blocks.", - searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , - command: ({ editor, range }: CommandProps) => { - editor.chain().focus().deleteRange(range).setHorizontalRule().run(); - }, - }, - ]; - - if (additionalOptions) { - additionalOptions.map((item) => { - slashCommands.push(item); - }); - } - - slashCommands = slashCommands.filter((item) => { - if (typeof query === "string" && query.length > 0) { - const search = query.toLowerCase(); - return ( - item.title.toLowerCase().includes(search) || - item.description.toLowerCase().includes(search) || - (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search))) - ); - } - return true; - }); - - return slashCommands; - }; - -export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { - const containerHeight = container.offsetHeight; - const itemHeight = item ? item.offsetHeight : 0; - - const top = item.offsetTop; - const bottom = top + itemHeight; - - if (top < container.scrollTop) { - container.scrollTop -= container.scrollTop - top + 5; - } else if (bottom > containerHeight + container.scrollTop) { - container.scrollTop += bottom - containerHeight - container.scrollTop + 5; - } -}; - -const CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => { - // states - const [selectedIndex, setSelectedIndex] = useState(0); - // refs - const commandListContainer = useRef(null); - - const selectItem = useCallback( - (index: number) => { - const item = items[index]; - if (item) command(item); - }, - [command, items] - ); - - useEffect(() => { - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; - const onKeyDown = (e: KeyboardEvent) => { - if (navigationKeys.includes(e.key)) { - e.preventDefault(); - if (e.key === "ArrowUp") { - setSelectedIndex((selectedIndex + items.length - 1) % items.length); - return true; - } - if (e.key === "ArrowDown") { - setSelectedIndex((selectedIndex + 1) % items.length); - return true; - } - if (e.key === "Enter") { - selectItem(selectedIndex); - return true; - } - return false; - } - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [items, selectedIndex, setSelectedIndex, selectItem]); - - useEffect(() => { - setSelectedIndex(0); - }, [items]); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - - const item = container?.children[selectedIndex] as HTMLElement; - - if (item && container) updateScrollView(container, item); - }, [selectedIndex]); - - if (items.length <= 0) return null; - - return ( -
- {items.map((item, index) => ( - - ))} -
- ); -}; - -interface CommandListInstance { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; -} - -const renderItems = () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; - return { - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component = new ReactRenderer(CommandList, { - props, - editor: props.editor, - }); - - const tippyContainer = - document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); - - // @ts-expect-error Tippy overloads are messed up - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: tippyContainer, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component?.updateProps(props); - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - if (component?.ref?.onKeyDown(props)) { - return true; - } - return false; - }, - onExit: () => { - popup?.[0].destroy(); - component?.destroy(); - }, - }; -}; - -export const SlashCommand = (additionalOptions?: Array) => - Command.configure({ - suggestion: { - items: getSuggestionItems(additionalOptions), - render: renderItems, - }, - }); diff --git a/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx new file mode 100644 index 0000000000..94cfb4c77a --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-items-list.tsx @@ -0,0 +1,294 @@ +import { + ALargeSmall, + CaseSensitive, + Code2, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + ImageIcon, + List, + ListOrdered, + ListTodo, + MinusSquare, + Quote, + Table, +} from "lucide-react"; +// constants +import { COLORS_LIST } from "@/constants/common"; +// helpers +import { + insertTableCommand, + toggleBlockquote, + toggleBulletList, + toggleOrderedList, + toggleTaskList, + toggleHeadingOne, + toggleHeadingTwo, + toggleHeadingThree, + toggleHeadingFour, + toggleHeadingFive, + toggleHeadingSix, + toggleTextColor, + toggleBackgroundColor, + insertImage, +} from "@/helpers/editor-commands"; +// types +import { CommandProps, ISlashCommandItem } from "@/types"; + +export type TSlashCommandSection = { + key: string; + title?: string; + items: ISlashCommandItem[]; +}; + +export const getSlashCommandFilteredSections = + (additionalOptions?: ISlashCommandItem[]) => + ({ query }: { query: string }): TSlashCommandSection[] => { + const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [ + { + key: "general", + items: [ + { + commandKey: "text", + key: "text", + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: , + command: ({ editor, range }: CommandProps) => { + if (range) { + editor.chain().focus().deleteRange(range).clearNodes().run(); + } + editor.chain().focus().clearNodes().run(); + }, + }, + { + commandKey: "h1", + key: "h1", + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }) => toggleHeadingOne(editor, range), + }, + { + commandKey: "h2", + key: "h2", + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }) => toggleHeadingTwo(editor, range), + }, + { + commandKey: "h3", + key: "h3", + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingThree(editor, range), + }, + { + commandKey: "h4", + key: "h4", + title: "Heading 4", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFour(editor, range), + }, + { + commandKey: "h5", + key: "h5", + title: "Heading 5", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingFive(editor, range), + }, + { + commandKey: "h6", + key: "h6", + title: "Heading 6", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }) => toggleHeadingSix(editor, range), + }, + { + commandKey: "to-do-list", + key: "to-do-list", + title: "To do", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: , + command: ({ editor, range }) => toggleTaskList(editor, range), + }, + { + commandKey: "bulleted-list", + key: "bulleted-list", + title: "Bullet list", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: , + command: ({ editor, range }) => toggleBulletList(editor, range), + }, + { + commandKey: "numbered-list", + key: "numbered-list", + title: "Numbered list", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }) => toggleOrderedList(editor, range), + }, + { + commandKey: "table", + key: "table", + title: "Table", + description: "Create a table", + searchTerms: ["table", "cell", "db", "data", "tabular"], + icon:
, + command: ({ editor, range }) => insertTableCommand(editor, range), + }, + { + commandKey: "quote", + key: "quote", + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }) => toggleBlockquote(editor, range), + }, + { + commandKey: "code", + key: "code", + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + { + commandKey: "image", + key: "image", + title: "Image", + icon: , + description: "Insert an image", + searchTerms: ["img", "photo", "picture", "media", "upload"], + command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), + }, + { + commandKey: "divider", + key: "divider", + title: "Divider", + description: "Visually divide blocks.", + searchTerms: ["line", "divider", "horizontal", "rule", "separate"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), + }, + ], + }, + { + key: "text-color", + title: "Colors", + items: [ + { + commandKey: "text-color", + key: "text-color-default", + title: "Default", + description: "Change text color", + searchTerms: ["color", "text", "default"], + icon: ( + + ), + command: ({ editor, range }) => toggleTextColor(undefined, editor, range), + }, + ...COLORS_LIST.map( + (color) => + ({ + commandKey: "text-color", + key: `text-color-${color.key}`, + title: color.label, + description: "Change text color", + searchTerms: ["color", "text", color.label], + icon: ( + + ), + command: ({ editor, range }) => toggleTextColor(color.key, editor, range), + }) as ISlashCommandItem + ), + ], + }, + { + key: "background-color", + title: "Background colors", + items: [ + { + commandKey: "background-color", + key: "background-color-default", + title: "Default background", + description: "Change background color", + searchTerms: ["color", "bg", "background", "default"], + icon: , + iconContainerStyle: { + borderRadius: "4px", + backgroundColor: "rgba(var(--color-background-100))", + border: "1px solid rgba(var(--color-border-300))", + }, + command: ({ editor, range }) => toggleTextColor(undefined, editor, range), + }, + ...COLORS_LIST.map( + (color) => + ({ + commandKey: "background-color", + key: `background-color-${color.key}`, + title: color.label, + description: "Change background color", + searchTerms: ["color", "bg", "background", color.label], + icon: , + iconContainerStyle: { + borderRadius: "4px", + backgroundColor: color.backgroundColor, + }, + command: ({ editor, range }) => toggleBackgroundColor(color.key, editor, range), + }) as ISlashCommandItem + ), + ], + }, + ]; + + additionalOptions?.map((item) => { + SLASH_COMMAND_SECTIONS?.[0]?.items.push(item); + }); + + const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({ + ...section, + items: section.items.filter((item) => { + if (typeof query !== "string") return; + + const lowercaseQuery = query.toLowerCase(); + return ( + item.title.toLowerCase().includes(lowercaseQuery) || + item.description.toLowerCase().includes(lowercaseQuery) || + item.searchTerms.some((t) => t.includes(lowercaseQuery)) + ); + }), + })); + + return filteredSlashSections.filter((s) => s.items.length !== 0); + }; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx new file mode 100644 index 0000000000..3a03c3b6a7 --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu-item.tsx @@ -0,0 +1,37 @@ +// helpers +import { cn } from "@/helpers/common"; +// types +import { ISlashCommandItem } from "@/types"; + +type Props = { + isSelected: boolean; + item: ISlashCommandItem; + itemIndex: number; + onClick: (e: React.MouseEvent) => void; + onMouseEnter: () => void; + sectionIndex: number; +}; + +export const CommandMenuItem: React.FC = (props) => { + const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props; + + return ( + + ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx new file mode 100644 index 0000000000..c6363bc51c --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +// components +import { TSlashCommandSection } from "./command-items-list"; +import { CommandMenuItem } from "./command-menu-item"; + +type Props = { + items: TSlashCommandSection[]; + command: any; +}; + +export const SlashCommandsMenu = (props: Props) => { + const { items: sections, command } = props; + // states + const [selectedIndex, setSelectedIndex] = useState({ + section: 0, + item: 0, + }); + // refs + const commandListContainer = useRef(null); + + const selectItem = useCallback( + (sectionIndex: number, itemIndex: number) => { + const item = sections[sectionIndex]?.items?.[itemIndex]; + if (item) command(item); + }, + [command, sections] + ); + // handle arrow key navigation + useEffect(() => { + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + const onKeyDown = (e: KeyboardEvent) => { + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + const currentSection = selectedIndex.section; + const currentItem = selectedIndex.item; + let nextSection = currentSection; + let nextItem = currentItem; + + if (e.key === "ArrowUp") { + nextItem = currentItem - 1; + if (nextItem < 0) { + nextSection = currentSection - 1; + if (nextSection < 0) nextSection = sections.length - 1; + nextItem = sections[nextSection].items.length - 1; + } + } + if (e.key === "ArrowDown") { + nextItem = currentItem + 1; + if (nextItem >= sections[currentSection].items.length) { + nextSection = currentSection + 1; + if (nextSection >= sections.length) nextSection = 0; + nextItem = 0; + } + } + if (e.key === "Enter") { + selectItem(currentSection, currentItem); + } + setSelectedIndex({ + section: nextSection, + item: nextItem, + }); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [sections, selectedIndex, setSelectedIndex, selectItem]); + // initialize the select index to 0 by default + useEffect(() => { + setSelectedIndex({ + section: 0, + item: 0, + }); + }, [sections]); + // scroll to the dropdown item when navigating via keyboard + useLayoutEffect(() => { + const container = commandListContainer?.current; + if (!container) return; + + const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement; + + // use scroll into view to bring the item in view if it is not in view + item?.scrollIntoView({ block: "nearest" }); + }, [sections, selectedIndex]); + + const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0; + + if (areSearchResultsEmpty) return null; + + return ( +
+ {sections.map((section, sectionIndex) => ( +
+ {section.title &&
{section.title}
} +
+ {section.items.map((item, itemIndex) => ( + { + e.stopPropagation(); + selectItem(sectionIndex, itemIndex); + }} + onMouseEnter={() => + setSelectedIndex({ + section: sectionIndex, + item: itemIndex, + }) + } + sectionIndex={sectionIndex} + /> + ))} +
+
+ ))} +
+ ); +}; diff --git a/packages/editor/src/core/extensions/slash-commands/index.ts b/packages/editor/src/core/extensions/slash-commands/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx new file mode 100644 index 0000000000..df70820dcf --- /dev/null +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -0,0 +1,111 @@ +import { Editor, Range, Extension } from "@tiptap/core"; +import { ReactRenderer } from "@tiptap/react"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; +import tippy from "tippy.js"; +// types +import { ISlashCommandItem } from "@/types"; +// components +import { getSlashCommandFilteredSections } from "./command-items-list"; +import { SlashCommandsMenu } from "./command-menu"; + +export type SlashCommandOptions = { + suggestion: Omit; +}; + +const Command = Extension.create({ + name: "slash-command", + addOptions() { + return { + suggestion: { + char: "/", + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { + props.command({ editor, range }); + }, + allow({ editor }: { editor: Editor }) { + const { selection } = editor.state; + + const parentNode = selection.$from.node(selection.$from.depth); + const blockType = parentNode.type.name; + + if (blockType === "codeBlock") { + return false; + } + + if (editor.isActive("table")) { + return false; + } + + return true; + }, + }, + }; + }, + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); + +interface CommandListInstance { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +} + +const renderItems = () => { + let component: ReactRenderer | null = null; + let popup: any | null = null; + return { + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component = new ReactRenderer(SlashCommandsMenu, { + props, + editor: props.editor, + }); + + const tippyContainer = + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: tippyContainer, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component?.updateProps(props); + + popup?.[0]?.setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0].hide(); + + return true; + } + + if (component?.ref?.onKeyDown(props)) { + return true; + } + return false; + }, + onExit: () => { + popup?.[0].destroy(); + component?.destroy(); + }, + }; +}; + +export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) => + Command.configure({ + suggestion: { + items: getSlashCommandFilteredSections(additionalOptions), + render: renderItems, + }, + }); diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index 66be05bb26..f4ebb6c2f2 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -154,3 +154,29 @@ export const unsetLinkEditor = (editor: Editor) => { export const setLinkEditor = (editor: Editor, url: string) => { editor.chain().focus().setLink({ href: url }).run(); }; + +export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => { + if (color) { + if (range) editor.chain().focus().deleteRange(range).setTextColor(color).run(); + else editor.chain().focus().setTextColor(color).run(); + } else { + if (range) editor.chain().focus().deleteRange(range).unsetTextColor().run(); + else editor.chain().focus().unsetTextColor().run(); + } +}; + +export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => { + if (color) { + if (range) { + editor.chain().focus().deleteRange(range).setBackgroundColor(color).run(); + } else { + editor.chain().focus().setBackgroundColor(color).run(); + } + } else { + if (range) { + editor.chain().focus().deleteRange(range).unsetBackgroundColor().run(); + } else { + editor.chain().focus().unsetBackgroundColor().run(); + } + } +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index c79d2204a4..be154c26a6 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -90,12 +90,7 @@ export const useEditor = (props: CustomEditorProps) => { extensions: [ ...CoreEditorExtensions({ enableHistory, - fileConfig: { - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - restoreFile: fileHandler.restore, - cancelUploadImage: fileHandler.cancel, - }, + fileHandler, mentionConfig: { mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), mentionHighlights: mentionHandler.highlights, @@ -141,7 +136,7 @@ export const useEditor = (props: CustomEditorProps) => { forwardedRef, () => ({ clearEditor: (emitUpdate = false) => { - editorRef.current?.commands.clearContent(emitUpdate); + editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); @@ -151,7 +146,8 @@ export const useEditor = (props: CustomEditorProps) => { insertContentAtSavedSelection(editorRef, content, savedSelection); } }, - executeMenuItemCommand: (itemKey: TEditorCommands) => { + executeMenuItemCommand: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); @@ -160,6 +156,8 @@ export const useEditor = (props: CustomEditorProps) => { if (item) { if (item.key === "image") { item.command(savedSelectionRef.current); + } else if (itemKey === "text-color" || itemKey === "background-color") { + item.command(props.color); } else { item.command(); } @@ -167,12 +165,19 @@ export const useEditor = (props: CustomEditorProps) => { console.warn(`No command found for item: ${itemKey}`); } }, - isMenuItemActive: (itemName: TEditorCommands): boolean => { + isMenuItemActive: (props) => { + const { itemKey } = props; const editorItems = getEditorMenuItems(editorRef.current); - const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName); - const item = getEditorMenuItem(itemName); - return item ? item.isActive() : false; + const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); + const item = getEditorMenuItem(itemKey); + if (!item) return false; + + if (itemKey === "text-color" || itemKey === "background-color") { + return item.isActive(props.color); + } else { + return item.isActive(""); + } }, onHeadingChange: (callback: (headings: IMarking[]) => void) => { // Subscribe to update event emitted from headers extension diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index 5dfa025e59..f5f930f290 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,16 +1,20 @@ import { DragEvent, useCallback, useEffect, useState } from "react"; import { Editor } from "@tiptap/core"; +// extensions +import { insertImagesSafely } from "@/extensions/drop"; +// plugins import { isFileValid } from "@/plugins/image"; -export const useUploader = ({ - onUpload, - editor, - loadImageFromFileSystem, -}: { - onUpload: (url: string) => void; +type TUploaderArgs = { editor: Editor; loadImageFromFileSystem: (file: string) => void; -}) => { + maxFileSize: number; + onUpload: (url: string) => void; +}; + +export const useUploader = (args: TUploaderArgs) => { + const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; + // states const [uploading, setUploading] = useState(false); const uploadFile = useCallback( @@ -22,7 +26,10 @@ export const useUploader = ({ setUploading(true); const fileNameTrimmed = trimFileName(file.name); const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); - const isValid = isFileValid(fileWithTrimmedName); + const isValid = isFileValid({ + file: fileWithTrimmedName, + maxFileSize, + }); if (!isValid) { setImageUploadInProgress(false); return; @@ -63,7 +70,16 @@ export const useUploader = ({ return { uploading, uploadFile }; }; -export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => { +type TDropzoneArgs = { + editor: Editor; + maxFileSize: number; + pos: number; + uploader: (file: File) => Promise; +}; + +export const useDropZone = (args: TDropzoneArgs) => { + const { editor, maxFileSize, pos, uploader } = args; + // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -86,40 +102,22 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => }, []); const onDrop = useCallback( - (e: DragEvent) => { + async (e: DragEvent) => { + e.preventDefault(); setDraggedInside(false); if (e.dataTransfer.files.length === 0) { return; } - - const fileList = e.dataTransfer.files; - - const files: File[] = []; - - for (let i = 0; i < fileList.length; i += 1) { - const item = fileList.item(i); - if (item) { - files.push(item); - } - } - - if (files.some((file) => file.type.indexOf("image") === -1)) { - return; - } - - e.preventDefault(); - - const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1); - - const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined; - - if (file) { - uploader(file); - } else { - console.error("No file found"); - } + const filesList = e.dataTransfer.files; + await uploadFirstImageAndInsertRemaining({ + editor, + filesList, + maxFileSize, + pos, + uploader, + }); }, - [uploader] + [uploader, editor, pos] ); const onDragEnter = () => { @@ -143,3 +141,51 @@ function trimFileName(fileName: string, maxLength = 100) { return fileName; } + +type TMultipleImagesArgs = { + editor: Editor; + filesList: FileList; + maxFileSize: number; + pos: number; + uploader: (file: File) => Promise; +}; + +// Upload the first image and insert the remaining images for uploading multiple image +// post insertion of image-component +export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) { + const { editor, filesList, maxFileSize, pos, uploader } = args; + const filteredFiles: File[] = []; + for (let i = 0; i < filesList.length; i += 1) { + const item = filesList.item(i); + if ( + item && + item.type.indexOf("image") !== -1 && + isFileValid({ + file: item, + maxFileSize, + }) + ) { + filteredFiles.push(item); + } + } + if (filteredFiles.length !== filesList.length) { + console.warn("Some files were not images and have been ignored."); + } + if (filteredFiles.length === 0) { + console.error("No image files found to upload"); + return; + } + + // Upload the first image + const firstFile = filteredFiles[0]; + uploader(firstFile); + + // Insert the remaining images + const remainingFiles = filteredFiles.slice(1); + + if (remainingFiles.length > 0) { + const docSize = editor.state.doc.content.size; + const posOfNextImageToBeInserted = Math.min(pos + 1, docSize); + insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" }); + } +} diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index 1aff29aa74..9fa73c3ecb 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit editorClassName, editorProps = {}, extensions, + fileHandler, forwardedRef, handleEditorReady, id, @@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit document: provider.document, }), ], + fileHandler, forwardedRef, handleEditorReady, mentionHandler, diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index add0508b99..23ce023adc 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types"; interface CustomReadOnlyEditorProps { initialValue?: string; @@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps { forwardedRef?: MutableRefObject; extensions?: any; editorProps?: EditorProps; + fileHandler: Pick; handleEditorReady?: (value: boolean) => void; mentionHandler: { highlights: () => Promise; @@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { forwardedRef, extensions = [], editorProps = {}, + fileHandler, handleEditorReady, mentionHandler, provider, @@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { }, extensions: [ ...CoreReadOnlyEditorExtensions({ - mentionHighlights: mentionHandler.highlights, + mentionConfig: { + mentionHighlights: mentionHandler.highlights, + }, + fileHandler, }), ...extensions, ], @@ -70,8 +75,8 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { const editorRef: MutableRefObject = useRef(null); useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); + clearEditor: (emitUpdate = false) => { + editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts index eb77d21bc8..7fc30805af 100644 --- a/packages/editor/src/core/plugins/drag-handle.ts +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -253,14 +253,46 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); + const isScrollable = (node: HTMLElement | SVGElement) => { + if (!(node instanceof HTMLElement || node instanceof SVGElement)) { + return false; + } + const style = getComputedStyle(node); + return ["overflow", "overflow-y"].some((propertyName) => { + const value = style.getPropertyValue(propertyName); + return value === "auto" || value === "scroll"; + }); + }; + + const getScrollParent = (node: HTMLElement | SVGElement) => { + let currentParent = node.parentElement; + while (currentParent) { + if (isScrollable(currentParent)) { + return currentParent; + } + currentParent = currentParent.parentElement; + } + return document.scrollingElement || document.documentElement; + }; + + const maxScrollSpeed = 100; + dragHandleElement.addEventListener("drag", (e) => { hideDragHandle(); - const frameRenderer = document.querySelector(".frame-renderer"); - if (!frameRenderer) return; - if (e.clientY < options.scrollThreshold.up) { - frameRenderer.scrollBy({ top: -70, behavior: "smooth" }); - } else if (window.innerHeight - e.clientY < options.scrollThreshold.down) { - frameRenderer.scrollBy({ top: 70, behavior: "smooth" }); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + if (e.clientY < scrollThreshold.up) { + const overflow = scrollThreshold.up - e.clientY; + const ratio = Math.min(overflow / scrollThreshold.up, 1); + const scrollAmount = -maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); + } else if (window.innerHeight - e.clientY < scrollThreshold.down) { + const overflow = e.clientY - (window.innerHeight - scrollThreshold.down); + const ratio = Math.min(overflow / scrollThreshold.down, 1); + const scrollAmount = maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); } }); diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 72bb913ae7..bcede77072 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -17,6 +17,8 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag }); transactions.forEach((transaction) => { + // if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically) + if (transaction.getMeta("skipImageDeletion")) return; // transaction could be a selection if (!transaction.docChanged) return; @@ -45,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag }); async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + if (!src) return; try { - if (!src) return; - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await deleteImage(assetUrlWithWorkspaceId); + await deleteImage(src); } catch (error) { console.error("Error deleting image: ", error); } diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index d722e53a63..4eecf01d7e 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -25,6 +25,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor if (node.type.name !== nodeType) return; if (pos < 0 || pos > newState.doc.content.size) return; if (oldImageSources.has(node.attrs.src)) return; + // if the src is just a id (private bucket), then we don't need to handle restore from here but + // only while it fails to load + if (!node.attrs.src?.startsWith("http")) return; addedImages.push(node as ImageNode); }); @@ -48,10 +51,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor }); async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { + if (!src) return; try { - if (!src) return; - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImage(src); } catch (error) { console.error("Error restoring image: ", error); throw error; diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts index c86e99335f..db88f3f73c 100644 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts @@ -1,25 +1,26 @@ -export function isFileValid(file: File, showAlert = true): boolean { +type TArgs = { + file: File; + maxFileSize: number; +}; + +export const isFileValid = (args: TArgs): boolean => { + const { file, maxFileSize } = args; + if (!file) { - if (showAlert) { - alert("No file selected. Please select a file to upload."); - } + alert("No file selected. Please select a file to upload."); return false; } const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; if (!allowedTypes.includes(file.type)) { - if (showAlert) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); - } + alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); return false; } - if (file.size > 5 * 1024 * 1024) { - if (showAlert) { - alert("File size too large. Please select a file smaller than 5MB."); - } + if (file.size > maxFileSize) { + alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`); return false; } return true; -} +}; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 4b706a7f9f..60721a5a66 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { }; export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { + fileHandler: Pick; forwardedRef?: React.MutableRefObject; }; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 93d612e599..67043ef9a1 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,10 +1,18 @@ import { DeleteImage, RestoreImage, UploadImage } from "@/types"; export type TFileHandler = { + getAssetSrc: (path: string) => string; cancel: () => void; delete: DeleteImage; upload: UploadImage; restore: RestoreImage; + validation: { + /** + * @description max file size in bytes + * @example enter 5242880( 5* 1024 * 1024) for 5MB + */ + maxFileSize: number; + }; }; export type TEditorFontStyle = "sans-serif" | "serif" | "monospace"; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 3624fa046c..31b315c1ca 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -6,14 +6,15 @@ import { IMentionHighlight, IMentionSuggestion, TAIHandler, + TColorEditorCommands, TDisplayConfig, TEditorCommands, TEmbedConfig, TExtensions, TFileHandler, + TNonColorEditorCommands, TServerHandler, } from "@/types"; - // editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; @@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = { export interface EditorRefApi extends EditorReadOnlyRefApi { setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: (itemKey: TEditorCommands) => void; - isMenuItemActive: (itemKey: TEditorCommands) => boolean; + executeMenuItemCommand: ( + props: + | { + itemKey: TNonColorEditorCommands; + } + | { + itemKey: TColorEditorCommands; + color: string | undefined; + } + ) => void; + isMenuItemActive: ( + props: + | { + itemKey: TNonColorEditorCommands; + } + | { + itemKey: TColorEditorCommands; + color: string | undefined; + } + ) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; isEditorReadyToDiscard: () => boolean; @@ -89,6 +108,7 @@ export interface IReadOnlyEditorProps { containerClassName?: string; displayConfig?: TDisplayConfig; editorClassName?: string; + fileHandler: Pick; forwardedRef?: React.MutableRefObject; id: string; initialValue: string; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts index c1b174a480..5c707bf33d 100644 --- a/packages/editor/src/core/types/image.ts +++ b/packages/editor/src/core/types/image.ts @@ -1,5 +1,5 @@ -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; +export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; +export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; export type UploadImage = (file: File) => Promise; diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 3cb9d76b0e..ce3408a34f 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { CSSProperties } from "react"; import { Editor, Range } from "@tiptap/core"; export type TEditorCommands = @@ -21,7 +21,12 @@ export type TEditorCommands = | "table" | "image" | "divider" - | "issue-embed"; + | "issue-embed" + | "text-color" + | "background-color"; + +export type TColorEditorCommands = Extract; +export type TNonColorEditorCommands = Exclude; export type CommandProps = { editor: Editor; @@ -29,10 +34,12 @@ export type CommandProps = { }; export type ISlashCommandItem = { - key: TEditorCommands; + commandKey: TEditorCommands; + key: string; title: string; description: string; searchTerms: string[]; - icon: ReactNode; + icon: React.ReactNode; + iconContainerStyle?: CSSProperties; command: ({ editor, range }: CommandProps) => void; }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index fc9fe1ac60..292dc53fb2 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,5 +1,6 @@ // styles // import "./styles/tailwind.css"; +import "src/styles/variables.css"; import "src/styles/editor.css"; import "src/styles/table.css"; import "src/styles/github-dark.css"; @@ -18,6 +19,9 @@ export { export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +// constants +export * from "@/constants/common"; + // helpers export * from "@/helpers/common"; export * from "@/helpers/editor-commands"; diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index e5047fb0c4..20d6b5fa0c 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -1,61 +1,3 @@ -.editor-container { - &.large-font { - --font-size-h1: 1.75rem; - --font-size-h2: 1.5rem; - --font-size-h3: 1.375rem; - --font-size-h4: 1.25rem; - --font-size-h5: 1.125rem; - --font-size-h6: 1rem; - --font-size-regular: 1rem; - --font-size-list: var(--font-size-regular); - --font-size-code: var(--font-size-regular); - - --line-height-h1: 2.25rem; - --line-height-h2: 2rem; - --line-height-h3: 1.75rem; - --line-height-h4: 1.5rem; - --line-height-h5: 1.5rem; - --line-height-h6: 1.5rem; - --line-height-regular: 1.5rem; - --line-height-list: var(--line-height-regular); - --line-height-code: var(--line-height-regular); - } - - &.small-font { - --font-size-h1: 1.4rem; - --font-size-h2: 1.2rem; - --font-size-h3: 1.1rem; - --font-size-h4: 1rem; - --font-size-h5: 0.9rem; - --font-size-h6: 0.8rem; - --font-size-regular: 0.8rem; - --font-size-list: var(--font-size-regular); - --font-size-code: var(--font-size-regular); - - --line-height-h1: 1.8rem; - --line-height-h2: 1.6rem; - --line-height-h3: 1.4rem; - --line-height-h4: 1.2rem; - --line-height-h5: 1.2rem; - --line-height-h6: 1.2rem; - --line-height-regular: 1.2rem; - --line-height-list: var(--line-height-regular); - --line-height-code: var(--line-height-regular); - } - - &.sans-serif { - --font-style: sans-serif; - } - - &.serif { - --font-style: serif; - } - - &.monospace { - --font-style: monospace; - } -} - .ProseMirror { position: relative; word-wrap: break-word; @@ -439,3 +381,62 @@ ul[data-type="taskList"] ul[data-type="taskList"] { margin-top: 0; } /* end tailwind typography */ + +/* text colors */ +[data-text-color="gray"] { + color: var(--editor-colors-gray-text); +} +[data-text-color="peach"] { + color: var(--editor-colors-peach-text); +} +[data-text-color="pink"] { + color: var(--editor-colors-pink-text); +} +[data-text-color="orange"] { + color: var(--editor-colors-orange-text); +} +[data-text-color="green"] { + color: var(--editor-colors-green-text); +} +[data-text-color="light-blue"] { + color: var(--editor-colors-light-blue-text); +} +[data-text-color="dark-blue"] { + color: var(--editor-colors-dark-blue-text); +} +[data-text-color="purple"] { + color: var(--editor-colors-purple-text); +} +/* [data-text-color="pink-blue-gradient"] { + background-clip: text; + color: transparent; + background-image: linear-gradient(90deg, #a961cd 50%, #e75962 100%); +} */ +/* end text colors */ + +/* background colors */ +[data-background-color="gray"] { + background-color: var(--editor-colors-gray-background); +} +[data-background-color="peach"] { + background-color: var(--editor-colors-peach-background); +} +[data-background-color="pink"] { + background-color: var(--editor-colors-pink-background); +} +[data-background-color="orange"] { + background-color: var(--editor-colors-orange-background); +} +[data-background-color="green"] { + background-color: var(--editor-colors-green-background); +} +[data-background-color="light-blue"] { + background-color: var(--editor-colors-light-blue-background); +} +[data-background-color="dark-blue"] { + background-color: var(--editor-colors-dark-blue-background); +} +[data-background-color="purple"] { + background-color: var(--editor-colors-purple-background); +} +/* end background colors */ diff --git a/packages/editor/src/styles/variables.css b/packages/editor/src/styles/variables.css new file mode 100644 index 0000000000..8b6595b871 --- /dev/null +++ b/packages/editor/src/styles/variables.css @@ -0,0 +1,96 @@ +: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 */ +} + +/* text background colors */ +[data-theme="light"], +[data-theme="light-contrast"] { + --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"], +[data-theme="dark-contrast"] { + --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 text background colors */ + +.editor-container { + /* font sizes and line heights */ + &.large-font { + --font-size-h1: 1.75rem; + --font-size-h2: 1.5rem; + --font-size-h3: 1.375rem; + --font-size-h4: 1.25rem; + --font-size-h5: 1.125rem; + --font-size-h6: 1rem; + --font-size-regular: 1rem; + --font-size-list: var(--font-size-regular); + --font-size-code: var(--font-size-regular); + + --line-height-h1: 2.25rem; + --line-height-h2: 2rem; + --line-height-h3: 1.75rem; + --line-height-h4: 1.5rem; + --line-height-h5: 1.5rem; + --line-height-h6: 1.5rem; + --line-height-regular: 1.5rem; + --line-height-list: var(--line-height-regular); + --line-height-code: var(--line-height-regular); + } + &.small-font { + --font-size-h1: 1.4rem; + --font-size-h2: 1.2rem; + --font-size-h3: 1.1rem; + --font-size-h4: 1rem; + --font-size-h5: 0.9rem; + --font-size-h6: 0.8rem; + --font-size-regular: 0.8rem; + --font-size-list: var(--font-size-regular); + --font-size-code: var(--font-size-regular); + + --line-height-h1: 1.8rem; + --line-height-h2: 1.6rem; + --line-height-h3: 1.4rem; + --line-height-h4: 1.2rem; + --line-height-h5: 1.2rem; + --line-height-h6: 1.2rem; + --line-height-regular: 1.2rem; + --line-height-list: var(--line-height-regular); + --line-height-code: var(--line-height-regular); + } + /* end font sizes and line heights */ + + /* font styles */ + &.sans-serif { + --font-style: "Inter", sans-serif; + } + &.serif { + --font-style: serif; + } + &.monospace { + --font-style: monospace; + } + /* end font styles */ +} diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 332e360804..335047356e 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,7 +1,7 @@ { "name": "@plane/eslint-config", "private": true, - "version": "0.23.0", + "version": "0.23.1", "files": [ "library.js", "next.js", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index c94c8f7634..b4b94db1f5 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@plane/helpers", - "version": "0.23.0", + "version": "0.23.1", "description": "Helper functions shared across multiple apps internally", "private": true, "main": "./dist/index.js", diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index bf35a97dcc..cec6628a64 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.23.0", + "version": "0.23.1", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/types/package.json b/packages/types/package.json index 5a7bd93d7d..5962ca25c9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.23.0", + "version": "0.23.1", "private": true, "types": "./src/index.d.ts", "main": "./src/index.d.ts" diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts index 2fb7ad51a7..ec417e73fe 100644 --- a/packages/types/src/analytics.d.ts +++ b/packages/types/src/analytics.d.ts @@ -20,7 +20,7 @@ export interface IAnalyticsData { } export interface IAnalyticsAssigneeDetails { - assignees__avatar: string | null; + assignees__avatar_url: string | null; assignees__display_name: string | null; assignees__first_name: string; assignees__id: string | null; @@ -87,7 +87,7 @@ export interface IExportAnalyticsFormData { } export interface IDefaultAnalyticsUser { - assignees__avatar: string | null; + assignees__avatar_url: string | null; assignees__first_name: string; assignees__last_name: string; assignees__display_name: string; @@ -99,7 +99,7 @@ export interface IDefaultAnalyticsResponse { issue_completed_month_wise: { month: number; count: number }[]; most_issue_closed_user: IDefaultAnalyticsUser[]; most_issue_created_user: { - created_by__avatar: string | null; + created_by__avatar_url: string | null; created_by__first_name: string; created_by__last_name: string; created_by__display_name: string; diff --git a/packages/types/src/current-user/accounts.d.ts b/packages/types/src/current-user/accounts.d.ts deleted file mode 100644 index d328f0529b..0000000000 --- a/packages/types/src/current-user/accounts.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type TCurrentUserAccount = { - id: string | undefined; - - user: string | undefined; - - provider_account_id: string | undefined; - provider: "google" | "github" | "gitlab" | string | undefined; - access_token: string | undefined; - access_token_expired_at: Date | undefined; - refresh_token: string | undefined; - refresh_token_expired_at: Date | undefined; - last_connected_at: Date | undefined; - metadata: object | undefined; - - created_at: Date | undefined; - updated_at: Date | undefined; -}; diff --git a/packages/types/src/current-user/index.ts b/packages/types/src/current-user/index.ts index 43a43b9cd3..aeb49bbab1 100644 --- a/packages/types/src/current-user/index.ts +++ b/packages/types/src/current-user/index.ts @@ -1,3 +1 @@ -export * from "./user"; export * from "./profile"; -export * from "./accounts"; diff --git a/packages/types/src/current-user/user.d.ts b/packages/types/src/current-user/user.d.ts deleted file mode 100644 index 9bc67b6cf3..0000000000 --- a/packages/types/src/current-user/user.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type TCurrentUser = { - id: string | undefined; - avatar: string | undefined; - cover_image: string | undefined; - date_joined: Date | undefined; - display_name: string | undefined; - email: string | undefined; - first_name: string | undefined; - last_name: string | undefined; - is_active: boolean; - is_bot: boolean; - is_email_verified: boolean; - is_managed: boolean; - mobile_number: string | undefined; - user_timezone: string | undefined; - username: string | undefined; - is_password_autoset: boolean; -}; - -export type TCurrentUserSettings = { - id: string | undefined; - email: string | undefined; - workspace: { - last_workspace_id: string | undefined; - last_workspace_slug: string | undefined; - fallback_workspace_id: string | undefined; - fallback_workspace_slug: string | undefined; - invites: number | undefined; - }; -}; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index fdcffb52b3..1c2fa273aa 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -20,7 +20,7 @@ export type TCycleEstimateDistributionBase = { export type TCycleAssigneesDistribution = { assignee_id: string | null; - avatar: string | null; + avatar_url: string | null; first_name: string | null; last_name: string | null; display_name: string | null; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 914ebb0c3d..df6a462b02 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -48,3 +48,15 @@ export enum ENotificationFilterType { ASSIGNED = "assigned", SUBSCRIBED = "subscribed", } + +export enum EFileAssetType { + COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION", + ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT", + ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION", + DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION", + PAGE_DESCRIPTION = "PAGE_DESCRIPTION", + PROJECT_COVER = "PROJECT_COVER", + USER_AVATAR = "USER_AVATAR", + USER_COVER = "USER_COVER", + WORKSPACE_LOGO = "WORKSPACE_LOGO", +} diff --git a/packages/types/src/file.d.ts b/packages/types/src/file.d.ts new file mode 100644 index 0000000000..8bcaade6c0 --- /dev/null +++ b/packages/types/src/file.d.ts @@ -0,0 +1,32 @@ +import { EFileAssetType } from "./enums" + +export type TFileMetaDataLite = { + name: string; + // file size in bytes + size: number; + type: string; +} + +export type TFileEntityInfo = { + entity_identifier: string; + entity_type: EFileAssetType; +} + +export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo; + +export type TFileSignedURLResponse = { + asset_id: string; + asset_url: string; + upload_data: { + url: string; + fields: { + "Content-Type": string; + key: string; + "x-amz-algorithm": string; + "x-amz-credential": string; + "x-amz-date": string; + policy: string; + "x-amz-signature": string; + }; + }; +}; \ No newline at end of file diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 6dfddc6b63..d637b0102a 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -29,3 +29,5 @@ export * from "./pragmatic"; export * from "./publish"; export * from "./workspace-notifications"; export * from "./favorite"; +export * from "./file"; +export * from "./workspace-draft-issues/base"; diff --git a/packages/types/src/integration.d.ts b/packages/types/src/integration.d.ts index bb76f9fc0c..e2561bd18f 100644 --- a/packages/types/src/integration.d.ts +++ b/packages/types/src/integration.d.ts @@ -1,7 +1,6 @@ // All the app integrations that are available export interface IAppIntegration { author: string; - author: ""; avatar_url: string | null; created_at: string; created_by: string | null; diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts index 82b881fd94..63f365d893 100644 --- a/packages/types/src/issues/activity/base.d.ts +++ b/packages/types/src/issues/activity/base.d.ts @@ -40,7 +40,7 @@ export type TIssueActivityUserDetail = { id: string; first_name: string; last_name: string; - avatar: string; + avatar_url: string; is_bot: boolean; display_name: string; }; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index 8292c11164..05f679cce2 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -10,6 +10,7 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; + export type TLoader = | "init-loader" | "mutation" diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 1584a3d16c..aacc28023b 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -45,7 +45,7 @@ export type TIssue = TBaseIssue & { is_subscribed?: boolean; parent?: Partial; issue_reactions?: TIssueReaction[]; - issue_attachment?: TIssueAttachment[]; + issue_attachments?: TIssueAttachment[]; issue_link?: TIssueLink[]; // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts index 7c3819e004..2238fa4c76 100644 --- a/packages/types/src/issues/issue_attachment.d.ts +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -1,17 +1,22 @@ +import { TFileSignedURLResponse } from "../file"; + export type TIssueAttachment = { id: string; attributes: { name: string; size: number; }; - asset: string; + asset_url: string; issue_id: string; - - //need + // required updated_at: string; updated_by: string; }; +export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & { + attachment: TIssueAttachment +}; + export type TIssueAttachmentMap = { [issue_id: string]: TIssueAttachment; }; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 6a5a092317..fa77a6a414 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -26,7 +26,7 @@ export type TModuleEstimateDistributionBase = { export type TModuleAssigneesDistribution = { assignee_id: string | null; - avatar: string | null; + avatar_url: string | null; first_name: string | null; last_name: string | null; display_name: string | null; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index a46f490f16..75d6668b8a 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -18,7 +18,7 @@ export interface IProject { close_in: number; created_at: Date; created_by: string; - cover_image: string | null; + cover_image_url: string; cycle_view: boolean; issue_views_view: boolean; module_view: boolean; @@ -54,6 +54,7 @@ export interface IProject { updated_by: string; workspace: IWorkspace | string; workspace_detail: IWorkspaceLite; + timezone: string; } export interface IProjectLite { @@ -75,7 +76,7 @@ export interface IProjectMap { export interface IProjectMemberLite { id: string; - member__avatar: string; + member__avatar_url: string; member__display_name: string; member_id: string; } diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 4d5db28f9c..0440ff05f9 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -3,17 +3,21 @@ import { TUserPermissions } from "./enums"; type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google"; -export interface IUser { - id: string; - avatar: string | null; - cover_image: string | null; - date_joined: string; + +export interface IUserLite { + avatar_url: string; display_name: string; - email: string; + email?: string; first_name: string; - last_name: string; - is_active: boolean; + id: string; is_bot: boolean; + last_name: string; +} +export interface IUser extends IUserLite { + cover_image_url: string | null; + date_joined: string; + email: string; + is_active: boolean; is_email_verified: boolean; is_password_autoset: boolean; is_tour_completed: boolean; @@ -86,15 +90,6 @@ export interface IUserTheme { sidebarBackground: string | undefined; } -export interface IUserLite { - avatar: string; - display_name: string; - email?: string; - first_name: string; - id: string; - is_bot: boolean; - last_name: string; -} export interface IUserMemberLite extends IUserLite { email?: string; @@ -158,13 +153,8 @@ export interface IUserProfileProjectSegregation { id: string; pending_issues: number; }[]; - user_data: { - avatar: string; - cover_image: string | null; + user_data: Pick & { date_joined: Date; - display_name: string; - first_name: string; - last_name: string; user_timezone: string; }; } diff --git a/packages/types/src/workspace-draft-issues/base.d.ts b/packages/types/src/workspace-draft-issues/base.d.ts new file mode 100644 index 0000000000..8090a9cb79 --- /dev/null +++ b/packages/types/src/workspace-draft-issues/base.d.ts @@ -0,0 +1,63 @@ +import { TIssuePriorities } from "../issues"; + +export type TWorkspaceDraftIssue = { + id: string; + name: string; + sort_order: number; + + state_id: string | undefined; + priority: TIssuePriorities | undefined; + label_ids: string[]; + assignee_ids: string[]; + estimate_point: string | undefined; + + project_id: string | undefined; + parent_id: string | undefined; + cycle_id: string | undefined; + module_ids: string[] | undefined; + + start_date: string | undefined; + target_date: string | undefined; + completed_at: string | undefined; + + created_at: string; + updated_at: string; + created_by: string; + updated_by: string; + + is_draft: boolean; + + type_id: string; +}; + +export type TWorkspaceDraftPaginationInfo = { + next_cursor: string | undefined; + prev_cursor: string | undefined; + next_page_results: boolean | undefined; + prev_page_results: boolean | undefined; + total_pages: number | undefined; + count: number | undefined; // current paginated results count + total_count: number | undefined; // total available results count + total_results: number | undefined; + results: T[] | undefined; + extra_stats: string | undefined; + grouped_by: string | undefined; + sub_grouped_by: string | undefined; +}; + +export type TWorkspaceDraftQueryParams = { + per_page: number; + cursor: string; +}; + +export type TWorkspaceDraftIssueLoader = + | "init-loader" + | "empty-state" + | "mutation" + | "pagination" + | "loaded" + | "create" + | "update" + | "delete" + | "move" + | undefined; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index f72f52463e..412083c428 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -14,8 +14,7 @@ export interface IWorkspace { readonly updated_at: Date; name: string; url: string; - logo: string | null; - slug: string; + logo_url: string | null; readonly total_members: number; readonly slug: string; readonly created_by: string; @@ -71,7 +70,7 @@ export interface IWorkspaceMember { member: IUserLite; role: TUserPermissions; created_at?: string; - avatar?: string; + avatar_url?: string; email?: string; first_name?: string; last_name?: string; @@ -92,6 +91,7 @@ export interface IWorkspaceMemberMe { updated_by: string; view_props: IWorkspaceViewProps; workspace: string; + draft_issue_count: number; } export interface ILastActiveWorkspaceDetails { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 5356628b73..f6b12920c7 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@plane/typescript-config", - "version": "0.23.0", + "version": "0.23.1", "private": true, "files": [ "base.json", diff --git a/packages/ui/package.json b/packages/ui/package.json index 73a720c9fc..09019457ae 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.23.0", + "version": "0.23.1", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/ui/src/header/helper.tsx b/packages/ui/src/header/helper.tsx index b6d76f8c6d..13fee8b479 100644 --- a/packages/ui/src/header/helper.tsx +++ b/packages/ui/src/header/helper.tsx @@ -10,9 +10,11 @@ export interface IHeaderProperties { } export const headerStyle: IHeaderProperties = { [EHeaderVariant.PRIMARY]: - "relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 bg-custom-background-100 z-[18]", - [EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]", - [EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]", + "relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 bg-custom-sidebar-background-100 z-[18]", + [EHeaderVariant.SECONDARY]: + "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between bg-custom-background-100 z-[15]", + [EHeaderVariant.TERNARY]: + "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]", }; export const minHeights: IHeaderProperties = { [EHeaderVariant.PRIMARY]: "", diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 69436c2e83..e857dc1a49 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -31,3 +31,4 @@ export * from "./favorite-folder-icon"; export * from "./planned-icon"; export * from "./in-progress-icon"; export * from "./done-icon"; +export * from "./pending-icon"; diff --git a/packages/ui/src/icons/pending-icon.tsx b/packages/ui/src/icons/pending-icon.tsx new file mode 100644 index 0000000000..5269a22e2b --- /dev/null +++ b/packages/ui/src/icons/pending-icon.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const PendingState: React.FC = ({ width = "10", height = "11", className, color = "#455068" }) => ( + + + + +); diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts index 0cb268fc88..fe72ef7aea 100644 --- a/packages/ui/src/modals/constants.ts +++ b/packages/ui/src/modals/constants.ts @@ -4,6 +4,9 @@ export enum EModalPosition { } export enum EModalWidth { + SM = "sm:max-w-sm", + MD = "sm:max-w-md", + LG = "sm:max-w-lg", XL = "sm:max-w-xl", XXL = "sm:max-w-2xl", XXXL = "sm:max-w-3xl", diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 186f44a101..4cd6d82e87 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,30 +1,31 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor"; // components import { IssueCommentToolbar } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -// services -import fileService from "@/services/file.service"; interface LiteTextEditorWrapperProps extends Omit { - workspaceSlug: string; + anchor: string; workspaceId: string; isSubmitting?: boolean; showSubmitButton?: boolean; + uploadFile: (file: File) => Promise; } export const LiteTextEditor = React.forwardRef((props, ref) => { const { + anchor, containerClassName, - workspaceSlug, workspaceId, isSubmitting = false, showSubmitButton = true, + uploadFile, ...rest } = props; // use-mention @@ -39,12 +40,11 @@ export const LiteTextEditor = React.forwardRef { if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); + ref.current?.executeMenuItemCommand({ + itemKey: key as TNonColorEditorCommands, + }); } }} isSubmitting={isSubmitting} diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 033b98ccd1..e12a46682e 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -3,18 +3,24 @@ import React from "react"; import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -type LiteTextReadOnlyEditorWrapperProps = Omit; +type LiteTextReadOnlyEditorWrapperProps = Omit & { + anchor: string; +}; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ anchor, ...props }, ref) => { const { mentionHighlights } = useMention(); return ( ; +type RichTextReadOnlyEditorWrapperProps = Omit & { + anchor: string; +}; export const RichTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ anchor, ...props }, ref) => { const { mentionHighlights } = useMention(); return ( = (props) => { .flat() .forEach((item) => { // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({ + itemKey: item.key as TNonColorEditorCommands, + }); }); setActiveStates(newActiveStates); } diff --git a/space/core/components/issues/navbar/user-avatar.tsx b/space/core/components/issues/navbar/user-avatar.tsx index 9c1f3311de..40339bb5c0 100644 --- a/space/core/components/issues/navbar/user-avatar.tsx +++ b/space/core/components/issues/navbar/user-avatar.tsx @@ -10,6 +10,7 @@ import { Popover, Transition } from "@headlessui/react"; import { Avatar, Button } from "@plane/ui"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useUser } from "@/hooks/store"; @@ -66,7 +67,7 @@ export const UserAvatar: FC = observer(() => { > = observer((props) => { const { anchor } = props; + // states + const [uploadedAssetIds, setUploadAssetIds] = useState([]); // refs const editorRef = useRef(null); // store hooks - const { peekId: issueId, addIssueComment } = useIssueDetails(); + const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails(); const { data: currentUser } = useUser(); - const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); + const { workspace: workspaceID } = usePublish(anchor); // form info const { handleSubmit, @@ -44,9 +49,15 @@ export const AddComment: React.FC = observer((props) => { if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; await addIssueComment(anchor, issueId, formData) - .then(() => { + .then(async (res) => { reset(defaultValues); editorRef.current?.clearEditor(); + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkAssetsUploadStatus(anchor, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadAssetIds([]); + } }) .catch(() => setToast({ @@ -69,8 +80,8 @@ export const AddComment: React.FC = observer((props) => { onEnterKeyPress={(e) => { if (currentUser) handleSubmit(onSubmit)(e); }} + anchor={anchor} workspaceId={workspaceID?.toString() ?? ""} - workspaceSlug={workspaceSlug?.toString() ?? ""} ref={editorRef} id="peek-overview-add-comment" initialValue={ @@ -81,6 +92,11 @@ export const AddComment: React.FC = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} placeholder="Add Comment..." + uploadFile={async (file) => { + const { asset_id } = await uploadCommentAsset(file, anchor); + setUploadAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} /> )} /> diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 47b506b965..1b228dfb3e 100644 --- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -9,6 +9,7 @@ import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; @@ -23,9 +24,9 @@ type Props = { export const CommentCard: React.FC = observer((props) => { const { anchor, comment } = props; // store hooks - const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); + const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails(); const { data: currentUser } = useUser(); - const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); + const { workspace: workspaceID } = usePublish(anchor); const isInIframe = useIsInIframe(); // states @@ -58,10 +59,10 @@ export const CommentCard: React.FC = observer((props) => { return (
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( // eslint-disable-next-line @next/next/no-img-element { = observer((props) => { name="comment_html" render={({ field: { onChange, value } }) => ( = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} showSubmitButton={false} + uploadFile={async (file) => { + const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); + return asset_id; + }} /> )} /> @@ -133,7 +138,12 @@ export const CommentCard: React.FC = observer((props) => {
- +
diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx index b47bfad68c..36bad2fadc 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => {

{issueDetails.name}

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

" && ( { + this.cancelSource = axios.CancelToken.source(); + return this.post(url, data, { + headers: { + "Content-Type": "multipart/form-data", + }, + cancelToken: this.cancelSource.token, + }) + .then((response) => response?.data) + .catch((error) => { + if (axios.isCancel(error)) { + console.log(error.message); + } else { + throw error?.response?.data; + } + }); + } + + cancelUpload() { + this.cancelSource.cancel("Upload canceled"); + } +} diff --git a/space/core/services/file.service.ts b/space/core/services/file.service.ts index 9fe06cd364..168738804e 100644 --- a/space/core/services/file.service.ts +++ b/space/core/services/file.service.ts @@ -1,106 +1,100 @@ -import axios from "axios"; +// plane types +import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; // services import { APIService } from "@/services/api.service"; +import { FileUploadService } from "@/services/file-upload.service"; -class FileService extends APIService { +export class FileService extends APIService { private cancelSource: any; + fileUploadService: FileUploadService; constructor() { super(API_BASE_URL); - this.uploadFile = this.uploadFile.bind(this); - this.deleteImage = this.deleteImage.bind(this); - this.restoreImage = this.restoreImage.bind(this); this.cancelUpload = this.cancelUpload.bind(this); + // services + this.fileUploadService = new FileUploadService(); } - async uploadFile(workspaceSlug: string, file: FormData): Promise { - this.cancelSource = axios.CancelToken.source(); - return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, { - headers: { - "Content-Type": "multipart/form-data", - }, - cancelToken: this.cancelSource.token, - }) + private async updateAssetUploadStatus(anchor: string, assetId: string): Promise { + return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`) .then((response) => response?.data) .catch((error) => { - if (axios.isCancel(error)) { - console.log(error.message); - } else { - console.log(error); - throw error?.response?.data; - } + throw error?.response?.data; + }); + } + + async updateBulkAssetsUploadStatus( + anchor: string, + entityId: string, + data: { + asset_ids: string[]; + } + ): Promise { + return this.post(`/api/public/assets/v2/anchor/${anchor}/${entityId}/bulk/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + return this.post(`/api/public/assets/v2/anchor/${anchor}/`, { + ...data, + ...fileMetaData, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload); + await this.updateAssetUploadStatus(anchor, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteNewAsset(assetPath: string): Promise { + return this.delete(assetPath) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`) + .then((response) => response?.status) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreNewAsset(workspaceSlug: string, src: string): Promise { + // remove the last slash and get the asset id + const assetId = getAssetIdFromUrl(src); + return this.post(`/api/public/assets/v2/workspaces/${workspaceSlug}/restore/${assetId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; }); } cancelUpload() { this.cancelSource.cancel("Upload cancelled"); } - - getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { - return async (file: File) => { - const formData = new FormData(); - formData.append("asset", file); - formData.append("attributes", JSON.stringify({})); - - const data = await this.uploadFile(workspaceSlug, formData); - return data.asset; - }; - } - - getDeleteImageFunction(workspaceId: string) { - return async (src: string) => { - try { - const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; - const data = await this.deleteImage(assetUrlWithWorkspaceId); - return data; - } catch (e) { - console.error(e); - } - }; - } - - getRestoreImageFunction(workspaceId: string) { - return async (src: string) => { - try { - const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; - const data = await this.restoreImage(assetUrlWithWorkspaceId); - return data; - } catch (e) { - console.error(e); - } - }; - } - - extractAssetIdFromUrl(src: string, workspaceId: string): string { - const indexWhereAssetIdStarts = src.indexOf(workspaceId) + workspaceId.length + 1; - if (indexWhereAssetIdStarts === -1) { - throw new Error("Workspace ID not found in source string"); - } - const assetUrl = src.substring(indexWhereAssetIdStarts); - return assetUrl; - } - - async deleteImage(assetUrlWithWorkspaceId: string): Promise { - return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } - - async restoreImage(assetUrlWithWorkspaceId: string): Promise { - return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, { - "Content-Type": "application/json", - }) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } } - -const fileService = new FileService(); - -export default fileService; diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts index 2f19b4f080..b5ecb80778 100644 --- a/space/core/services/issue.service.ts +++ b/space/core/services/issue.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; // types -import { TIssuesResponse, IIssue } from "@/types/issue"; +import { Comment, TIssuesResponse, IIssue } from "@/types/issue"; class IssueService extends APIService { constructor() { @@ -83,7 +83,7 @@ class IssueService extends APIService { }); } - async createIssueComment(anchor: string, issueID: string, data: any): Promise { + async createIssueComment(anchor: string, issueID: string, data: any): Promise { return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data) .then((response) => response?.data) .catch((error) => { diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts index 8b4710b17b..ee8a3031ed 100644 --- a/space/core/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -3,12 +3,16 @@ import set from "lodash/set"; import { makeObservable, observable, action, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; +// plane types +import { TFileSignedURLResponse } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // services +import { FileService } from "@/services/file.service"; import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types -import { IIssue, IPeekMode, IVote } from "@/types/issue"; +import { Comment, IIssue, IPeekMode, IVote } from "@/types/issue"; export interface IIssueDetailStore { loader: boolean; @@ -28,9 +32,10 @@ export interface IIssueDetailStore { // issue actions fetchIssueDetails: (anchor: string, issueID: string) => void; // comment actions - addIssueComment: (anchor: string, issueID: string, data: any) => Promise; + addIssueComment: (anchor: string, issueID: string, data: any) => Promise; updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise; deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void; + uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise; addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; // reaction actions @@ -54,6 +59,7 @@ export class IssueDetailStore implements IIssueDetailStore { rootStore: CoreRootStore; // services issueService: IssueService; + fileService: FileService; constructor(_rootStore: CoreRootStore) { makeObservable(this, { @@ -72,6 +78,7 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueComment: action, updateIssueComment: action, deleteIssueComment: action, + uploadCommentAsset: action, addCommentReaction: action, removeCommentReaction: action, // reaction actions @@ -83,6 +90,7 @@ export class IssueDetailStore implements IIssueDetailStore { }); this.rootStore = _rootStore; this.issueService = new IssueService(); + this.fileService = new FileService(); } setPeekId = (issueID: string | null) => { @@ -220,6 +228,23 @@ export class IssueDetailStore implements IIssueDetailStore { } }; + uploadCommentAsset = async (file: File, anchor: string, commentID?: string) => { + try { + const res = await this.fileService.uploadAsset( + anchor, + { + entity_identifier: commentID ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }; + addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { const newReaction = { id: uuidv4(), diff --git a/space/core/store/user.store.ts b/space/core/store/user.store.ts index 33b2cbe60a..6616b10b09 100644 --- a/space/core/store/user.store.ts +++ b/space/core/store/user.store.ts @@ -79,7 +79,7 @@ export class UserStore implements IUserStore { first_name: this.data?.first_name, last_name: this.data?.last_name, display_name: this.data?.display_name, - avatar: this.data?.avatar || undefined, + avatar_url: this.data?.avatar_url || undefined, is_bot: false, }; } diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts index 79c6257d5a..3041a188d0 100644 --- a/space/core/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -139,7 +139,7 @@ export interface IIssueReaction { } export interface ActorDetail { - avatar?: string; + avatar_url?: string; display_name?: string; first_name?: string; is_bot?: boolean; diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts new file mode 100644 index 0000000000..648e409e70 --- /dev/null +++ b/space/helpers/editor.helper.ts @@ -0,0 +1,82 @@ +// plane editor +import { TFileHandler } from "@plane/editor"; +// constants +import { MAX_FILE_SIZE } from "@/constants/common"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); + +/** + * @description generate the file source using assetId + * @param {string} anchor + */ +export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => { + const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`); + return url; +}; + +type TArgs = { + anchor: string; + uploadFile: (file: File) => Promise; + workspaceId: string; +}; + +/** + * @description this function returns the file handler required by the editors + * @param {TArgs} args + */ +export const getEditorFileHandlers = (args: TArgs): TFileHandler => { + const { anchor, uploadFile, workspaceId } = args; + + return { + getAssetSrc: (path) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + upload: uploadFile, + delete: async (src: string) => { + if (src?.startsWith("http")) { + await fileService.deleteOldEditorAsset(workspaceId, src); + } else { + await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); + } + }, + restore: async (src: string) => { + if (src?.startsWith("http")) { + await fileService.restoreOldEditorAsset(workspaceId, src); + } else { + await fileService.restoreNewAsset(anchor, src); + } + }, + cancel: fileService.cancelUpload, + validation: { + maxFileSize: MAX_FILE_SIZE, + }, + }; +}; + +/** + * @description this function returns the file handler required by the read-only editors + */ +export const getReadOnlyEditorFileHandlers = ( + args: Pick +): { getAssetSrc: TFileHandler["getAssetSrc"] } => { + const { anchor } = args; + + return { + getAssetSrc: (path) => { + if (!path) return ""; + if (path?.startsWith("http")) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + }; +}; diff --git a/space/helpers/file.helper.ts b/space/helpers/file.helper.ts new file mode 100644 index 0000000000..b149ebc7cf --- /dev/null +++ b/space/helpers/file.helper.ts @@ -0,0 +1,51 @@ +// plane types +import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; + +/** + * @description from the provided signed URL response, generate a payload to be used to upload the file + * @param {TFileSignedURLResponse} signedURLResponse + * @param {File} file + * @returns {FormData} file upload request payload + */ +export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => { + const formData = new FormData(); + Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value)); + formData.append("file", file); + return formData; +}; + +/** + * @description combine the file path with the base URL + * @param {string} path + * @returns {string} final URL with the base URL + */ +export const getFileURL = (path: string): string | undefined => { + if (!path) return undefined; + const isValidURL = path.startsWith("http"); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; + +/** + * @description returns the necessary file meta data to upload a file + * @param {File} file + * @returns {TFileMetaDataLite} payload with file info + */ +export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({ + name: file.name, + size: file.size, + type: file.type, +}); + +/** + * @description this function returns the assetId from the asset source + * @param {string} src + * @returns {string} assetId + */ +export const getAssetIdFromUrl = (src: string): string => { + const sourcePaths = src.split("/"); + const assetUrl = sourcePaths[sourcePaths.length - 1]; + return assetUrl; +}; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 5c704c44c3..dc838596a6 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -78,3 +78,25 @@ export const isCommentEmpty = (comment: string | undefined): boolean => { export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/space/package.json b/space/package.json index 4bd7dda492..fb6c27ed3f 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.23.0", + "version": "0.23.1", "private": true, "scripts": { "dev": "turbo run develop", @@ -28,7 +28,6 @@ "date-fns": "^3.6.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", - "js-cookie": "^3.0.1", "lodash": "^4.17.21", "lowlight": "^2.9.0", "lucide-react": "^0.378.0", @@ -52,7 +51,6 @@ "@plane/eslint-config": "*", "@plane/typescript-config": "*", "@types/dompurify": "^3.0.5", - "@types/js-cookie": "^3.0.3", "@types/lodash": "^4.17.1", "@types/node": "18.14.1", "@types/nprogress": "^0.2.0", diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index 72dae40b1e..4edf41bbdb 100644 --- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -16,7 +16,7 @@ export const WorkspaceActiveCycleHeader = observer(() => ( type="text" link={ } /> } diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx new file mode 100644 index 0000000000..f77e61c319 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/header.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import { PenSquare } from "lucide-react"; +// ui +import { Breadcrumbs, Button, Header } from "@plane/ui"; +// components +import { BreadcrumbLink, CountChip } from "@/components/common"; +import { CreateUpdateIssueModal } from "@/components/issues"; +// constants +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store"; +// plane-web +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; + +export const WorkspaceDraftHeader = observer(() => { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { paginationInfo } = useWorkspaceDraftIssues(); + const { joinedProjectIds } = useProject(); + // check if user is authorized to create draft issue + 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/[workspaceSlug]/(projects)/drafts/layout.tsx b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx new file mode 100644 index 0000000000..a5a647bfdb --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceDraftHeader } from "./header"; + +export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/drafts/page.tsx b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx new file mode 100644 index 0000000000..f94fc872ae --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/drafts/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft"; + +const WorkspaceDraftPage = () => { + // router + const { workspaceSlug: routeWorkspaceSlug } = useParams(); + const pageTitle = "Workspace Draft"; + + // derived values + const workspaceSlug = (routeWorkspaceSlug as string) || undefined; + + if (!workspaceSlug) return null; + return ( + <> + +
+ +
+ + ); +}; + +export default WorkspaceDraftPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 27e33e2c2c..aa81ae5816 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -29,7 +29,7 @@ export const CycleIssuesMobileHeader = () => { const { getCycleById } = useCycle(); const layouts = [ { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "kanban", title: "Board", icon: Kanban }, { key: "calendar", title: "Calendar", icon: Calendar }, ]; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx index a543eca0be..e733317d2b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx @@ -8,7 +8,7 @@ import { RefreshCcw } from "lucide-react"; import { Breadcrumbs, Button, Intake, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; -import { InboxIssueCreateEditModalRoot } from "@/components/inbox"; +import { InboxIssueCreateModalRoot } from "@/components/inbox"; // hooks import { useProject, useProjectInbox, useUserPermissions } from "@/hooks/store"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; @@ -69,12 +69,11 @@ export const ProjectInboxHeader: FC = observer(() => { {currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
- setCreateIssueModal(false)} - issue={undefined} />
-
( { />
- -
+
-
+
{`${watch("first_name")} ${watch("last_name")}`}
- {watch("email")} + {watch("email")}
- - {/* - - - Activity Overview - - */}
- -
-
-

- First name* -

- ( - +
+
+
+

+ First name* +

+ ( + + )} + /> + {errors.first_name && {errors.first_name.message}} +
+
+

Last name

+ ( + + )} + /> +
+
+

+ Display name* +

+ { + if (value.trim().length < 1) return "Display name can't be empty."; + if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; + if (value.replace(/\s/g, "").length < 1) + return "Display name must be at least 1 character long."; + if (value.replace(/\s/g, "").length > 20) + return "Display name must be less than 20 characters long."; + return true; + }, + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors?.display_name && ( + {errors?.display_name?.message} )} - /> - {errors.first_name && Please enter first name} +
+
+

+ Email* +

+ ( + + )} + /> +
+
+

+ Role* +

+ ( + + {USER_ROLES.map((item) => ( + + {item.label} + + ))} + + )} + /> + {errors.role && Please select a role} +
- -
-

Last name

- - ( - - )} - /> -
- -
-

- Email* -

- ( - +
+
+
+

+ Timezone* +

+ ( + t.value === value)) ?? value) + : "Select a timezone" + } + options={timeZoneOptions} + onChange={onChange} + buttonClassName={errors.user_timezone ? "border-red-500" : ""} + className="rounded-md border-[0.5px] !border-custom-border-200" + optionsClassName="w-72" + input + /> + )} + /> + {errors.user_timezone && {errors.user_timezone.message}} +
+ +
+

Language

+ {}} + className="rounded-md bg-custom-background-90" + input disabled /> - )} - /> +
+
- -
-

- Role* -

- ( - - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && Please select a role} -
- -
-

- Display name* -

- { - if (value.trim().length < 1) return "Display name can't be empty."; - - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - - if (value.replace(/\s/g, "").length < 1) - return "Display name must be at least 1 characters long."; - - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - - return true; - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors?.display_name && {errors?.display_name?.message}} -
- -
-

- Timezone* -

- - ( - t.value === value)?.label ?? value) : "Select a timezone"} - options={timeZoneOptions} - onChange={onChange} - buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"} - className="rounded-md border-[0.5px] !border-custom-border-200" - input - /> - )} - /> - {errors.role && Please select a time zone} -
- -
+
@@ -398,11 +416,11 @@ const ProfileSettingsPage = observer(() => {
- + {({ open }) => ( <> - Deactivate account + Deactivate account { ); }); -// ProfileSettingsPage.getLayout = function getLayout(page: ReactElement) { -// return {page}; -// }; - export default ProfileSettingsPage; diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index adb06863d5..aec3e24dca 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -16,6 +16,7 @@ import { SidebarNavItem } from "@/components/sidebar"; import { PROFILE_ACTION_LINKS } from "@/constants/profile"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -180,17 +181,17 @@ export const ProfileLayoutSidebar = observer(() => { > - {workspace?.logo && workspace.logo !== "" ? ( + {workspace?.logo_url && workspace.logo_url !== "" ? ( Workspace Logo ) : ( - workspace?.name?.charAt(0) ?? "..." + (workspace?.name?.charAt(0) ?? "...") )} {!sidebarCollapsed && ( diff --git a/web/ce/components/cycles/analytics-sidebar/base.tsx b/web/ce/components/cycles/analytics-sidebar/base.tsx new file mode 100644 index 0000000000..87f07e387c --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -0,0 +1,95 @@ +"use client"; +import { FC, Fragment } from "react"; +import { observer } from "mobx-react"; +// plane ui +import { TCycleEstimateType } from "@plane/types"; +import { Loader } from "@plane/ui"; +// components +import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { EstimateTypeDropdown, validateCycleSnapshot } from "@/components/cycles"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +// hooks +import { useCycle } from "@/hooks/store"; + +type ProgressChartProps = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; +export const SidebarChart: FC = observer((props) => { + const { workspaceSlug, projectId, cycleId } = props; + + // hooks + const { getEstimateTypeByCycleId, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, setEstimateType } = + useCycle(); + + // derived data + const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); + const cycleStartDate = getDate(cycleDetails?.start_date); + const cycleEndDate = getDate(cycleDetails?.end_date); + const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; + const totalIssues = cycleDetails?.total_issues || 0; + const estimateType = getEstimateTypeByCycleId(cycleId); + + const chartDistributionData = + estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; + + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + if (!workspaceSlug || !projectId || !cycleId) return null; + + const isArchived = !!cycleDetails?.archived_at; + + // handlers + const onChange = async (value: TCycleEstimateType) => { + setEstimateType(cycleId, value); + if (!workspaceSlug || !projectId || !cycleId) return; + try { + if (isArchived) { + await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); + } else { + await fetchCycleDetails(workspaceSlug, projectId, cycleId); + } + } catch (err) { + console.error(err); + setEstimateType(cycleId, estimateType); + } + }; + return ( + <> +
+ +
+
+
+
+
+ + Ideal +
+
+ + Current +
+
+ {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( + + + + ) : ( + + + + )} +
+
+ + ); +}); diff --git a/web/ce/components/cycles/analytics-sidebar/index.ts b/web/ce/components/cycles/analytics-sidebar/index.ts index 3ba38c61be..1efe34c51e 100644 --- a/web/ce/components/cycles/analytics-sidebar/index.ts +++ b/web/ce/components/cycles/analytics-sidebar/index.ts @@ -1 +1 @@ -export * from "./sidebar-chart"; +export * from "./root"; diff --git a/web/ce/components/cycles/analytics-sidebar/root.tsx b/web/ce/components/cycles/analytics-sidebar/root.tsx new file mode 100644 index 0000000000..d18f9168dc --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -0,0 +1,12 @@ +"use client"; +import React, { FC } from "react"; +// components +import { SidebarChart } from "./base"; + +type Props = { + workspaceSlug: string; + projectId: string; + cycleId: string; +}; + +export const SidebarChartRoot: FC = (props) => ; diff --git a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx b/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx deleted file mode 100644 index e5b69ef24b..0000000000 --- a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Fragment } from "react"; -import { TCycleDistribution, TCycleEstimateDistribution } from "@plane/types"; -import { Loader } from "@plane/ui"; -import ProgressChart from "@/components/core/sidebar/progress-chart"; - -type ProgressChartProps = { - chartDistributionData: TCycleEstimateDistribution | TCycleDistribution | undefined; - cycleStartDate: Date | undefined; - cycleEndDate: Date | undefined; - totalEstimatePoints: number; - totalIssues: number; - plotType: string; -}; -export const SidebarBaseChart = (props: ProgressChartProps) => { - const { chartDistributionData, cycleStartDate, cycleEndDate, totalEstimatePoints, totalIssues, plotType } = props; - const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; - - return ( -
-
-
- - Ideal -
-
- - Current -
-
- {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( - - {plotType === "points" ? ( - - ) : ( - - )} - - ) : ( - - - - )} -
- ); -}; diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx index b12cc6de71..fbd9439842 100644 --- a/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -1,6 +1,9 @@ +import { FC } from "react"; import { observer } from "mobx-react"; // types import { IIssueDisplayProperties } from "@plane/types"; +// ui +import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; // helpers import { cn } from "@/helpers/common.helper"; // hooks @@ -11,6 +14,7 @@ type TIssueIdentifierBaseProps = { size?: "xs" | "sm" | "md" | "lg"; textContainerClassName?: string; displayProperties?: IIssueDisplayProperties | undefined; + enableClickToCopyIdentifier?: boolean; }; type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & { @@ -23,9 +27,55 @@ type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & { issueSequenceId: string | number; }; -type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails; +export type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails; + +type TIssueTypeIdentifier = { + issueTypeId: string; + size?: "xs" | "sm" | "md" | "lg"; +}; + +export const IssueTypeIdentifier: FC = observer((props) => <>); + +type TIdentifierTextProps = { + identifier: string; + enableClickToCopyIdentifier?: boolean; + textContainerClassName?: string; +}; + +export const IdentifierText: React.FC = (props) => { + const { identifier, enableClickToCopyIdentifier = false, textContainerClassName } = props; + // handlers + const handleCopyIssueIdentifier = () => { + if (enableClickToCopyIdentifier) { + navigator.clipboard.writeText(identifier).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Issue ID copied to clipboard", + }); + }); + } + }; + + return ( + + + {identifier} + + + ); +}; + export const IssueIdentifier: React.FC = observer((props) => { - const { projectId, textContainerClassName, displayProperties } = props; + const { projectId, textContainerClassName, displayProperties, enableClickToCopyIdentifier = false } = props; // store hooks const { getProjectIdentifierById } = useProject(); const { @@ -43,9 +93,11 @@ export const IssueIdentifier: React.FC = observer((props) return (
- - {projectIdentifier}-{issueSequenceId} - +
); }); diff --git a/web/ce/components/issues/issue-details/issue-type-switcher.tsx b/web/ce/components/issues/issue-details/issue-type-switcher.tsx index 5cbd8e6d67..5d4adeb955 100644 --- a/web/ce/components/issues/issue-details/issue-type-switcher.tsx +++ b/web/ce/components/issues/issue-details/issue-type-switcher.tsx @@ -20,5 +20,5 @@ export const IssueTypeSwitcher: React.FC = observer((pr if (!issue || !issue.project_id) return <>; - return ; + return ; }); diff --git a/web/ce/components/issues/issue-modal/additional-properties.tsx b/web/ce/components/issues/issue-modal/additional-properties.tsx index 228ab51e85..99ddc8830b 100644 --- a/web/ce/components/issues/issue-modal/additional-properties.tsx +++ b/web/ce/components/issues/issue-modal/additional-properties.tsx @@ -3,6 +3,7 @@ type TIssueAdditionalPropertiesProps = { issueTypeId: string | null; projectId: string; workspaceSlug: string; + isDraft?: boolean; }; export const IssueAdditionalProperties: React.FC = () => <>; diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx index d3440ea479..211155d37d 100644 --- a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -11,11 +11,13 @@ type Props = { handleInsertText: (insertOnNextLine: boolean) => void; handleRegenerate: () => Promise; isRegenerating: boolean; + projectId: string; response: string | undefined; + workspaceSlug: string; }; export const AskPiMenu: React.FC = (props) => { - const { handleInsertText, handleRegenerate, isRegenerating, response } = props; + const { handleInsertText, handleRegenerate, isRegenerating, projectId, response, workspaceSlug } = props; // states const [query, setQuery] = useState(""); @@ -39,6 +41,8 @@ export const AskPiMenu: React.FC = (props) => { initialValue={response} containerClassName="!p-0 border-none" editorClassName="!pl-0" + workspaceSlug={workspaceSlug} + projectId={projectId} />
)} @@ -218,6 +230,8 @@ export const GptAssistantPopover: React.FC = (props) => { id="ai-assistant-response" initialValue={`

${response}

`} ref={responseRef} + workspaceSlug={workspaceSlug} + projectId={projectId} />
)} diff --git a/web/core/components/core/modals/user-image-upload-modal.tsx b/web/core/components/core/modals/user-image-upload-modal.tsx index 7e033053f7..ad7a4daacf 100644 --- a/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/web/core/components/core/modals/user-image-upload-modal.tsx @@ -5,34 +5,33 @@ import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // hooks import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// hooks -import { useInstance } from "@/hooks/store"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; // services import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type Props = { - handleDelete?: () => void; + handleRemove: () => Promise; isOpen: boolean; - isRemoving: boolean; onClose: () => void; onSuccess: (url: string) => void; value: string | null; }; -// services -const fileService = new FileService(); - export const UserImageUploadModal: React.FC = observer((props) => { - const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props; + const { handleRemove, isOpen, onClose, onSuccess, value } = props; // states const [image, setImage] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); - // store hooks - const { config } = useInstance(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -41,7 +40,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, multiple: false, }); @@ -53,31 +52,46 @@ export const UserImageUploadModal: React.FC = observer((props) => { const handleSubmit = async () => { if (!image) return; - setIsImageUploading(true); - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); + try { + const { asset_url } = await fileService.uploadUserAsset( + { + entity_identifier: "", + entity_type: EFileAssetType.USER_AVATAR, + }, + image + ); + onSuccess(asset_url); + setImage(null); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.toString() ?? "Something went wrong. Please try again.", + }); + throw new Error("Error in uploading file."); + } finally { + setIsImageUploading(false); + } + }; - fileService - .uploadUserFile(formData) - .then((res) => { - const imageUrl = res.asset; - - onSuccess(imageUrl); - setImage(null); - - if (value) fileService.deleteUserFile(value); - }) - .catch((err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsImageUploading(false)); + const handleImageRemove = async () => { + if (!value) return; + setIsRemoving(true); + try { + if (checkURLValidity(value)) { + await fileService.deleteOldUserAsset(value); + } else { + const assetId = getAssetIdFromUrl(value); + await fileService.deleteUserAsset(assetId); + } + await handleRemove(); + } catch (error) { + console.log("Error in uploading user asset:", error); + } finally { + setIsRemoving(false); + } }; return ( @@ -130,7 +144,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { Edit image @@ -158,11 +172,9 @@ export const UserImageUploadModal: React.FC = observer((props) => {

File formats supported- .jpeg, .jpg, .png, .webp

- {handleDelete && ( - - )} +
diff --git a/web/core/components/core/modals/workspace-image-upload-modal.tsx b/web/core/components/core/modals/workspace-image-upload-modal.tsx index 614fe5f418..cc7248a626 100644 --- a/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -1,23 +1,27 @@ "use client"; import React, { useState } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // hooks -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; // hooks -import { useWorkspace, useInstance } from "@/hooks/store"; +import { useWorkspace } from "@/hooks/store"; // services import { FileService } from "@/services/file.service"; type Props = { - handleRemove?: () => void; + handleRemove: () => Promise; isOpen: boolean; - isRemoving: boolean; onClose: () => void; onSuccess: (url: string) => void; value: string | null; @@ -27,16 +31,15 @@ type Props = { const fileService = new FileService(); export const WorkspaceImageUploadModal: React.FC = observer((props) => { - const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props; + const { handleRemove, isOpen, onClose, onSuccess, value } = props; // states const [image, setImage] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); // router const { workspaceSlug } = useParams(); - const pathname = usePathname(); // store hooks - const { config } = useInstance(); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, updateWorkspaceLogo } = useWorkspace(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -45,45 +48,58 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, multiple: false, }); const handleClose = () => { - setImage(null); setIsImageUploading(false); onClose(); + setTimeout(() => { + setImage(null); + }, 300); }; const handleSubmit = async () => { - if (!image || (!workspaceSlug && pathname !== "/onboarding")) return; - + if (!image || !workspaceSlug || !currentWorkspace) return; setIsImageUploading(true); - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); + try { + const { asset_url } = await fileService.uploadWorkspaceAsset( + workspaceSlug.toString(), + { + entity_identifier: currentWorkspace.id, + entity_type: EFileAssetType.WORKSPACE_LOGO, + }, + image + ); + updateWorkspaceLogo(workspaceSlug.toString(), asset_url); + onSuccess(asset_url); + } catch (error) { + console.log("error", error); + throw new Error("Error in uploading file."); + } finally { + setIsImageUploading(false); + } + }; - if (!workspaceSlug) return; - - fileService - .uploadFile(workspaceSlug.toString(), formData) - .then((res) => { - const imageUrl = res.asset; - - onSuccess(imageUrl); - setImage(null); - - if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value); - }) - .catch((err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsImageUploading(false)); + const handleImageRemove = async () => { + if (!workspaceSlug || !value) return; + setIsRemoving(true); + try { + if (checkURLValidity(value)) { + await fileService.deleteOldWorkspaceAsset(currentWorkspace?.id ?? "", value); + } else { + const assetId = getAssetIdFromUrl(value); + await fileService.deleteWorkspaceAsset(workspaceSlug.toString(), assetId); + } + await handleRemove(); + handleClose(); + } catch (error) { + console.log("Error in removing workspace asset:", error); + } finally { + setIsRemoving(false); + } }; return ( @@ -115,7 +131,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => {
- Upload Image + Upload image
@@ -136,7 +152,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { Edit image @@ -164,11 +180,9 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => {

File formats supported- .jpeg, .jpg, .png, .webp

- {handleRemove && ( - - )} +
diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index 1ee86a620b..8e1d823493 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -17,15 +17,17 @@ import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; import { EIssuesStoreType } from "@/constants/issue"; -// helper +// helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useIssues } from "@/hooks/store"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import useLocalStorage from "@/hooks/use-local-storage"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; +// store import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; export type ActiveCycleStatsProps = { @@ -250,7 +252,10 @@ export const ActiveCycleStats: FC = observer((props) => { key={assignee.assignee_id} title={
- + {assignee.display_name}
diff --git a/web/core/components/cycles/active-cycle/header.tsx b/web/core/components/cycles/active-cycle/header.tsx deleted file mode 100644 index 73d36f9923..0000000000 --- a/web/core/components/cycles/active-cycle/header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { FC } from "react"; -import Link from "next/link"; -// types -import { ICycle, TCycleGroups } from "@plane/types"; -// ui -import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui"; -// helpers -import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper"; -import { truncateText } from "@/helpers/string.helper"; -// hooks -import { useMember } from "@/hooks/store"; - -export type ActiveCycleHeaderProps = { - cycle: ICycle; - workspaceSlug: string; - projectId: string; -}; - -export const ActiveCycleHeader: FC = (props) => { - const { cycle, workspaceSlug, projectId } = props; - // store - const { getUserDetails } = useMember(); - const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined; - - const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0; - const currentCycleStatus = cycle.status?.toLocaleLowerCase() as TCycleGroups | undefined; - - const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name); - - return ( -
-
- - -

{truncateText(cycle.name, 70)}

-
- - - {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} - - -
-
-
-
- - {cycleAssignee.length > 0 && ( - - - {cycleAssignee.map((member) => ( - - ))} - - - )} -
-
- - View Cycle - -
-
- ); -}; diff --git a/web/core/components/cycles/active-cycle/index.ts b/web/core/components/cycles/active-cycle/index.ts index c219782520..bf5f3e9b44 100644 --- a/web/core/components/cycles/active-cycle/index.ts +++ b/web/core/components/cycles/active-cycle/index.ts @@ -1,7 +1,3 @@ -export * from "./header"; -export * from "./stats"; -export * from "./upcoming-cycles-list-item"; -export * from "./upcoming-cycles-list"; export * from "./cycle-stats"; -export * from "./progress"; export * from "./productivity"; +export * from "./progress"; diff --git a/web/core/components/cycles/active-cycle/productivity.tsx b/web/core/components/cycles/active-cycle/productivity.tsx index 1e70f326f4..74957af03c 100644 --- a/web/core/components/cycles/active-cycle/productivity.tsx +++ b/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,8 +1,8 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types"; -import { CustomSelect, Loader } from "@plane/ui"; +import { ICycle, TCycleEstimateType } from "@plane/types"; +import { Loader } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { EmptyState } from "@/components/empty-state"; @@ -11,6 +11,7 @@ import { EmptyStateType } from "@/constants/empty-state"; import { useCycle, useProjectEstimates } from "@/hooks/store"; // plane web constants import { EEstimateSystem } from "@/plane-web/constants/estimates"; +import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown"; export type ActiveCycleProductivityProps = { workspaceSlug: string; @@ -18,16 +19,10 @@ export type ActiveCycleProductivityProps = { cycle: ICycle | null; }; -const cycleBurnDownChartOptions = [ - { value: "issues", label: "Issues" }, - { value: "points", label: "Points" }, -]; - export const ActiveCycleProductivity: FC = observer((props) => { const { workspaceSlug, projectId, cycle } = props; // hooks const { getEstimateTypeByCycleId, setEstimateType } = useCycle(); - const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates(); // derived values const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues"; @@ -37,11 +32,6 @@ export const ActiveCycleProductivity: FC = observe setEstimateType(cycle.id, value); }; - const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; - const estimateDetails = - isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); - const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; - const chartDistributionData = cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined; const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; @@ -52,22 +42,7 @@ export const ActiveCycleProductivity: FC = observe

Issue burndown

- {isCurrentEstimateTypeIsPoints && ( -
- {cycleBurnDownChartOptions.find((v) => v.value === estimateType)?.label ?? "None"}} - onChange={onChange} - maxHeight="lg" - > - {cycleBurnDownChartOptions.map((item) => ( - - {item.label} - - ))} - -
- )} +
diff --git a/web/core/components/cycles/active-cycle/stats.tsx b/web/core/components/cycles/active-cycle/stats.tsx deleted file mode 100644 index 1a91fdfc8f..0000000000 --- a/web/core/components/cycles/active-cycle/stats.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import React, { Fragment } from "react"; -import { Tab } from "@headlessui/react"; -import { ICycle } from "@plane/types"; -// hooks -import { Avatar } from "@plane/ui"; -import { SingleProgressStats } from "@/components/core"; -import useLocalStorage from "@/hooks/use-local-storage"; -// components -// ui -// types - -type Props = { - cycle: ICycle; -}; - -export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { - const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); - - const currentValue = (tab: string | null) => { - switch (tab) { - case "Assignees": - return 0; - case "Labels": - return 1; - - default: - return 0; - } - }; - - return ( - { - switch (i) { - case 0: - return setTab("Assignees"); - case 1: - return setTab("Labels"); - - default: - return setTab("Assignees"); - } - }} - > - - - `rounded-3xl border border-custom-border-200 px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" - }` - } - > - Assignees - - - `rounded-3xl border border-custom-border-200 px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" - }` - } - > - Labels - - - {cycle && cycle.total_issues > 0 ? ( - - - {cycle.distribution?.assignees?.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - - {assignee?.display_name ?? ""} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - else - return ( - -
- User -
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} - - - - {cycle.distribution?.labels?.map((label, index) => ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - /> - ))} - - - ) : ( -
- There are no issues present in this cycle. -
- )} - - ); -}; diff --git a/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx deleted file mode 100644 index 4cdadac975..0000000000 --- a/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { Users } from "lucide-react"; -// ui -import { Avatar, AvatarGroup, FavoriteStar, setPromiseToast } from "@plane/ui"; -// components -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; -// hooks -import { useCycle, useEventTracker, useMember } from "@/hooks/store"; - -type Props = { - cycleId: string; -}; - -export const UpcomingCycleListItem: React.FC = observer((props) => { - const { cycleId } = props; - // refs - const parentRef = useRef(null); - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { captureEvent } = useEventTracker(); - const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle(); - const { getUserDetails } = useMember(); - // derived values - const cycle = getCycleById(cycleId); - - const handleAddToFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - } - ); - - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; - - const handleRemoveFromFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; - - if (!cycle) return null; - - return ( - -
{cycle.name}
-
- {cycle.start_date && cycle.end_date && ( -
- {renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)} -
- )} - {cycle.assignee_ids && cycle.assignee_ids?.length > 0 ? ( - - {cycle.assignee_ids?.map((assigneeId) => { - const member = getUserDetails(assigneeId); - return ; - })} - - ) : ( - - )} - - { - if (cycle.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycle.is_favorite} - /> - - {workspaceSlug && projectId && ( - - )} -
- - ); -}); diff --git a/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx deleted file mode 100644 index 221ffab0b6..0000000000 --- a/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -import { useTheme } from "next-themes"; -// components -import { UpcomingCycleListItem } from "@/components/cycles"; -// hooks -import { useCycle } from "@/hooks/store"; - -type Props = { - handleEmptyStateAction: () => void; -}; - -export const UpcomingCyclesList: FC = observer((props) => { - const { handleEmptyStateAction } = props; - // store hooks - const { currentProjectUpcomingCycleIds } = useCycle(); - - // theme - const { resolvedTheme } = useTheme(); - - const resolvedEmptyStatePath = `/empty-state/active-cycle/cycle-${resolvedTheme === "light" ? "light" : "dark"}.webp`; - - if (!currentProjectUpcomingCycleIds) return null; - - return ( -
-
- Next cycles -
- {currentProjectUpcomingCycleIds.length > 0 ? ( -
- {currentProjectUpcomingCycleIds.map((cycleId) => ( - - ))} -
- ) : ( -
-
-
- -
-
No upcoming cycles
-

- Create new cycles to find them here or check -
- {"'"}All{"'"} cycles tab to see all cycles or{" "} - -

-
-
- )} -
- ); -}); diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 2f9b4b79e9..d9725d1d66 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -1,14 +1,13 @@ "use client"; -import { FC, Fragment, useCallback, useMemo, useState } from "react"; +import { FC, Fragment, useCallback, useMemo } from "react"; import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; -import { ICycle, IIssueFilterOptions, TCycleEstimateType, TCyclePlotType, TProgressSnapshot } from "@plane/types"; -import { CustomSelect } from "@plane/ui"; +import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; // components import { CycleProgressStats } from "@/components/cycles"; // constants @@ -16,18 +15,30 @@ import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // helpers import { getDate } from "@/helpers/date-time.helper"; // hooks -import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store"; -// plane web constants -import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar"; -import { EEstimateSystem } from "@/plane-web/constants/estimates"; +import { useIssues, useCycle } from "@/hooks/store"; +// plane web components +import { SidebarChartRoot } from "@/plane-web/components/cycles"; type TCycleAnalyticsProgress = { workspaceSlug: string; projectId: string; cycleId: string; }; +type Options = { + value: string; + label: string; +}; -const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { +export const cycleEstimateOptions: Options[] = [ + { value: "issues", label: "Issues" }, + { value: "points", label: "Points" }, +]; +export const cycleChartOptions: Options[] = [ + { value: "burndown", label: "Burn-down" }, + { value: "burnup", label: "Burn-up" }, +]; + +export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { if (!cycleDetails || cycleDetails === null) return cycleDetails; const updatedCycleDetails: any = { ...cycleDetails }; @@ -42,65 +53,25 @@ const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { return updatedCycleDetails; }; -type options = { - value: string; - label: string; -}; -export const cycleChartOptions: options[] = [ - { value: "burndown", label: "Burn-down" }, - { value: "burnup", label: "Burn-up" }, -]; -export const cycleEstimateOptions: options[] = [ - { value: "issues", label: "issues" }, - { value: "points", label: "points" }, -]; export const CycleAnalyticsProgress: FC = observer((props) => { // props const { workspaceSlug, projectId, cycleId } = props; // router const searchParams = useSearchParams(); const peekCycle = searchParams.get("peekCycle") || undefined; - // hooks - const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); - const { - getPlotTypeByCycleId, - getEstimateTypeByCycleId, - setPlotType, - getCycleById, - fetchCycleDetails, - fetchArchivedCycleDetails, - setEstimateType, - } = useCycle(); + const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.CYCLE); - // state - const [loader, setLoader] = useState(false); // derived values const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId); const estimateType = getEstimateTypeByCycleId(cycleId); - const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; - const estimateDetails = - isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); - const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; - const completedIssues = cycleDetails?.completed_issues || 0; const totalIssues = cycleDetails?.total_issues || 0; - const completedEstimatePoints = cycleDetails?.completed_estimate_points || 0; const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; - const progressHeaderPercentage = cycleDetails - ? estimateType === "points" - ? completedEstimatePoints != 0 && totalEstimatePoints != 0 - ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100) - : 0 - : completedIssues != 0 && completedIssues != 0 - ? Math.round((completedIssues / totalIssues) * 100) - : 0 - : 0; - const chartDistributionData = estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; @@ -125,25 +96,6 @@ export const CycleAnalyticsProgress: FC = observer((pro const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date(); const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate; const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid; - const isArchived = !!cycleDetails?.archived_at; - - // handlers - const onChange = async (value: TCycleEstimateType) => { - setEstimateType(cycleId, value); - if (!workspaceSlug || !projectId || !cycleId) return; - try { - setLoader(true); - if (isArchived) { - await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); - } else { - await fetchCycleDetails(workspaceSlug, projectId, cycleId); - } - setLoader(false); - } catch (error) { - setLoader(false); - setEstimateType(cycleId, estimateType); - } - }; const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { @@ -204,31 +156,7 @@ export const CycleAnalyticsProgress: FC = observer((pro -
- {cycleEstimateOptions.find((v) => v.value === estimateType)?.label ?? "None"}} - onChange={onChange} - maxHeight="lg" - buttonClassName="border-none rounded text-sm font-medium" - > - {cycleEstimateOptions.map((item) => ( - - {item.label} - - ))} - -
-
- -
+ {/* progress detailed view */} {chartDistributionData && (
diff --git a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index e98871b408..069ccb2fff 100644 --- a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -17,6 +17,7 @@ import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -28,7 +29,7 @@ import emptyMembers from "@/public/empty-state/empty_members.svg"; type TAssigneeData = { id: string | undefined; title: string | undefined; - avatar: string | undefined; + avatar_url: string | undefined; completed: number; total: number; }[]; @@ -82,7 +83,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => key={assignee?.id} title={
- + {assignee?.title ?? ""}
} @@ -277,14 +278,14 @@ export const CycleProgressStats: FC = observer((props) => { ? (currentDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_issues, total: assignee.total_issues, })) : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_estimates, total: assignee.total_estimates, })); diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index 8bea7edfe1..fd8c984a69 100644 --- a/web/core/components/cycles/analytics-sidebar/root.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -7,8 +7,8 @@ import { useParams } from "next/navigation"; import { Loader } from "@plane/ui"; // components import { CycleAnalyticsProgress, CycleSidebarHeader, CycleSidebarDetails } from "@/components/cycles"; -import useCyclesDetails from "../active-cycle/use-cycles-details"; // hooks +import useCyclesDetails from "../active-cycle/use-cycles-details"; type Props = { handleClose: () => void; diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index 0c6ad1fd81..c60b5dae95 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -3,13 +3,15 @@ import React, { FC } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { LayersIcon, SquareUser, Users } from "lucide-react"; -// ui +// plane types import { ICycle } from "@plane/types"; +// plane ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember, useProjectEstimates } from "@/hooks/store"; -// plane web +// plane web constants import { EEstimateSystem } from "@/plane-web/constants/estimates"; type Props = { @@ -72,7 +74,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
- + {cycleOwnerDetails?.display_name}
@@ -94,7 +96,7 @@ export const CycleSidebarDetails: FC = observer((props) => { ); diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx index 6a654e0535..af645804eb 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx @@ -240,7 +240,7 @@ export const CycleSidebarHeader: FC = observer((props) => {

Archive cycle

- Only completed cycle
can be archived. + Only completed cycles
can be archived.

diff --git a/web/core/components/cycles/board/cycles-board-card.tsx b/web/core/components/cycles/board/cycles-board-card.tsx deleted file mode 100644 index 1f755089a9..0000000000 --- a/web/core/components/cycles/board/cycles-board-card.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { FC, MouseEvent, useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; -import { CalendarCheck2, CalendarClock, Info, MoveRight } from "lucide-react"; -// types -import type { TCycleGroups } from "@plane/types"; -// ui -import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast, FavoriteStar } from "@plane/ui"; -// components -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers -import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; -import { generateQueryParams } from "@/helpers/router.helper"; -// hooks -import { useEventTracker, useCycle, useMember, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; - -export interface ICyclesBoardCard { - workspaceSlug: string; - projectId: string; - cycleId: string; -} - -export const CyclesBoardCard: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; - // refs - const parentRef = useRef(null); - // router - const router = useAppRouter(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - // store - const { captureEvent } = useEventTracker(); - const { allowPermissions } = useUserPermissions(); - - const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); - const { getUserDetails } = useMember(); - // computed - const cycleDetails = getCycleById(cycleId); - // hooks - const { isMobile } = usePlatformOS(); - - if (!cycleDetails) return null; - - const cycleStatus = cycleDetails.status?.toLocaleLowerCase(); - // const isCompleted = cycleStatus === "completed"; - const endDate = getDate(cycleDetails.end_date); - const startDate = getDate(cycleDetails.start_date); - const isDateValid = cycleDetails.start_date || cycleDetails.end_date; - - const isEditingAllowed = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT - ); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - - const cycleTotalIssues = - cycleDetails.backlog_issues + - cycleDetails.unstarted_issues + - cycleDetails.started_issues + - cycleDetails.completed_issues + - cycleDetails.cancelled_issues; - - const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - - const issueCount = cycleDetails - ? cycleTotalIssues === 0 - ? "0 Issue" - : cycleTotalIssues === cycleDetails.completed_issues - ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` - : "0 Issue"; - - const handleAddToFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - } - ); - - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; - - const handleRemoveFromFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; - - const openCycleOverview = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const query = generateQueryParams(searchParams, ["peekCycle"]); - if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); - } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); - } - }; - - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; - - return ( -
- -
-
-
- - - - - {cycleDetails.name} - -
-
- {currentCycle && ( - - {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} - - )} - -
-
- -
-
-
- - {issueCount} -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids.length > 0 && ( - -
- - {cycleDetails.assignee_ids.map((assigne_id) => { - const member = getUserDetails(assigne_id); - return ; - })} - -
-
- )} -
- - -
-
-
-
-
- - -
- {isDateValid && ( -
- - {renderFormattedDate(startDate)} - - - {renderFormattedDate(endDate)} -
- )} -
-
-
- -
- {isEditingAllowed && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - - -
-
- ); -}); diff --git a/web/core/components/cycles/board/cycles-board-map.tsx b/web/core/components/cycles/board/cycles-board-map.tsx deleted file mode 100644 index 3e83ca755d..0000000000 --- a/web/core/components/cycles/board/cycles-board-map.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// components -import { CyclesBoardCard } from "@/components/cycles"; - -type Props = { - cycleIds: string[]; - peekCycle: string | undefined; - projectId: string; - workspaceSlug: string; -}; - -export const CyclesBoardMap: React.FC = (props) => { - const { cycleIds, peekCycle, projectId, workspaceSlug } = props; - - return ( -
- {cycleIds.map((cycleId) => ( - - ))} -
- ); -}; diff --git a/web/core/components/cycles/board/index.ts b/web/core/components/cycles/board/index.ts deleted file mode 100644 index 2e6933d99d..0000000000 --- a/web/core/components/cycles/board/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./cycles-board-card"; -export * from "./cycles-board-map"; -export * from "./root"; diff --git a/web/core/components/cycles/board/root.tsx b/web/core/components/cycles/board/root.tsx deleted file mode 100644 index 1d4684fe5b..0000000000 --- a/web/core/components/cycles/board/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import { ChevronRight } from "lucide-react"; -import { Disclosure } from "@headlessui/react"; -// components -import { CyclePeekOverview, CyclesBoardMap } from "@/components/cycles"; -// helpers -import { cn } from "@/helpers/common.helper"; - -export interface ICyclesBoard { - completedCycleIds: string[]; - cycleIds: string[]; - workspaceSlug: string; - projectId: string; - peekCycle: string | undefined; -} - -export const CyclesBoard: FC = observer((props) => { - const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props; - - return ( -
-
-
- {cycleIds.length > 0 && ( - - )} - {completedCycleIds.length !== 0 && ( - - - {({ open }) => ( - <> - Completed cycles ({completedCycleIds.length}) - - - )} - - - - - - )} -
- -
-
- ); -}); diff --git a/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx b/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx new file mode 100644 index 0000000000..7eba6418d9 --- /dev/null +++ b/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { TCycleEstimateType } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; +import { useCycle, useProjectEstimates } from "@/hooks/store"; +import { cycleEstimateOptions } from "../analytics-sidebar"; + +type TProps = { + value: TCycleEstimateType; + onChange: (value: TCycleEstimateType) => Promise; + showDefault?: boolean; + projectId: string; + cycleId: string; +}; + +export const EstimateTypeDropdown = (props: TProps) => { + const { value, onChange, projectId, cycleId, showDefault = false } = props; + const { getIsPointsDataAvailable } = useCycle(); + const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; + return getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled ? ( +
+ {cycleEstimateOptions.find((v) => v.value === value)?.label ?? "None"}} + onChange={onChange} + maxHeight="lg" + buttonClassName="bg-custom-background-90 border-none rounded text-sm font-medium " + > + {cycleEstimateOptions.map((item) => ( + + {item.label} + + ))} + +
+ ) : showDefault ? ( + {value} + ) : null; +}; diff --git a/web/core/components/cycles/dropdowns/index.ts b/web/core/components/cycles/dropdowns/index.ts index 302e3a1a6e..2d1f115541 100644 --- a/web/core/components/cycles/dropdowns/index.ts +++ b/web/core/components/cycles/dropdowns/index.ts @@ -1 +1,2 @@ export * from "./filters"; +export * from "./estimate-type-dropdown"; diff --git a/web/core/components/cycles/index.ts b/web/core/components/cycles/index.ts index f286b39e65..679ab7238a 100644 --- a/web/core/components/cycles/index.ts +++ b/web/core/components/cycles/index.ts @@ -1,6 +1,5 @@ export * from "./active-cycle"; export * from "./applied-filters"; -export * from "./board/"; export * from "./dropdowns"; export * from "./gantt-chart"; export * from "./list"; diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 5782881b13..989e0436e3 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -18,12 +18,15 @@ import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; // helpers import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { generateQueryParams } from "@/helpers/router.helper"; import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// services import { CycleService } from "@/services/cycle.service"; const cycleService = new CycleService(); @@ -208,7 +211,7 @@ export const CycleListItemAction: FC = observer((props) => { <>
@@ -223,7 +224,9 @@ export const CreatedUpcomingIssueListItem: React.FC = observ if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( @@ -281,7 +284,9 @@ export const CreatedOverdueIssueListItem: React.FC = observe if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( @@ -334,7 +339,9 @@ export const CreatedCompletedIssueListItem: React.FC = obser if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( diff --git a/web/core/components/dashboard/widgets/recent-activity.tsx b/web/core/components/dashboard/widgets/recent-activity.tsx index bc81f57c94..dd21815ccc 100644 --- a/web/core/components/dashboard/widgets/recent-activity.tsx +++ b/web/core/components/dashboard/widgets/recent-activity.tsx @@ -13,6 +13,7 @@ import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/component // helpers import { cn } from "@/helpers/common.helper"; import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useDashboard, useUser } from "@/hooks/store"; @@ -54,9 +55,9 @@ export const RecentActivityWidget: React.FC = observer((props) => {
) - ) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( + ) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? ( = observer((prop
= (props) => { const { dashboardId, searchQuery = "", workspaceSlug } = props; + + // state + const [visibleItems, setVisibleItems] = useState(16); + const [isExpanded, setIsExpanded] = useState(false); // store hooks const { fetchWidgetStats } = useDashboard(); const { getUserDetails } = useMember(); @@ -88,8 +94,10 @@ export const CollaboratorsList: React.FC = (props) => { const sortedStats = sortBy(widgetStats, [(user) => user?.user_id !== currentUser?.id]); const filteredStats = sortedStats.filter((user) => { - const { display_name, first_name, last_name } = getUserDetails(user?.user_id) || {}; - + if (!user) return false; + const userDetails = getUserDetails(user?.user_id); + if (!userDetails || userDetails.is_bot) return false; + const { display_name, first_name, last_name } = userDetails; const searchLower = searchQuery.toLowerCase(); return ( display_name?.toLowerCase().includes(searchLower) || @@ -98,16 +106,49 @@ export const CollaboratorsList: React.FC = (props) => { ); }); + // Update the displayedStats to always use the visibleItems limit + const handleLoadMore = () => { + setVisibleItems((prev) => { + const newValue = prev + 16; + if (newValue >= filteredStats.length) { + setIsExpanded(true); + return filteredStats.length; + } + return newValue; + }); + }; + + const handleHide = () => { + setVisibleItems(16); + setIsExpanded(false); + }; + + const displayedStats = filteredStats.slice(0, visibleItems); + return ( -
- {filteredStats?.map((user) => ( - - ))} -
+ <> +
+ {displayedStats?.map((user) => ( + + ))} +
+ {filteredStats.length > visibleItems && !isExpanded && ( +
+
+ Load more +
+
+ )} + {isExpanded && ( +
+
Hide
+
+ )} + ); }; diff --git a/web/core/components/dashboard/widgets/recent-projects.tsx b/web/core/components/dashboard/widgets/recent-projects.tsx index a390f3ac2d..5255908717 100644 --- a/web/core/components/dashboard/widgets/recent-projects.tsx +++ b/web/core/components/dashboard/widgets/recent-projects.tsx @@ -4,18 +4,20 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { Plus } from "lucide-react"; -// types +// plane types import { TRecentProjectsWidgetResponse } from "@plane/types"; -// ui +// plane ui import { Avatar, AvatarGroup, Card } from "@plane/ui"; - // components import { Logo } from "@/components/common"; import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets"; // constants import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useEventTracker, useDashboard, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const WIDGET_KEY = "recent_projects"; @@ -51,7 +53,11 @@ const ProjectListItem: React.FC = observer((props) => {
{projectDetails.members?.map((member) => ( - + ))}
diff --git a/web/core/components/dropdowns/member/avatar.tsx b/web/core/components/dropdowns/member/avatar.tsx index 50e3ae5990..0a7a92d438 100644 --- a/web/core/components/dropdowns/member/avatar.tsx +++ b/web/core/components/dropdowns/member/avatar.tsx @@ -1,10 +1,11 @@ "use client"; import { observer } from "mobx-react"; -// icons import { LucideIcon, Users } from "lucide-react"; -// ui +// plane ui import { Avatar, AvatarGroup } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember } from "@/hooks/store"; @@ -27,14 +28,21 @@ export const ButtonAvatars: React.FC = observer((props) => { const userDetails = getUserDetails(userId); if (!userDetails) return; - return ; + return ; })} ); } else { if (userIds) { const userDetails = getUserDetails(userIds); - return ; + return ( + + ); } } diff --git a/web/core/components/dropdowns/member/member-options.tsx b/web/core/components/dropdowns/member/member-options.tsx index bf14e14a64..cc34d25cc1 100644 --- a/web/core/components/dropdowns/member/member-options.tsx +++ b/web/core/components/dropdowns/member/member-options.tsx @@ -8,15 +8,17 @@ import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; -//components -import { cn } from "@plane/editor"; +// plane ui import { Avatar } from "@plane/ui"; -//store +// helpers +import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useUser, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; interface Props { - className? : string; + className?: string; optionsClassName?: string; projectId?: string; referenceElement: HTMLButtonElement | null; @@ -25,7 +27,7 @@ interface Props { } export const MemberOptions = observer((props: Props) => { - const { projectId, referenceElement, placement, isOpen, optionsClassName="" } = props; + const { projectId, referenceElement, placement, isOpen, optionsClassName = "" } = props; // states const [query, setQuery] = useState(""); const [popperElement, setPopperElement] = useState(null); @@ -82,7 +84,7 @@ export const MemberOptions = observer((props: Props) => { query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, content: (
- + {currentUser?.id === userId ? "You" : userDetails?.display_name}
), @@ -95,8 +97,10 @@ export const MemberOptions = observer((props: Props) => { return createPortal(
void; onClose?: () => void; - renderCondition?: (project: IProject) => boolean; + renderCondition?: (project: TProject) => boolean; value: string | null; renderByDefault?: boolean; }; diff --git a/web/core/components/editor/index.ts b/web/core/components/editor/index.ts index 72e92a6a8a..0b14bd1357 100644 --- a/web/core/components/editor/index.ts +++ b/web/core/components/editor/index.ts @@ -1,2 +1,3 @@ export * from "./lite-text-editor"; +export * from "./pdf"; export * from "./rich-text-editor"; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index 8036e4c8d4..3e64e83a33 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor"; // types import { IUserLite } from "@plane/types"; // components @@ -9,11 +9,12 @@ import { IssueCommentToolbar } from "@/components/editor"; import { EIssueCommentAccessSpecifier } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks import { useMember, useMention, useUser } from "@/hooks/store"; -// services -import { FileService } from "@/services/file.service"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; interface LiteTextEditorWrapperProps extends Omit { workspaceSlug: string; @@ -24,10 +25,9 @@ interface LiteTextEditorWrapperProps extends Omit Promise; } -const fileService = new FileService(); - export const LiteTextEditor = React.forwardRef((props, ref) => { const { containerClassName, @@ -40,6 +40,7 @@ export const LiteTextEditor = React.forwardRef { if (isMutableRefObject(ref)) { - ref.current?.executeMenuItemCommand(key); + ref.current?.executeMenuItemCommand({ + itemKey: key as TNonColorEditorCommands, + }); } }} handleAccessChange={handleAccessChange} diff --git a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx index 0dd2b1bd3e..e2585a4724 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx @@ -3,13 +3,17 @@ import React from "react"; import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMention, useUser } from "@/hooks/store"; -type LiteTextReadOnlyEditorWrapperProps = Omit; +type LiteTextReadOnlyEditorWrapperProps = Omit & { + workspaceSlug: string; + projectId: string; +}; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ workspaceSlug, projectId, ...props }, ref) => { // store hooks const { data: currentUser } = useUser(); const { mentionHighlights } = useMention({ @@ -19,6 +23,10 @@ export const LiteTextReadOnlyEditor = React.forwardRef = (props) => { .flat() .forEach((item) => { // Assert that editorRef.current is not null - newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({ + itemKey: item.key as TNonColorEditorCommands, + }); }); setActiveStates(newActiveStates); } diff --git a/web/core/components/editor/pdf/document.tsx b/web/core/components/editor/pdf/document.tsx new file mode 100644 index 0000000000..4dca9e6d53 --- /dev/null +++ b/web/core/components/editor/pdf/document.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Document, Font, Page, PageProps } from "@react-pdf/renderer"; +import { Html } from "react-pdf-html"; +// constants +import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor"; + +Font.register({ + family: "Inter", + fonts: [ + { src: "/fonts/inter/thin.ttf", fontWeight: "thin" }, + { src: "/fonts/inter/thin.ttf", fontWeight: "thin", fontStyle: "italic" }, + { src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight" }, + { src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight", fontStyle: "italic" }, + { src: "/fonts/inter/light.ttf", fontWeight: "light" }, + { src: "/fonts/inter/light.ttf", fontWeight: "light", fontStyle: "italic" }, + { src: "/fonts/inter/regular.ttf", fontWeight: "normal" }, + { src: "/fonts/inter/regular.ttf", fontWeight: "normal", fontStyle: "italic" }, + { src: "/fonts/inter/medium.ttf", fontWeight: "medium" }, + { src: "/fonts/inter/medium.ttf", fontWeight: "medium", fontStyle: "italic" }, + { src: "/fonts/inter/semibold.ttf", fontWeight: "semibold" }, + { src: "/fonts/inter/semibold.ttf", fontWeight: "semibold", fontStyle: "italic" }, + { src: "/fonts/inter/bold.ttf", fontWeight: "bold" }, + { src: "/fonts/inter/bold.ttf", fontWeight: "bold", fontStyle: "italic" }, + { src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold" }, + { src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold", fontStyle: "italic" }, + { src: "/fonts/inter/heavy.ttf", fontWeight: "heavy" }, + { src: "/fonts/inter/heavy.ttf", fontWeight: "heavy", fontStyle: "italic" }, + ], +}); + +type Props = { + content: string; + pageFormat: PageProps["size"]; +}; + +export const PDFDocument: React.FC = (props) => { + const { content, pageFormat } = props; + + return ( + + + {content} + + + ); +}; diff --git a/web/core/components/editor/pdf/index.ts b/web/core/components/editor/pdf/index.ts new file mode 100644 index 0000000000..fe6d89c0eb --- /dev/null +++ b/web/core/components/editor/pdf/index.ts @@ -0,0 +1 @@ +export * from "./document"; diff --git a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx index bb29669379..5e7eb80d67 100644 --- a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx +++ b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx @@ -5,21 +5,21 @@ import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/edi import { IUserLite } from "@plane/types"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; // hooks import { useMember, useMention, useUser } from "@/hooks/store"; -// services -import { FileService } from "@/services/file.service"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; interface RichTextEditorWrapperProps extends Omit { workspaceSlug: string; workspaceId: string; projectId: string; + uploadFile: (file: File) => Promise; } -const fileService = new FileService(); - export const RichTextEditor = forwardRef((props, ref) => { - const { containerClassName, workspaceSlug, workspaceId, projectId, ...rest } = props; + const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props; // store hooks const { data: currentUser } = useUser(); const { @@ -36,16 +36,19 @@ export const RichTextEditor = forwardRef; +type RichTextReadOnlyEditorWrapperProps = Omit & { + workspaceSlug: string; + projectId?: string; +}; export const RichTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ workspaceSlug, projectId, ...props }, ref) => { const { mentionHighlights } = useMention({}); return ( = observer((p const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox(); const { data: currentUser } = useUser(); const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); const router = useAppRouter(); const { getProjectById } = useProject(); @@ -89,6 +89,12 @@ export const InboxIssueActionsHeader: FC = observer((p const canDelete = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) || issue?.created_by === currentUser?.id; + const isProjectAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug, + projectId + ); const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined; // days left for snooze const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till); @@ -199,6 +205,17 @@ export const InboxIssueActionsHeader: FC = observer((p [handleInboxIssueNavigation] ); + const handleActionWithPermission = (isAdmin: boolean, action: () => void, errorMessage: string) => { + if (isAdmin) action(); + else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Permission denied", + message: errorMessage, + }); + } + }; + useEffect(() => { if (!isNotificationEmbed) document.addEventListener("keydown", onKeyDown); return () => { @@ -217,16 +234,19 @@ export const InboxIssueActionsHeader: FC = observer((p value={inboxIssue?.duplicate_to} onSubmit={handleInboxIssueDuplicate} /> - - setAcceptIssueModal(false)} - issue={inboxIssue?.issue} - onSubmit={handleInboxIssueAccept} + setAcceptIssueModal(false)} + beforeFormSubmit={handleInboxIssueAccept} + withDraftIssueWrapper={false} + fetchIssueDetails={false} + modalTitle={`Move ${currentProjectDetails?.identifier}-${issue?.sequence_id} to project issues`} + primaryButtonText={{ + default: "Add to project", + loading: "Adding", + }} /> - = observer((p size="sm" prependIcon={} className="text-green-500 border-0.5 border-green-500 bg-green-500/20 focus:bg-green-500/20 focus:text-green-500 hover:bg-green-500/40 bg-opacity-20" - onClick={() => setAcceptIssueModal(true)} + onClick={() => + handleActionWithPermission( + isProjectAdmin, + () => setAcceptIssueModal(true), + "Only project admins can accept issues" + ) + } > Accept @@ -307,7 +333,13 @@ export const InboxIssueActionsHeader: FC = observer((p size="sm" prependIcon={} className="text-red-500 border-0.5 border-red-500 bg-red-500/20 focus:bg-red-500/20 focus:text-red-500 hover:bg-red-500/40 bg-opacity-20" - onClick={() => setDeclineIssueModal(true)} + onClick={() => + handleActionWithPermission( + isProjectAdmin, + () => setDeclineIssueModal(true), + "Only project admins can deny issues" + ) + } > Decline @@ -341,7 +373,15 @@ export const InboxIssueActionsHeader: FC = observer((p {isAllowed && ( {canMarkAsAccepted && ( - + + handleActionWithPermission( + isProjectAdmin, + handleIssueSnoozeAction, + "Only project admins can snooze/Un-snooze issues" + ) + } + >
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 @@ -351,7 +391,15 @@ export const InboxIssueActionsHeader: FC = observer((p )} {canMarkAsDuplicate && ( - setSelectDuplicateIssue(true)}> + + handleActionWithPermission( + isProjectAdmin, + () => setSelectDuplicateIssue(true), + "Only project admins can mark issues as duplicate" + ) + } + >
Mark as duplicate @@ -401,6 +449,8 @@ export const InboxIssueActionsHeader: FC = observer((p setIsMobileSidebar={setIsMobileSidebar} isNotificationEmbed={isNotificationEmbed} embedRemoveCurrentNotification={embedRemoveCurrentNotification} + isProjectAdmin={isProjectAdmin} + handleActionWithPermission={handleActionWithPermission} />
diff --git a/web/core/components/inbox/content/inbox-issue-mobile-header.tsx b/web/core/components/inbox/content/inbox-issue-mobile-header.tsx index e87573e9be..7a66d0976f 100644 --- a/web/core/components/inbox/content/inbox-issue-mobile-header.tsx +++ b/web/core/components/inbox/content/inbox-issue-mobile-header.tsx @@ -47,6 +47,8 @@ type Props = { setIsMobileSidebar: (value: boolean) => void; isNotificationEmbed: boolean; embedRemoveCurrentNotification?: () => void; + isProjectAdmin: boolean; + handleActionWithPermission: (isAdmin: boolean, action: () => void, errorMessage: string) => void; }; export const InboxIssueActionsMobileHeader: React.FC = observer((props) => { @@ -70,6 +72,8 @@ export const InboxIssueActionsMobileHeader: React.FC = observer((props) = setIsMobileSidebar, isNotificationEmbed, embedRemoveCurrentNotification, + isProjectAdmin, + handleActionWithPermission, } = props; const router = useAppRouter(); const issue = inboxIssue?.issue; @@ -139,7 +143,15 @@ export const InboxIssueActionsMobileHeader: React.FC = observer((props) =
)} {canMarkAsAccepted && !isAcceptedOrDeclined && ( - + + handleActionWithPermission( + isProjectAdmin, + handleIssueSnoozeAction, + "Only project admins can snooze/Un-snooze issues" + ) + } + >
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 ? "Un-snooze" : "Snooze"} @@ -147,7 +159,15 @@ export const InboxIssueActionsMobileHeader: React.FC = observer((props) = )} {canMarkAsDuplicate && !isAcceptedOrDeclined && ( - setSelectDuplicateIssue(true)}> + + handleActionWithPermission( + isProjectAdmin, + () => setSelectDuplicateIssue(true), + "Only project admins can mark issues as duplicate" + ) + } + >
Mark as duplicate @@ -155,7 +175,15 @@ export const InboxIssueActionsMobileHeader: React.FC = observer((props) = )} {canMarkAsAccepted && ( - setAcceptIssueModal(true)}> + + handleActionWithPermission( + isProjectAdmin, + () => setAcceptIssueModal(true), + "Only project admins can accept issues" + ) + } + >
Accept @@ -163,7 +191,15 @@ export const InboxIssueActionsMobileHeader: React.FC = observer((props) = )} {canMarkAsDeclined && ( - setDeclineIssueModal(true)}> + + handleActionWithPermission( + isProjectAdmin, + () => setDeclineIssueModal(true), + "Only project admins can deny issues" + ) + } + >
Decline diff --git a/web/core/components/inbox/content/issue-root.tsx b/web/core/components/inbox/content/issue-root.tsx index df6ccdce5c..87c8ae6d2c 100644 --- a/web/core/components/inbox/content/issue-root.tsx +++ b/web/core/components/inbox/content/issue-root.tsx @@ -3,7 +3,9 @@ import { Dispatch, SetStateAction, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; +// plane types import { TIssue } from "@plane/types"; +// plane ui import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; // components import { InboxIssueContentProperties } from "@/components/inbox/content"; diff --git a/web/core/components/inbox/content/root.tsx b/web/core/components/inbox/content/root.tsx index 852be8a80b..78e682340c 100644 --- a/web/core/components/inbox/content/root.tsx +++ b/web/core/components/inbox/content/root.tsx @@ -62,10 +62,10 @@ export const InboxContentRoot: FC = observer((props) => { } ); - const isEditable = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], - EUserPermissionsLevel.PROJECT - ); + const isEditable = + allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT) || + inboxIssue?.created_by === currentUser?.id; + const isGuest = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST; const isOwner = inboxIssue?.issue.created_by === currentUser?.id; const readOnly = !isOwner && isGuest; diff --git a/web/core/components/inbox/inbox-filter/applied-filters/member.tsx b/web/core/components/inbox/inbox-filter/applied-filters/member.tsx index 2bf69b0231..aff556c621 100644 --- a/web/core/components/inbox/inbox-filter/applied-filters/member.tsx +++ b/web/core/components/inbox/inbox-filter/applied-filters/member.tsx @@ -3,8 +3,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; +// plane types import { TInboxIssueFilterMemberKeys } from "@plane/types"; +// plane ui import { Avatar, Tag } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember, useProjectInbox } from "@/hooks/store"; @@ -37,7 +41,12 @@ export const InboxIssueAppliedFiltersMember: FC return (
- +
{optionDetail?.display_name}
= observer((props: Props) => { key={`members-${member.id}`} isChecked={filterValue?.includes(member.id) ? true : false} onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))} - icon={} + icon={ + + } title={currentUser?.id === member.id ? "You" : member?.display_name} /> ); diff --git a/web/core/components/inbox/modals/create-edit-modal/edit-root.tsx b/web/core/components/inbox/modals/create-edit-modal/edit-root.tsx deleted file mode 100644 index 05ff4f1e50..0000000000 --- a/web/core/components/inbox/modals/create-edit-modal/edit-root.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client"; - -import { FC, useCallback, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { usePathname } from "next/navigation"; -// editor -import { EditorRefApi } from "@plane/editor"; -// types -import { TIssue } from "@plane/types"; -// ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { - InboxIssueTitle, - InboxIssueDescription, - InboxIssueProperties, -} from "@/components/inbox/modals/create-edit-modal"; -// constants -import { ISSUE_UPDATED } from "@/constants/event-tracker"; -// helpers -import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -// hooks -import { useEventTracker, useInboxIssues, useProject, useWorkspace } from "@/hooks/store"; - -type TInboxIssueEditRoot = { - workspaceSlug: string; - projectId: string; - issueId: string; - issue: Partial; - handleModalClose: () => void; - onSubmit?: () => void; -}; - -export const InboxIssueEditRoot: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, issue, handleModalClose, onSubmit } = props; - const pathname = usePathname(); - // refs - const descriptionEditorRef = useRef(null); - const submitBtnRef = useRef(null); - // store hooks - const { captureIssueEvent } = useEventTracker(); - const { currentProjectDetails } = useProject(); - const { updateProjectIssue } = useInboxIssues(issueId); - const { getWorkspaceBySlug } = useWorkspace(); - const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; - // states - const [formSubmitting, setFormSubmitting] = useState(false); - const [formData, setFormData] = useState | undefined>(undefined); - const handleFormData = useCallback( - >(issueKey: T, issueValue: Partial[T]) => { - setFormData({ - ...formData, - [issueKey]: issueValue, - }); - }, - [formData] - ); - - useEffect(() => { - if (formData?.id != issue?.id) - setFormData({ - id: issue?.id || undefined, - name: issue?.name ?? "", - description_html: issue?.description_html ?? "

", - priority: issue?.priority ?? "none", - state_id: issue?.state_id ?? "", - label_ids: issue?.label_ids ?? [], - assignee_ids: issue?.assignee_ids ?? [], - start_date: renderFormattedPayloadDate(issue?.start_date) ?? "", - target_date: renderFormattedPayloadDate(issue?.target_date) ?? "", - }); - }, [issue, formData]); - - const handleFormSubmit = async () => { - const payload: Partial = { - name: formData?.name || "", - description_html: formData?.description_html || "

", - priority: formData?.priority || "none", - state_id: formData?.state_id || "", - label_ids: formData?.label_ids || [], - assignee_ids: formData?.assignee_ids || [], - start_date: formData?.start_date || undefined, - target_date: formData?.target_date || undefined, - cycle_id: formData?.cycle_id || "", - module_ids: formData?.module_ids || [], - estimate_point: formData?.estimate_point || undefined, - parent_id: formData?.parent_id || null, - }; - setFormSubmitting(true); - - onSubmit && (await onSubmit()); - await updateProjectIssue(payload) - .then(async () => { - captureIssueEvent({ - eventName: ISSUE_UPDATED, - payload: { - ...formData, - state: "SUCCESS", - element: "Inbox page", - }, - path: pathname, - }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: `Success!`, - message: "Issue created successfully.", - }); - descriptionEditorRef?.current?.clearEditor(); - handleModalClose(); - }) - .catch((error) => { - console.error(error); - captureIssueEvent({ - eventName: ISSUE_UPDATED, - payload: { - ...formData, - state: "FAILED", - element: "Inbox page", - }, - path: pathname, - }); - setToast({ - type: TOAST_TYPE.ERROR, - title: `Error!`, - message: "Some error occurred. Please try again.", - }); - }); - setFormSubmitting(false); - }; - - const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false; - - if (!workspaceSlug || !projectId || !workspaceId || !formData) return <>; - return ( - <> -
-

- Move {currentProjectDetails?.identifier}-{issue?.sequence_id} to project issues -

-
- - submitBtnRef?.current?.click()} - /> - -
-
-
- - -
- - ); -}); diff --git a/web/core/components/inbox/modals/create-edit-modal/modal.tsx b/web/core/components/inbox/modals/create-edit-modal/modal.tsx deleted file mode 100644 index 5d51477b6b..0000000000 --- a/web/core/components/inbox/modals/create-edit-modal/modal.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { FC } from "react"; -// types -import { TIssue } from "@plane/types"; -// ui -import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; -// components -import { InboxIssueCreateRoot, InboxIssueEditRoot } from "@/components/inbox/modals/create-edit-modal"; - -type TInboxIssueCreateEditModalRoot = { - workspaceSlug: string; - projectId: string; - modalState: boolean; - handleModalClose: () => void; - issue: Partial | undefined; - onSubmit?: () => void; -}; - -export const InboxIssueCreateEditModalRoot: FC = (props) => { - const { workspaceSlug, projectId, modalState, handleModalClose, issue, onSubmit } = props; - - return ( - - {issue && issue?.id ? ( - - ) : ( - - )} - - ); -}; diff --git a/web/core/components/inbox/modals/create-edit-modal/create-root.tsx b/web/core/components/inbox/modals/create-modal/create-root.tsx similarity index 90% rename from web/core/components/inbox/modals/create-edit-modal/create-root.tsx rename to web/core/components/inbox/modals/create-modal/create-root.tsx index b4dfd6904d..d874689c0b 100644 --- a/web/core/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/core/components/inbox/modals/create-modal/create-root.tsx @@ -9,11 +9,7 @@ import { EditorRefApi } from "@plane/editor"; import { TIssue } from "@plane/types"; import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; // components -import { - InboxIssueTitle, - InboxIssueDescription, - InboxIssueProperties, -} from "@/components/inbox/modals/create-edit-modal"; +import { InboxIssueTitle, InboxIssueDescription, InboxIssueProperties } from "@/components/inbox/modals/create-modal"; // constants import { ISSUE_CREATED } from "@/constants/event-tracker"; import { ETabIndices } from "@/constants/tab-indices"; @@ -25,6 +21,9 @@ import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import useKeypress from "@/hooks/use-keypress"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TInboxIssueCreateRoot = { workspaceSlug: string; @@ -46,6 +45,9 @@ export const defaultIssueData: Partial = { export const InboxIssueCreateRoot: FC = observer((props) => { const { workspaceSlug, projectId, handleModalClose } = props; + // states + const [uploadedAssetIds, setUploadedAssetIds] = useState([]); + // router const router = useAppRouter(); const pathname = usePathname(); // refs @@ -112,7 +114,13 @@ export const InboxIssueCreateRoot: FC = observer((props) setFormSubmitting(true); await createInboxIssue(workspaceSlug, projectId, payload) - .then((res) => { + .then(async (res) => { + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res?.issue.id ?? "", { + asset_ids: uploadedAssetIds, + }); + setUploadedAssetIds([]); + } if (!createMore) { router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`); handleModalClose(); @@ -177,6 +185,7 @@ export const InboxIssueCreateRoot: FC = observer((props) editorRef={descriptionEditorRef} containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" onEnterKeyPress={() => submitBtnRef?.current?.click()} + onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} />
@@ -188,7 +197,7 @@ export const InboxIssueCreateRoot: FC = observer((props) role="button" tabIndex={getIndex("create_more")} > - {}} size="sm" /> + { }} size="sm" /> Create more
diff --git a/web/core/components/inbox/modals/create-edit-modal/index.ts b/web/core/components/inbox/modals/create-modal/index.ts similarity index 84% rename from web/core/components/inbox/modals/create-edit-modal/index.ts rename to web/core/components/inbox/modals/create-modal/index.ts index 484c1a31e4..907c3ddbaf 100644 --- a/web/core/components/inbox/modals/create-edit-modal/index.ts +++ b/web/core/components/inbox/modals/create-modal/index.ts @@ -1,6 +1,5 @@ export * from "./modal"; export * from "./create-root"; -export * from "./edit-root"; export * from "./issue-title"; export * from "./issue-description"; export * from "./issue-properties"; diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/core/components/inbox/modals/create-modal/issue-description.tsx similarity index 68% rename from web/core/components/inbox/modals/create-edit-modal/issue-description.tsx rename to web/core/components/inbox/modals/create-modal/issue-description.tsx index 4daface0bc..b9bad6c11a 100644 --- a/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-description.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { EditorRefApi } from "@plane/editor"; // types import { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components @@ -18,6 +19,9 @@ import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TInboxIssueDescription = { containerClassName?: string; @@ -28,12 +32,22 @@ type TInboxIssueDescription = { handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; editorRef: RefObject; onEnterKeyPress?: (e?: any) => void; + onAssetUpload?: (assetId: string) => void; }; // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef, onEnterKeyPress } = - props; + const { + containerClassName, + workspaceSlug, + projectId, + workspaceId, + data, + handleData, + editorRef, + onEnterKeyPress, + onAssetUpload, + } = props; // hooks const { loader } = useProjectInbox(); const { isMobile } = usePlatformOS(); @@ -61,6 +75,24 @@ export const InboxIssueDescription: FC = observer((props containerClassName={containerClassName} onEnterKeyPress={onEnterKeyPress} tabIndex={getIndex("description_html")} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: data.id ?? "", + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + onAssetUpload?.(asset_id); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> ); }); diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-properties.tsx b/web/core/components/inbox/modals/create-modal/issue-properties.tsx similarity index 99% rename from web/core/components/inbox/modals/create-edit-modal/issue-properties.tsx rename to web/core/components/inbox/modals/create-modal/issue-properties.tsx index b17a60abdd..313adaf301 100644 --- a/web/core/components/inbox/modals/create-edit-modal/issue-properties.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-properties.tsx @@ -91,7 +91,7 @@ export const InboxIssueProperties: FC = observer((props) {/* labels */}
{}} + setIsOpen={() => { }} value={data?.label_ids || []} onChange={(labelIds) => handleData("label_ids", labelIds)} projectId={projectId} diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-title.tsx b/web/core/components/inbox/modals/create-modal/issue-title.tsx similarity index 100% rename from web/core/components/inbox/modals/create-edit-modal/issue-title.tsx rename to web/core/components/inbox/modals/create-modal/issue-title.tsx diff --git a/web/core/components/inbox/modals/create-modal/modal.tsx b/web/core/components/inbox/modals/create-modal/modal.tsx new file mode 100644 index 0000000000..7af26fbf91 --- /dev/null +++ b/web/core/components/inbox/modals/create-modal/modal.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +// ui +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; +// components +import { InboxIssueCreateRoot } from "@/components/inbox/modals/create-modal"; + +type TInboxIssueCreateModalRoot = { + workspaceSlug: string; + projectId: string; + modalState: boolean; + handleModalClose: () => void; +}; + +export const InboxIssueCreateModalRoot: FC = (props) => { + const { workspaceSlug, projectId, modalState, handleModalClose } = props; + + return ( + + + + ); +}; diff --git a/web/core/components/inbox/modals/index.ts b/web/core/components/inbox/modals/index.ts index 91e185ffee..78d9e6561b 100644 --- a/web/core/components/inbox/modals/index.ts +++ b/web/core/components/inbox/modals/index.ts @@ -1,4 +1,4 @@ -export * from "./create-edit-modal"; +export * from "./create-modal"; export * from "./decline-issue-modal"; export * from "./delete-issue-modal"; export * from "./select-duplicate"; diff --git a/web/core/components/integration/github/single-user-select.tsx b/web/core/components/integration/github/single-user-select.tsx index a936db6302..c2ecda03e8 100644 --- a/web/core/components/integration/github/single-user-select.tsx +++ b/web/core/components/integration/github/single-user-select.tsx @@ -2,15 +2,18 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; +// plane types import { IGithubRepoCollaborator } from "@plane/types"; -// services +// plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; +// constants import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; -// ui // types import { IUserDetails } from "./root"; -// fetch-keys type Props = { collaborator: IGithubRepoCollaborator; @@ -53,7 +56,7 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, query: member.member?.display_name ?? "", content: (
- + {member.member?.display_name}
), diff --git a/web/core/components/integration/jira/import-users.tsx b/web/core/components/integration/jira/import-users.tsx index 3b7a7cd737..6bffece7fc 100644 --- a/web/core/components/integration/jira/import-users.tsx +++ b/web/core/components/integration/jira/import-users.tsx @@ -4,14 +4,16 @@ import { FC } from "react"; import { useParams } from "next/navigation"; import { useFormContext, useFieldArray, Controller } from "react-hook-form"; import useSWR from "swr"; +// plane types import { IJiraImporterForm } from "@plane/types"; -// services +// plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui"; +// constants import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; -// ui -// types -// fetch keys const workspaceService = new WorkspaceService(); @@ -42,7 +44,7 @@ export const JiraImportUsers: FC = () => { query: member.member.display_name ?? "", content: (
- + {member.member.display_name}
), diff --git a/web/core/components/issues/attachment/attachment-detail.tsx b/web/core/components/issues/attachment/attachment-detail.tsx index 255b955bb3..04d5641af7 100644 --- a/web/core/components/issues/attachment/attachment-detail.tsx +++ b/web/core/components/issues/attachment/attachment-detail.tsx @@ -13,6 +13,7 @@ import { IssueAttachmentDeleteModal } from "@/components/issues"; // helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; import { truncateText } from "@/helpers/string.helper"; // hooks import { useIssueDetail, useMember } from "@/hooks/store"; @@ -40,6 +41,10 @@ export const IssueAttachmentsDetail: FC = observer((pro const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false); // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const fileName = getFileName(attachment?.attributes.name ?? ""); + const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileIcon = getFileIcon(fileExtension, 28); + const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks const { isMobile } = usePlatformOS(); @@ -56,13 +61,13 @@ export const IssueAttachmentsDetail: FC = observer((pro /> )}
- +
-
{getFileIcon(getFileExtension(attachment.asset), 28)}
+
{fileIcon}
- - {truncateText(`${getFileName(attachment.attributes.name)}`, 10)} + + {truncateText(`${fileName}`, 10)} = observer((pro
- {getFileExtension(attachment.asset).toUpperCase()} + {fileExtension.toUpperCase()} {convertBytesToSize(attachment.attributes.size)}
diff --git a/web/core/components/issues/attachment/attachment-item-list.tsx b/web/core/components/issues/attachment/attachment-item-list.tsx index a0126b2512..f1af2884cd 100644 --- a/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -3,10 +3,10 @@ import { observer } from "mobx-react"; import { FileRejection, useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; // hooks -import {TOAST_TYPE, setToast } from "@plane/ui"; -import { MAX_FILE_SIZE } from "@/constants/common"; -import { generateFileName } from "@/helpers/attachment.helper"; -import { useInstance, useIssueDetail } from "@/hooks/store"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { useIssueDetail } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; // components import { IssueAttachmentsListItem } from "./attachment-list-item"; // types @@ -24,66 +24,57 @@ type TIssueAttachmentItemList = { export const IssueAttachmentItemList: FC = observer((props) => { const { workspaceSlug, issueId, handleAttachmentOperations, disabled } = props; + // states const [isLoading, setIsLoading] = useState(false); - // store hooks - const { config } = useInstance(); const { attachment: { getAttachmentsByIssueId }, attachmentDeleteModalId, toggleDeleteAttachmentModal, } = useIssueDetail(); + // file size + const { maxFileSize } = useFileSize(); // derived values const issueAttachments = getAttachmentsByIssueId(issueId); const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { - const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - if(rejectedFiles.length===0){ + if (rejectedFiles.length === 0) { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData) - .catch(()=>{ - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", + handleAttachmentOperations + .create(currentFile) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }); }) - }) - .finally(() => setIsLoading(false)); + .finally(() => setIsLoading(false)); return; } setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: (totalAttachedFiles>1)? - "Only one file can be uploaded at a time." : - "File must be 5MB or less.", - }) + type: TOAST_TYPE.ERROR, + title: "Error!", + message: + totalAttachedFiles > 1 + ? "Only one file can be uploaded at a time." + : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, + }); return; }, - [handleAttachmentOperations, workspaceSlug] + [handleAttachmentOperations, maxFileSize, workspaceSlug] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); diff --git a/web/core/components/issues/attachment/attachment-list-item.tsx b/web/core/components/issues/attachment/attachment-list-item.tsx index 28cff6995f..e3adc5f828 100644 --- a/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/web/core/components/issues/attachment/attachment-list-item.tsx @@ -11,6 +11,7 @@ import { getFileIcon } from "@/components/icons"; // helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -29,9 +30,12 @@ export const IssueAttachmentsListItem: FC = observer( attachment: { getAttachmentById }, toggleDeleteAttachmentModal, } = useIssueDetail(); - // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const fileName = getFileName(attachment?.attributes.name ?? ""); + const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileIcon = getFileIcon(fileExtension, 18); + const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks const { isMobile } = usePlatformOS(); @@ -43,17 +47,14 @@ export const IssueAttachmentsListItem: FC = observer( onClick={(e) => { e.preventDefault(); e.stopPropagation(); - window.open(attachment.asset, "_blank"); + window.open(fileURL, "_blank"); }} >
-
{getFileIcon(getFileExtension(attachment.asset), 18)}
- -

{`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}

+
{fileIcon}
+ +

{`${fileName}.${fileExtension}`}

{convertBytesToSize(attachment.attributes.size)} diff --git a/web/core/components/issues/attachment/attachment-upload.tsx b/web/core/components/issues/attachment/attachment-upload.tsx index 4be4cf11ba..a2f5269009 100644 --- a/web/core/components/issues/attachment/attachment-upload.tsx +++ b/web/core/components/issues/attachment/attachment-upload.tsx @@ -1,12 +1,8 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// helpers -import { generateFileName } from "@/helpers/attachment.helper"; -// hooks -import { useInstance } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; // types import { TAttachmentOperations } from "./root"; @@ -20,43 +16,29 @@ type Props = { export const IssueAttachmentUpload: React.FC = observer((props) => { const { workspaceSlug, disabled = false, handleAttachmentOperations } = props; - // store hooks - const { config } = useInstance(); // states const [isLoading, setIsLoading] = useState(false); + // file size + const { maxFileSize } = useFileSize(); const onDrop = useCallback( (acceptedFiles: File[]) => { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); + handleAttachmentOperations.create(currentFile).finally(() => setIsLoading(false)); }, [handleAttachmentOperations, workspaceSlug] ); const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); - const maxFileSize = config?.file_size_limit ?? MAX_FILE_SIZE; - const fileError = fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null; diff --git a/web/core/components/issues/attachment/root.tsx b/web/core/components/issues/attachment/root.tsx index f1bec92e8f..e7874cc643 100644 --- a/web/core/components/issues/attachment/root.tsx +++ b/web/core/components/issues/attachment/root.tsx @@ -17,7 +17,7 @@ export type TIssueAttachmentRoot = { }; export type TAttachmentOperations = { - create: (data: FormData) => Promise; + create: (file: File) => Promise; remove: (linkId: string) => Promise; }; @@ -30,11 +30,11 @@ export const IssueAttachmentRoot: FC = (props) => { const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ - create: async (data: FormData) => { + create: async (file: File) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); + const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); setPromiseToast(attachmentUploadPromise, { loading: "Uploading attachment...", success: { diff --git a/web/core/components/issues/confirm-issue-discard.tsx b/web/core/components/issues/confirm-issue-discard.tsx index 09bedbf598..5807034908 100644 --- a/web/core/components/issues/confirm-issue-discard.tsx +++ b/web/core/components/issues/confirm-issue-discard.tsx @@ -61,10 +61,12 @@ export const ConfirmIssueDiscard: React.FC = (props) => {
- Draft Issue + Save this draft?
-

Would you like to save this issue in drafts?

+

+ You can save this issue to Drafts so you can come back to it later.{" "} +

@@ -80,7 +82,7 @@ export const ConfirmIssueDiscard: React.FC = (props) => { Cancel
diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index 56819d0061..8c18618c50 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // types import { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components @@ -15,6 +16,9 @@ import { TIssueOperations } from "@/components/issues/issue-detail"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); export type IssueDescriptionInputProps = { containerClassName?: string; @@ -115,12 +119,31 @@ export const IssueDescriptionInput: FC = observer((p placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } containerClassName={containerClassName} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: issueId, + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> ) : ( ) } diff --git a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx index 539c9ea189..b452dc3ad6 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -16,11 +16,12 @@ export const useAttachmentOperations = ( const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ - create: async (data: FormData) => { + create: async (file: File) => { + console.log("creating attachment...", file); try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); + const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); setPromiseToast(attachmentUploadPromise, { loading: "Uploading attachment...", success: { diff --git a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx index 01923b2106..105d7bd133 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -1,15 +1,15 @@ "use client"; + import React, { FC, useCallback, useState } from "react"; import { observer } from "mobx-react"; import { FileRejection, useDropzone } from "react-dropzone"; import { Plus } from "lucide-react"; -import {TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// helper -import { generateFileName } from "@/helpers/attachment.helper"; +// plane ui +import { TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useInstance, useIssueDetail } from "@/hooks/store"; +import { useIssueDetail } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; import { useAttachmentOperations } from "./helper"; @@ -26,73 +26,68 @@ export const IssueAttachmentActionButton: FC = observer((props) => { // state const [isLoading, setIsLoading] = useState(false); // store hooks - const { config } = useInstance(); const { setLastWidgetAction } = useIssueDetail(); - + // file size + const { maxFileSize } = useFileSize(); // operations const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); - // handlers const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - if(rejectedFiles.length===0){ + if (rejectedFiles.length === 0) { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData) - .catch(()=>{ - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", + handleAttachmentOperations + .create(currentFile) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }); }) - }) - .finally(() => { - setLastWidgetAction("attachments"); - setIsLoading(false); - }); - return; + .finally(() => { + setLastWidgetAction("attachments"); + setIsLoading(false); + }); + return; } setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: (totalAttachedFiles>1)? - "Only one file can be uploaded at a time." : - "File must be 5MB or less.", - }) + message: + totalAttachedFiles > 1 + ? "Only one file can be uploaded at a time." + : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, + }); return; }, - [handleAttachmentOperations, workspaceSlug] + [handleAttachmentOperations, maxFileSize, workspaceSlug] ); - const { getRootProps, getInputProps } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); return ( - +
{ + // TODO: Remove extra div and move event propagation to button + e.stopPropagation(); + }} + > + +
); -}); \ No newline at end of file +}); diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx index 2fb7116ffe..8b9a3eff0c 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx @@ -1,10 +1,11 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; import { MessageCircle } from "lucide-react"; -// hooks -import { calculateTimeAgo } from "@/helpers/date-time.helper"; -import { useIssueDetail } from "@/hooks/store"; // helpers +import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useIssueDetail } from "@/hooks/store"; type TIssueCommentBlock = { commentId: string; @@ -27,9 +28,9 @@ export const IssueCommentBlock: FC = observer((props) => {
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( { = observer((props) => { }; useEffect(() => { - isEditing && setFocus("comment_html"); + if (isEditing) { + setFocus("comment_html"); + } }, [isEditing, setFocus]); const commentHTML = watch("comment_html"); @@ -155,6 +157,10 @@ export const IssueCommentCard: FC = observer((props) => { } }} showSubmitButton={false} + uploadFile={async (file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(file, comment.id); + return asset_id; + }} />
@@ -189,7 +195,13 @@ export const IssueCommentCard: FC = observer((props) => { )}
)} - + = (props) => { const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props; + // states + const [uploadedAssetIds, setUploadedAssetIds] = useState([]); // refs - const editorRef = useRef(null); + const editorRef = useRef(null); // store hooks const workspaceStore = useWorkspace(); const { peekIssue } = useIssueDetail(); @@ -44,13 +51,24 @@ export const IssueCommentCreate: FC = (props) => { }, }); - const onSubmit = async (formData: Partial) => - await activityOperations.createComment(formData).finally(() => { - reset({ - comment_html: "

", + const onSubmit = async (formData: Partial) => { + await activityOperations + .createComment(formData) + .then(async (res) => { + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadedAssetIds([]); + } + }) + .finally(() => { + reset({ + comment_html: "

", + }); + editorRef.current?.clearEditor(); }); - editorRef.current?.clearEditor(); - }); + }; const commentHTML = watch("comment_html"); const isEmpty = isCommentEmpty(commentHTML); @@ -92,6 +110,11 @@ export const IssueCommentCreate: FC = (props) => { handleAccessChange={onAccessChange} showAccessSpecifier={showAccessSpecifier} isSubmitting={isSubmitting} + uploadFile={async (file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(file); + setUploadedAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} /> )} /> diff --git a/web/core/components/issues/issue-detail/issue-activity/root.tsx b/web/core/components/issues/issue-detail/issue-activity/root.tsx index 8bb15b9731..60f9e59c13 100644 --- a/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -1,9 +1,10 @@ "use client"; -import { FC, Fragment, useMemo, useState } from "react"; +import { FC, useMemo, useState } from "react"; import { observer } from "mobx-react"; // types -import { TIssueComment } from "@plane/types"; +import { TFileSignedURLResponse, TIssueComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -15,6 +16,9 @@ import { useIssueDetail, useProject } from "@/hooks/store"; import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog"; // plane web constants import { TActivityFilters, defaultActivityFilters } from "@/plane-web/constants/issues"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TIssueActivity = { workspaceSlug: string; @@ -25,9 +29,10 @@ type TIssueActivity = { }; export type TActivityOperations = { - createComment: (data: Partial) => Promise; + createComment: (data: Partial) => Promise; updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; + uploadCommentAsset: (file: File, commentId?: string) => Promise; }; export const IssueActivity: FC = observer((props) => { @@ -51,15 +56,16 @@ export const IssueActivity: FC = observer((props) => { const activityOperations: TActivityOperations = useMemo( () => ({ - createComment: async (data: Partial) => { + createComment: async (data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); - await createComment(workspaceSlug, projectId, issueId, data); + const comment = await createComment(workspaceSlug, projectId, issueId, data); setToast({ title: "Success!", type: TOAST_TYPE.SUCCESS, message: "Comment created successfully.", }); + return comment; } catch (error) { setToast({ title: "Error!", @@ -68,7 +74,7 @@ export const IssueActivity: FC = observer((props) => { }); } }, - updateComment: async (commentId: string, data: Partial) => { + updateComment: async (commentId, data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await updateComment(workspaceSlug, projectId, issueId, commentId, data); @@ -85,7 +91,7 @@ export const IssueActivity: FC = observer((props) => { }); } }, - removeComment: async (commentId: string) => { + removeComment: async (commentId) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await removeComment(workspaceSlug, projectId, issueId, commentId); @@ -102,6 +108,24 @@ export const IssueActivity: FC = observer((props) => { }); } }, + uploadCommentAsset: async (file, commentId) => { + try { + if (!workspaceSlug || !projectId) throw new Error("Missing fields"); + const res = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: commentId ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }, }), [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment] ); diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 2bb16f5153..ffd930de42 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -83,7 +83,6 @@ export const IssueMainContent: React.FC = observer((props) => { containerClassName="-ml-3" /> - {/* {issue?.description_html === issueDescription && ( */} = observer((props) => { setIsSubmitting={(value) => setIsSubmitting(value)} containerClassName="-ml-3 border-none" /> - {/* )} */} {currentUser && ( = observer((props) => { // hooks const { issueMap } = useIssues(); const { getProjectStates } = useProjectState(); + const { handleRedirection } = useIssuePeekOverviewRedirection(); + const { isMobile } = usePlatformOS(); const parentIssue = issueMap?.[issue.parent_id || ""] || undefined; @@ -42,7 +45,10 @@ export const IssueParentDetail: FC = observer((props) => { return ( <>
- + handleRedirection(workspaceSlug, parentIssue, isMobile)} + >
@@ -56,7 +62,7 @@ export const IssueParentDetail: FC = observer((props) => {
{(parentIssue?.name ?? "").substring(0, 50)}
- +
diff --git a/web/core/components/issues/issue-detail/parent/sibling-item.tsx b/web/core/components/issues/issue-detail/parent/sibling-item.tsx index da7adefa96..8fb9bbd9a2 100644 --- a/web/core/components/issues/issue-detail/parent/sibling-item.tsx +++ b/web/core/components/issues/issue-detail/parent/sibling-item.tsx @@ -33,6 +33,7 @@ export const IssueParentSiblingItem: FC = observer((pro {issueDetail.project_id && projectDetails?.identifier && ( diff --git a/web/core/components/issues/issue-detail/root.tsx b/web/core/components/issues/issue-detail/root.tsx index 062129846d..9db4b1ab9e 100644 --- a/web/core/components/issues/issue-detail/root.tsx +++ b/web/core/components/issues/issue-detail/root.tsx @@ -85,7 +85,7 @@ export const IssueDetailRoot: FC = observer((props) => { try { await fetchIssue(workspaceSlug, projectId, issueId); } catch (error) { - console.error("Error fetching the parent issue"); + console.error("Error fetching the parent issue:", error); } }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { @@ -101,6 +101,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in updating issue:", error); captureIssueEvent({ eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, @@ -132,6 +133,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in deleting issue:", error); setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -153,6 +155,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in archiving issue:", error); captureIssueEvent({ eventName: ISSUE_ARCHIVED, payload: { id: issueId, state: "FAILED", element: "Issue details page" }, @@ -318,6 +321,7 @@ export const IssueDetailRoot: FC = observer((props) => { archiveIssue, removeArchivedIssue, addIssueToCycle, + addCycleToIssue, removeIssueFromCycle, changeModulesInIssue, removeIssueFromModule, diff --git a/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx b/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx index a7a0a5c0ac..5a1becee22 100644 --- a/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx +++ b/web/core/components/issues/issue-layouts/calendar/quick-add-issue-actions.tsx @@ -8,7 +8,7 @@ import { PlusIcon } from "lucide-react"; // types import { ISearchIssueResponse, TIssue } from "@plane/types"; // ui -import { TOAST_TYPE, setToast, CustomMenu } from "@plane/ui"; +import { CustomMenu, setPromiseToast } from "@plane/ui"; // components import { ExistingIssuesListModal } from "@/components/core"; import { QuickAddIssueRoot } from "@/components/issues"; @@ -45,22 +45,21 @@ export const CalendarQuickAddIssueActions: FC = o if (!workspaceSlug || !projectId) return; const issueIds = data.map((i) => i.id); + const addExistingIssuesPromise = Promise.all( + data.map((issue) => updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {})) + ).then(() => addIssuesToView?.(issueIds)); - try { - // To handle all updates in parallel - await Promise.all( - data.map((issue) => - updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, prePopulatedData ?? {}) - ) - ); - await addIssuesToView?.(issueIds); - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, + setPromiseToast(addExistingIssuesPromise, { + loading: `Adding ${issueIds.length > 1 ? "issues" : "issue"} to cycle...`, + success: { + title: "Success!", + message: () => `${issueIds.length > 1 ? "Issues" : "Issue"} added to cycle successfully.`, + }, + error: { title: "Error!", - message: "Something went wrong. Please try again.", - }); - } + message: (err) => err?.message || "Something went wrong. Please try again.", + }, + }); }; const handleNewIssue = () => { @@ -130,4 +129,4 @@ export const CalendarQuickAddIssueActions: FC = o /> ); -}); +}); \ No newline at end of file diff --git a/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx b/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx index 7f71abe7d1..ed0b6a1544 100644 --- a/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx +++ b/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx @@ -2,9 +2,11 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane ui import { Avatar } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useMember } from "@/hooks/store"; type Props = { @@ -29,7 +31,12 @@ export const AppliedMembersFilters: React.FC = observer((props) => { return (
- + {memberDetails.display_name} {editable && ( } + workspaceSlug={workspaceSlug} + projectId={projectId} /> )}
diff --git a/web/core/components/issues/issue-modal/context/issue-modal.tsx b/web/core/components/issues/issue-modal/context/issue-modal.tsx index 845aec5525..8181445a45 100644 --- a/web/core/components/issues/issue-modal/context/issue-modal.tsx +++ b/web/core/components/issues/issue-modal/context/issue-modal.tsx @@ -21,6 +21,8 @@ export type TCreateUpdatePropertyValuesProps = { issueId: string; projectId: string; workspaceSlug: string; + issueTypeId: string | null | undefined; + isDraft?: boolean; }; export type TIssueModalContext = { diff --git a/web/core/components/issues/issue-modal/draft-issue-layout.tsx b/web/core/components/issues/issue-modal/draft-issue-layout.tsx index 49bb1734de..8146e6cb40 100644 --- a/web/core/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/core/components/issues/issue-modal/draft-issue-layout.tsx @@ -14,9 +14,7 @@ import { ConfirmIssueDiscard } from "@/components/issues"; import { isEmptyHtmlString } from "@/helpers/string.helper"; // hooks import { useIssueModal } from "@/hooks/context/use-issue-modal"; -import { useEventTracker } from "@/hooks/store"; -// services -import { IssueDraftService } from "@/services/issue"; +import { useEventTracker, useWorkspaceDraftIssues } from "@/hooks/store"; // local components import { IssueFormRoot } from "./form"; @@ -25,21 +23,27 @@ export interface DraftIssueProps { data?: Partial; issueTitleRef: React.MutableRefObject; isCreateMoreToggleEnabled: boolean; + onAssetUpload: (assetId: string) => void; onCreateMoreToggleChange: (value: boolean) => void; onChange: (formData: Partial | null) => void; onClose: (saveDraftIssueInLocalStorage?: boolean) => void; onSubmit: (formData: Partial, is_draft_issue?: boolean) => Promise; projectId: string; isDraft: boolean; + moveToIssue?: boolean; + modalTitle?: string; + primaryButtonText?: { + default: string; + loading: string; + }; } -const issueDraftService = new IssueDraftService(); - export const DraftIssueLayout: React.FC = observer((props) => { const { changesMade, data, issueTitleRef, + onAssetUpload, onChange, onClose, onSubmit, @@ -47,6 +51,9 @@ export const DraftIssueLayout: React.FC = observer((props) => { isCreateMoreToggleEnabled, onCreateMoreToggleChange, isDraft, + moveToIssue = false, + modalTitle, + primaryButtonText, } = props; // states const [issueDiscardModal, setIssueDiscardModal] = useState(false); @@ -57,6 +64,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { // store hooks const { captureIssueEvent } = useEventTracker(); const { handleCreateUpdatePropertyValues } = useIssueModal(); + const { createIssue } = useWorkspaceDraftIssues(); const handleClose = () => { if (data?.id) { @@ -95,15 +103,15 @@ export const DraftIssueLayout: React.FC = observer((props) => { const payload = { ...changesMade, name: changesMade?.name && changesMade?.name?.trim() !== "" ? changesMade.name?.trim() : "Untitled", + project_id: projectId, }; - const response = await issueDraftService - .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) + const response = await createIssue(workspaceSlug.toString(), payload) .then((res) => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", - message: "Draft Issue created successfully.", + message: "Draft created.", }); captureIssueEvent({ eventName: "Draft issue created", @@ -131,8 +139,10 @@ export const DraftIssueLayout: React.FC = observer((props) => { if (response && handleCreateUpdatePropertyValues) { handleCreateUpdatePropertyValues({ issueId: response.id, + issueTypeId: response.type_id, projectId, workspaceSlug: workspaceSlug?.toString(), + isDraft: true, }); } }; @@ -154,11 +164,15 @@ export const DraftIssueLayout: React.FC = observer((props) => { onCreateMoreToggleChange={onCreateMoreToggleChange} data={data} issueTitleRef={issueTitleRef} + onAssetUpload={onAssetUpload} onChange={onChange} onClose={handleClose} onSubmit={onSubmit} projectId={projectId} isDraft={isDraft} + moveToIssue={moveToIssue} + modalTitle={modalTitle} + primaryButtonText={primaryButtonText} /> ); diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx index 35785d3487..74808a003a 100644 --- a/web/core/components/issues/issue-modal/form.tsx +++ b/web/core/components/issues/issue-modal/form.tsx @@ -7,7 +7,7 @@ import { useForm } from "react-hook-form"; // editor import { EditorRefApi } from "@plane/editor"; // types -import type { TIssue, ISearchIssueResponse } from "@plane/types"; +import type { TIssue, ISearchIssueResponse, TWorkspaceDraftIssue } from "@plane/types"; // hooks import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -26,7 +26,7 @@ import { getChangedIssuefields } from "@/helpers/issue.helper"; import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks import { useIssueModal } from "@/hooks/context/use-issue-modal"; -import { useIssueDetail, useProject, useProjectState } from "@/hooks/store"; +import { useIssueDetail, useProject, useProjectState, useWorkspaceDraftIssues } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; // plane web components @@ -53,18 +53,26 @@ export interface IssueFormProps { data?: Partial; issueTitleRef: React.MutableRefObject; isCreateMoreToggleEnabled: boolean; + onAssetUpload: (assetId: string) => void; onCreateMoreToggleChange: (value: boolean) => void; onChange?: (formData: Partial | null) => void; onClose: () => void; onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; projectId: string; isDraft: boolean; + moveToIssue?: boolean; + modalTitle?: string; + primaryButtonText?: { + default: string; + loading: string; + }; } export const IssueFormRoot: FC = observer((props) => { const { data, issueTitleRef, + onAssetUpload, onChange, onClose, onSubmit, @@ -72,6 +80,12 @@ export const IssueFormRoot: FC = observer((props) => { isCreateMoreToggleEnabled, onCreateMoreToggleChange, isDraft, + moveToIssue, + modalTitle = `${data?.id ? "Update" : isDraft ? "Create a draft" : "Create new issue"}`, + primaryButtonText = { + default: `${data?.id ? "Update" : isDraft ? "Save to Drafts" : "Save"}`, + loading: `${data?.id ? "Updating" : "Saving"}`, + }, } = props; // states @@ -91,6 +105,7 @@ export const IssueFormRoot: FC = observer((props) => { const { getIssueTypeIdOnProjectChange, getActiveAdditionalPropertiesLength, handlePropertyValuesValidation } = useIssueModal(); const { isMobile } = usePlatformOS(); + const { moveIssue } = useWorkspaceDraftIssues(); const { issue: { getIssueById }, @@ -184,6 +199,7 @@ export const IssueFormRoot: FC = observer((props) => { project_id: getValues<"project_id">("project_id"), id: data.id, description_html: formData.description_html ?? "

", + type_id: getValues<"type_id">("type_id"), }; // this condition helps to move the issues from draft to project issues @@ -266,7 +282,7 @@ export const IssueFormRoot: FC = observer((props) => { )}
handleFormSubmit(data))}>
-

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

+

{modalTitle}

{/* Disable project selection if editing an issue */}
= observer((props) => {
= observer((props) => { } setGptAssistantModal={setGptAssistantModal} handleGptAssistantClose={() => reset(getValues())} + onAssetUpload={onAssetUpload} onClose={onClose} />
@@ -342,6 +361,7 @@ export const IssueFormRoot: FC = observer((props) => { issueTypeId={watch("type_id")} projectId={projectId} workspaceSlug={workspaceSlug?.toString()} + isDraft={isDraft} /> )}
@@ -397,41 +417,34 @@ export const IssueFormRoot: FC = observer((props) => { > Discard - {isDraft && ( - <> - {data?.id ? ( - - ) : ( - - )} - - )} + {moveToIssue && ( + + )}
diff --git a/web/core/components/issues/issue-modal/modal.tsx b/web/core/components/issues/issue-modal/modal.tsx index 9266cb3225..dffa94a527 100644 --- a/web/core/components/issues/issue-modal/modal.tsx +++ b/web/core/components/issues/issue-modal/modal.tsx @@ -15,11 +15,18 @@ export interface IssuesModalProps { data?: Partial; isOpen: boolean; onClose: () => void; + beforeFormSubmit?: () => Promise; onSubmit?: (res: TIssue) => Promise; withDraftIssueWrapper?: boolean; storeType?: EIssuesStoreType; isDraft?: boolean; fetchIssueDetails?: boolean; + moveToIssue?: boolean; + modalTitle?: string; + primaryButtonText?: { + default: string; + loading: string; + }; } export const CreateUpdateIssueModal: React.FC = observer( diff --git a/web/core/components/issues/peek-overview/index.ts b/web/core/components/issues/peek-overview/index.ts index 9cd51648b5..3e0d56558f 100644 --- a/web/core/components/issues/peek-overview/index.ts +++ b/web/core/components/issues/peek-overview/index.ts @@ -1,5 +1,4 @@ export * from "./header"; -export * from "./issue-attachments"; export * from "./issue-detail"; export * from "./properties"; export * from "./root"; diff --git a/web/core/components/issues/peek-overview/issue-attachments.tsx b/web/core/components/issues/peek-overview/issue-attachments.tsx deleted file mode 100644 index 8ffcdc2773..0000000000 --- a/web/core/components/issues/peek-overview/issue-attachments.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -// hooks -import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; -import { IssueAttachmentUpload, IssueAttachmentsList, TAttachmentOperations } from "@/components/issues"; -import { useEventTracker, useIssueDetail } from "@/hooks/store"; -// components -// ui - -type Props = { - disabled: boolean; - issueId: string; - projectId: string; - workspaceSlug: string; -}; - -export const PeekOverviewIssueAttachments: React.FC = (props) => { - const { disabled, issueId, projectId, workspaceSlug } = props; - // store hooks - const { captureIssueEvent } = useEventTracker(); - const { - attachment: { createAttachment, removeAttachment }, - } = useIssueDetail(); - - const handleAttachmentOperations: TAttachmentOperations = useMemo( - () => ({ - create: async (data: FormData) => { - try { - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); - setPromiseToast(attachmentUploadPromise, { - loading: "Uploading attachment...", - success: { - title: "Attachment uploaded", - message: () => "The attachment has been successfully uploaded", - }, - error: { - title: "Attachment not uploaded", - message: () => "The attachment could not be uploaded", - }, - }); - - const res = await attachmentUploadPromise; - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: res.id, - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - }); - } - }, - remove: async (attachmentId: string) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); - setToast({ - message: "The attachment has been successfully removed", - type: TOAST_TYPE.SUCCESS, - title: "Attachment removed", - }); - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - setToast({ - message: "The Attachment could not be removed", - type: TOAST_TYPE.ERROR, - title: "Attachment not removed", - }); - } - }, - }), - [workspaceSlug, projectId, issueId, captureIssueEvent, createAttachment, removeAttachment] - ); - - return ( -
-
Attachments
-
- - -
-
- ); -}; diff --git a/web/core/components/issues/peek-overview/issue-detail.tsx b/web/core/components/issues/peek-overview/issue-detail.tsx index 20117d10f5..242ebfd0ce 100644 --- a/web/core/components/issues/peek-overview/issue-detail.tsx +++ b/web/core/components/issues/peek-overview/issue-detail.tsx @@ -1,7 +1,7 @@ import { FC, useEffect } from "react"; import { observer } from "mobx-react"; // components -import { TIssueOperations } from "@/components/issues"; +import { IssueParentDetail, TIssueOperations } from "@/components/issues"; // store hooks import { useIssueDetail, useUser } from "@/hooks/store"; // hooks @@ -57,6 +57,15 @@ export const PeekOverviewIssueDetails: FC = observer( return (
+ {issue.parent_id && ( + + )} = observer((props) => { const removeRoutePeekId = () => { setPeekIssue(undefined); - if (embedIssue) embedRemoveCurrentNotification && embedRemoveCurrentNotification(); + if (embedIssue) embedRemoveCurrentNotification?.(); }; const issueOperations: TIssueOperations = useMemo( () => ({ - fetch: async (workspaceSlug: string, projectId: string, issueId: string, loader = true) => { + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { try { setError(false); await fetchIssue( @@ -67,8 +70,8 @@ export const IssuePeekOverview: FC = observer((props) => { } }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { - issues?.updateIssue && - (await issues + if (issues?.updateIssue) { + await issues .updateIssue(workspaceSlug, projectId, issueId, data) .then(async () => { fetchActivities(workspaceSlug, projectId, issueId); @@ -93,7 +96,8 @@ export const IssuePeekOverview: FC = observer((props) => { type: TOAST_TYPE.ERROR, message: "Issue update failed", }); - })); + }); + } }, remove: async (workspaceSlug: string, projectId: string, issueId: string) => { try { diff --git a/web/core/components/issues/workspace-draft/delete-modal.tsx b/web/core/components/issues/workspace-draft/delete-modal.tsx new file mode 100644 index 0000000000..9eefe0d032 --- /dev/null +++ b/web/core/components/issues/workspace-draft/delete-modal.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +// types +import { TWorkspaceDraftIssue } from "@plane/types"; +// ui +import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +// constants +import { PROJECT_ERROR_MESSAGES } from "@/constants/project"; +// hooks +import { useIssues, useUser, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +type Props = { + isOpen: boolean; + handleClose: () => void; + dataId?: string | null | undefined; + data?: TWorkspaceDraftIssue; + onSubmit?: () => Promise; +}; + +export const WorkspaceDraftIssueDeleteIssueModal: React.FC = (props) => { + const { dataId, data, isOpen, handleClose, onSubmit } = props; + // states + const [isDeleting, setIsDeleting] = useState(false); + // store hooks + const { issueMap } = useIssues(); + const { allowPermissions } = useUserPermissions(); + + const { data: currentUser } = useUser(); + + // derived values + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + + useEffect(() => { + setIsDeleting(false); + }, [isOpen]); + + if (!dataId && !data) return null; + + // derived values + const issue = data ? data : issueMap[dataId!]; + const isIssueCreator = issue?.created_by === currentUser?.id; + const authorized = isIssueCreator || canPerformProjectAdminActions; + + const onClose = () => { + setIsDeleting(false); + handleClose(); + }; + + const handleIssueDelete = async () => { + setIsDeleting(true); + + if (!authorized) { + setToast({ + title: PROJECT_ERROR_MESSAGES.permissionError.title, + type: TOAST_TYPE.ERROR, + message: PROJECT_ERROR_MESSAGES.permissionError.message, + }); + onClose(); + return; + } + if (onSubmit) + await onSubmit() + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: `draft deleted.`, + }); + onClose(); + }) + .catch((errors) => { + const isPermissionError = errors?.error === "Only admin or creator can delete the issue"; + const currentError = isPermissionError + ? PROJECT_ERROR_MESSAGES.permissionError + : PROJECT_ERROR_MESSAGES.issueDeleteError; + setToast({ + title: currentError.title, + type: TOAST_TYPE.ERROR, + message: currentError.message, + }); + }) + .finally(() => onClose()); + }; + + return ( + Are you sure you want to delete this draft? This can't be undone.} + /> + ); +}; diff --git a/web/core/components/issues/workspace-draft/draft-issue-block.tsx b/web/core/components/issues/workspace-draft/draft-issue-block.tsx new file mode 100644 index 0000000000..f983864595 --- /dev/null +++ b/web/core/components/issues/workspace-draft/draft-issue-block.tsx @@ -0,0 +1,199 @@ +"use client"; +import React, { FC, useRef, useState } from "react"; +import { omit } from "lodash"; +import { observer } from "mobx-react"; +import { Copy, Pencil, SquareStackIcon, Trash2 } from "lucide-react"; +// types +import { TWorkspaceDraftIssue } from "@plane/types"; +// ui +import { Row, TContextMenuItem, Tooltip } from "@plane/ui"; +// constants +import { EIssuesStoreType } from "@/constants/issue"; +// helper +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme, useProject, useWorkspaceDraftIssues } from "@/hooks/store"; +// plane-web components +import { IdentifierText, IssueTypeIdentifier } from "@/plane-web/components/issues"; +// local components +import { WorkspaceDraftIssueQuickActions } from "../issue-layouts"; +import { CreateUpdateIssueModal } from "../issue-modal"; +import { WorkspaceDraftIssueDeleteIssueModal } from "./delete-modal"; +import { DraftIssueProperties } from "./draft-issue-properties"; + +type Props = { + workspaceSlug: string; + issueId: string; +}; + +export const DraftIssueBlock: FC = observer((props) => { + // props + const { workspaceSlug, issueId } = props; + // states + const [moveToIssue, setMoveToIssue] = useState(false); + const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState(undefined); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + // hooks + const { getIssueById, updateIssue, deleteIssue } = useWorkspaceDraftIssues(); + const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme(); + const { getProjectIdentifierById } = useProject(); + // ref + const issueRef = useRef(null); + // derived values + const issue = getIssueById(issueId); + const projectIdentifier = (issue && issue.project_id && getProjectIdentifierById(issue.project_id)) || undefined; + if (!issue || !projectIdentifier) return null; + + const duplicateIssuePayload = omit( + { + ...issue, + name: `${issue.name} (copy)`, + is_draft: true, + }, + ["id"] + ); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + title: "Edit", + icon: Pencil, + action: () => { + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + }, + { + key: "make-a-copy", + title: "Make a copy", + icon: Copy, + action: () => { + setCreateUpdateIssueModal(true); + }, + }, + { + key: "move-to-issues", + title: "Move to project", + icon: SquareStackIcon, + action: () => { + setMoveToIssue(true); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }, + }, + { + key: "delete", + title: "Delete", + icon: Trash2, + action: () => { + setDeleteIssueModal(true); + }, + }, + ]; + + return ( + <> + setDeleteIssueModal(false)} + onSubmit={async () => deleteIssue(workspaceSlug, issueId)} + /> + { + setCreateUpdateIssueModal(false); + setIssueToEdit(undefined); + setMoveToIssue(false); + }} + data={issueToEdit ?? duplicateIssuePayload} + onSubmit={async (data) => { + if (issueToEdit) await updateIssue(workspaceSlug, issueId, data); + }} + storeType={EIssuesStoreType.WORKSPACE_DRAFT} + fetchIssueDetails={false} + moveToIssue={moveToIssue} + isDraft + /> +
{ + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }} + > + +
+
+
+
+ {issue.project_id && ( +
+ {issue?.type_id && } + +
+ )} +
+ + {/* sub-issues chevron */} +
+
+ + +

{issue.name}

+
+
+ + {/* quick actions */} +
+ +
+
+ +
+ { + await updateIssue(workspaceSlug, issueId, data); + }} + activeLayout="List" + /> +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
+ +
+ + ); +}); diff --git a/web/core/components/issues/workspace-draft/draft-issue-properties.tsx b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx new file mode 100644 index 0000000000..7150012764 --- /dev/null +++ b/web/core/components/issues/workspace-draft/draft-issue-properties.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import xor from "lodash/xor"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// icons +import { CalendarCheck2, CalendarClock } from "lucide-react"; +// types +import { TIssue, TIssuePriorities, TWorkspaceDraftIssue } from "@plane/types"; +// components +import { + DateDropdown, + EstimateDropdown, + PriorityDropdown, + MemberDropdown, + ModuleDropdown, + CycleDropdown, + StateDropdown, +} from "@/components/dropdowns"; +// constants +import { ISSUE_UPDATED } from "@/constants/event-tracker"; +// helpers +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks +import { + useEventTracker, + useLabel, + useProjectState, + useProject, + useProjectEstimates, + useWorkspaceDraftIssues, +} from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// local components +import { IssuePropertyLabels } from "../issue-layouts"; + +export interface IIssueProperties { + issue: TWorkspaceDraftIssue; + updateIssue: + | ((projectId: string | null, issueId: string, data: Partial) => Promise) + | undefined; + className: string; + activeLayout: string; +} + +export const DraftIssueProperties: React.FC = observer((props) => { + const { issue, updateIssue, activeLayout, className } = props; + // store hooks + const { getProjectById } = useProject(); + const { labelMap } = useLabel(); + const { captureIssueEvent } = useEventTracker(); + const { addCycleToIssue, addModulesToIssue } = useWorkspaceDraftIssues(); + const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const { getStateById } = useProjectState(); + const { isMobile } = usePlatformOS(); + const projectDetails = getProjectById(issue.project_id); + + // router + const { workspaceSlug } = useParams(); + const pathname = usePathname(); + + const currentLayout = `${activeLayout} layout`; + // derived values + const stateDetails = getStateById(issue.state_id); + + const issueOperations = useMemo( + () => ({ + addModulesToIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.id) return; + await addModulesToIssue(workspaceSlug.toString(), issue.id, moduleIds); + }, + removeModulesFromIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.id) return; + await addModulesToIssue(workspaceSlug.toString(), issue.id, moduleIds); + }, + addIssueToCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.id) return; + await addCycleToIssue(workspaceSlug.toString(), issue.id, cycleId); + }, + removeIssueFromCycle: async () => { + if (!workspaceSlug || !issue.id) return; + // TODO: To be checked + await addCycleToIssue(workspaceSlug.toString(), issue.id, ""); + }, + }), + [workspaceSlug, issue, addCycleToIssue, addModulesToIssue] + ); + + const handleState = (stateId: string) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { state_id: stateId }); + + const handlePriority = (value: TIssuePriorities) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { priority: value }); + + const handleLabel = (ids: string[]) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { label_ids: ids }); + + const handleAssignee = (ids: string[]) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { assignee_ids: ids }); + + const handleModule = useCallback( + (moduleIds: string[] | null) => { + if (!issue || !issue.module_ids || !moduleIds) return; + + const updatedModuleIds = xor(issue.module_ids, moduleIds); + const modulesToAdd: string[] = []; + const modulesToRemove: string[] = []; + for (const moduleId of updatedModuleIds) + if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId); + else modulesToAdd.push(moduleId); + if (modulesToAdd.length > 0) issueOperations.addModulesToIssue(modulesToAdd); + if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove); + }, + [issueOperations, currentLayout, pathname, issue] + ); + + const handleCycle = useCallback( + (cycleId: string | null) => { + if (!issue || issue.cycle_id === cycleId) return; + if (cycleId) issueOperations.addIssueToCycle?.(cycleId); + else issueOperations.removeIssueFromCycle?.(); + }, + [issue, issueOperations, currentLayout, pathname] + ); + + const handleStartDate = (date: Date | null) => + issue?.project_id && + updateIssue && + updateIssue(issue.project_id, issue.id, { + start_date: date ? (renderFormattedPayloadDate(date) ?? undefined) : undefined, + }); + + const handleTargetDate = (date: Date | null) => + issue?.project_id && + updateIssue && + updateIssue(issue.project_id, issue.id, { + target_date: date ? (renderFormattedPayloadDate(date) ?? undefined) : undefined, + }); + + const handleEstimate = (value: string | undefined) => + issue?.project_id && updateIssue && updateIssue(issue.project_id, issue.id, { estimate_point: value }); + + if (!issue.project_id) return null; + + const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; + + const minDate = getDate(issue.start_date); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); + + const handleEventPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + + return ( +
+ {/* basic properties */} + {/* state */} +
+ +
+ + {/* priority */} +
+ +
+ + {/* label */} + +
+ +
+ + {/* start date */} +
+ } + buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} + optionsClassName="z-10" + renderByDefault={isMobile} + showTooltip + /> +
+ + {/* target/due date */} +
+ } + buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + buttonClassName={ + shouldHighlightIssueDueDate(issue?.target_date || null, stateDetails?.group) ? "text-red-500" : "" + } + clearIconClassName="!text-custom-text-100" + optionsClassName="z-10" + renderByDefault={isMobile} + showTooltip + /> +
+ + {/* assignee */} +
+ 0 ? "transparent-without-text" : "border-without-text"} + buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} + showTooltip={issue?.assignee_ids?.length === 0} + placeholder="Assignees" + optionsClassName="z-10" + tooltipContent="" + renderByDefault={isMobile} + /> +
+ + {/* modules */} + {projectDetails?.module_view && ( +
+ +
+ )} + + {/* cycles */} + {projectDetails?.cycle_view && ( +
+ +
+ )} + + {/* estimates */} + {issue.project_id && areEstimateEnabledByProjectId(issue.project_id?.toString()) && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/core/components/issues/workspace-draft/empty-state.tsx b/web/core/components/issues/workspace-draft/empty-state.tsx new file mode 100644 index 0000000000..4a1292d616 --- /dev/null +++ b/web/core/components/issues/workspace-draft/empty-state.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { FC, Fragment, useState } from "react"; +// components +import { EmptyState } from "@/components/empty-state"; +import { CreateUpdateIssueModal } from "@/components/issues"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +import { EIssuesStoreType } from "@/constants/issue"; + +export const WorkspaceDraftEmptyState: FC = () => { + // state + const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); + + return ( + + setIsDraftIssueModalOpen(false)} + isDraft + /> +
+ { + setIsDraftIssueModalOpen(true); + }} + /> +
+
+ ); +}; diff --git a/web/core/components/issues/workspace-draft/index.ts b/web/core/components/issues/workspace-draft/index.ts new file mode 100644 index 0000000000..07138bc0bc --- /dev/null +++ b/web/core/components/issues/workspace-draft/index.ts @@ -0,0 +1,4 @@ +export * from "./draft-issue-block"; +export * from "./draft-issue-properties"; +export * from "./delete-modal"; +export * from "./root"; diff --git a/web/core/components/issues/workspace-draft/loader.tsx b/web/core/components/issues/workspace-draft/loader.tsx new file mode 100644 index 0000000000..d663a0d035 --- /dev/null +++ b/web/core/components/issues/workspace-draft/loader.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FC } from "react"; +// components +import { ListLoaderItemRow } from "@/components/ui"; + +type TWorkspaceDraftIssuesLoader = { + items?: number; +}; + +export const WorkspaceDraftIssuesLoader: FC = (props) => { + const { items = 14 } = props; + return ( +
+ {[...Array(items)].map((_, index) => ( + + ))} +
+ ); +}; diff --git a/web/core/components/issues/workspace-draft/quick-action.tsx b/web/core/components/issues/workspace-draft/quick-action.tsx new file mode 100644 index 0000000000..884e81a912 --- /dev/null +++ b/web/core/components/issues/workspace-draft/quick-action.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +export interface Props { + parentRef: React.RefObject; + MENU_ITEMS: TContextMenuItem[]; +} + +export const WorkspaceDraftIssueQuickActions: React.FC = observer((props) => { + const { parentRef, MENU_ITEMS } = props; + + return ( + <> + + + {MENU_ITEMS.map((item) => ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ))} +
+ + ); +}); diff --git a/web/core/components/issues/workspace-draft/root.tsx b/web/core/components/issues/workspace-draft/root.tsx new file mode 100644 index 0000000000..177b8af556 --- /dev/null +++ b/web/core/components/issues/workspace-draft/root.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, Fragment } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { EmptyState } from "@/components/empty-state"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +import { EDraftIssuePaginationType } from "@/constants/workspace-drafts"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useCommandPalette, useProject, useWorkspaceDraftIssues } from "@/hooks/store"; +import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; +// components +import { DraftIssueBlock } from "./draft-issue-block"; +import { WorkspaceDraftEmptyState } from "./empty-state"; +import { WorkspaceDraftIssuesLoader } from "./loader"; + +type TWorkspaceDraftIssuesRoot = { + workspaceSlug: string; +}; + +export const WorkspaceDraftIssuesRoot: FC = observer((props) => { + const { workspaceSlug } = props; + // hooks + const { loader, paginationInfo, fetchIssues, issueIds } = useWorkspaceDraftIssues(); + const { workspaceProjectIds } = useProject(); + const { toggleCreateProjectModal } = useCommandPalette(); + + //swr hook for fetching issue properties + useWorkspaceIssueProperties(workspaceSlug); + + // fetching issues + const { isLoading } = useSWR( + workspaceSlug && issueIds.length <= 0 ? `WORKSPACE_DRAFT_ISSUES_${workspaceSlug}` : null, + workspaceSlug && issueIds.length <= 0 ? async () => await fetchIssues(workspaceSlug, "init-loader") : null + ); + + // handle nest issues + const handleNextIssues = async () => { + if (!paginationInfo?.next_page_results) return; + await fetchIssues(workspaceSlug, "pagination", EDraftIssuePaginationType.NEXT); + }; + + if (isLoading) { + return ; + } + + if (workspaceProjectIds?.length === 0) + return ( + { + toggleCreateProjectModal(true); + }} + /> + ); + + if (issueIds.length <= 0) return ; + + return ( +
+
+ {issueIds.map((issueId: string) => ( + + ))} +
+ + {paginationInfo?.next_page_results && ( + + {loader === "pagination" && issueIds.length >= 0 ? ( + + ) : ( +
+ Load More ↓ +
+ )} +
+ )} +
+ ); +}); diff --git a/web/core/components/modules/analytics-sidebar/progress-stats.tsx b/web/core/components/modules/analytics-sidebar/progress-stats.tsx index 712928f149..3fc7849a4a 100644 --- a/web/core/components/modules/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/modules/analytics-sidebar/progress-stats.tsx @@ -17,6 +17,7 @@ import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -28,7 +29,7 @@ import emptyMembers from "@/public/empty-state/empty_members.svg"; type TAssigneeData = { id: string | undefined; title: string | undefined; - avatar: string | undefined; + avatar_url: string | undefined; completed: number; total: number; }[]; @@ -82,7 +83,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => key={assignee?.id} title={
- + {assignee?.title ?? ""}
} @@ -277,14 +278,14 @@ export const ModuleProgressStats: FC = observer((props) => ? (currentDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_issues, total: assignee.total_issues, })) : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_estimates, total: assignee.total_estimates, })); diff --git a/web/core/components/modules/applied-filters/members.tsx b/web/core/components/modules/applied-filters/members.tsx index 69f7d00046..ccb8c90c92 100644 --- a/web/core/components/modules/applied-filters/members.tsx +++ b/web/core/components/modules/applied-filters/members.tsx @@ -2,9 +2,11 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane ui import { Avatar } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useMember } from "@/hooks/store"; type Props = { @@ -29,7 +31,12 @@ export const AppliedMembersFilters: React.FC = observer((props) => { return (
- + {memberDetails.display_name} {editable && (
); -}); \ No newline at end of file +}); diff --git a/web/core/components/modules/module-list-item.tsx b/web/core/components/modules/module-list-item.tsx index 64627b2aba..64dfe74527 100644 --- a/web/core/components/modules/module-list-item.tsx +++ b/web/core/components/modules/module-list-item.tsx @@ -13,11 +13,9 @@ import { ModuleListItemAction, ModuleQuickActions } from "@/components/modules"; // helpers import { generateQueryParams } from "@/helpers/router.helper"; // hooks -import { useModule, useProjectEstimates } from "@/hooks/store"; +import { useModule } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// plane web constants -import { EEstimateSystem } from "@/plane-web/constants/estimates"; type Props = { moduleId: string; @@ -35,27 +33,14 @@ export const ModuleListItem: React.FC = observer((props) => { // store hooks const { getModuleById } = useModule(); const { isMobile } = usePlatformOS(); - const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates(); // derived values const moduleDetails = getModuleById(moduleId); if (!moduleDetails) return null; - /** - * NOTE: This completion percentage calculation is based on the total issues count. - * when estimates are available and estimate type is points, we should consider the estimate point count - * when estimates are available and estimate type is not points, then by default we consider the issue count - */ - const isEstimateEnabled = - projectId && - currentActiveEstimateId && - areEstimateEnabledByProjectId(projectId?.toString()) && - estimateById(currentActiveEstimateId)?.type === EEstimateSystem.POINTS; - - const completionPercentage = isEstimateEnabled - ? ((moduleDetails?.completed_estimate_points || 0) / (moduleDetails?.total_estimate_points || 0)) * 100 - : ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; + const completionPercentage = + ((moduleDetails.completed_issues + moduleDetails.cancelled_issues) / moduleDetails.total_issues) * 100; const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); diff --git a/web/core/components/onboarding/profile-setup.tsx b/web/core/components/onboarding/profile-setup.tsx index 7685276b75..fee8dead9a 100644 --- a/web/core/components/onboarding/profile-setup.tsx +++ b/web/core/components/onboarding/profile-setup.tsx @@ -17,22 +17,22 @@ import { OnboardingHeader, SwitchAccountDropdown } from "@/components/onboarding // constants import { USER_DETAILS, E_ONBOARDING_STEP_1, E_ONBOARDING_STEP_2 } from "@/constants/event-tracker"; // helpers +import { getFileURL } from "@/helpers/file.helper"; import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // hooks import { useEventTracker, useUser, useUserProfile } from "@/hooks/store"; -// services // assets import ProfileSetupDark from "@/public/onboarding/profile-setup-dark.webp"; import ProfileSetupLight from "@/public/onboarding/profile-setup-light.webp"; import UserPersonalizationDark from "@/public/onboarding/user-personalization-dark.webp"; import UserPersonalizationLight from "@/public/onboarding/user-personalization-light.webp"; +// services import { AuthService } from "@/services/auth.service"; -import { FileService } from "@/services/file.service"; type TProfileSetupFormValues = { first_name: string; last_name: string; - avatar?: string | null; + avatar_url?: string | null; password?: string; confirm_password?: string; role?: string; @@ -42,7 +42,7 @@ type TProfileSetupFormValues = { const defaultValues: Partial = { first_name: "", last_name: "", - avatar: "", + avatar_url: "", password: undefined, confirm_password: undefined, role: undefined, @@ -77,7 +77,6 @@ const USER_DOMAIN = [ "Other", ]; -const fileService = new FileService(); const authService = new AuthService(); export const ProfileSetup: React.FC = observer((props) => { @@ -86,7 +85,6 @@ export const ProfileSetup: React.FC = observer((props) => { const [profileSetupStep, setProfileSetupStep] = useState( user?.is_password_autoset ? EProfileSetupSteps.USER_DETAILS : EProfileSetupSteps.ALL ); - const [isRemoving, setIsRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [showPassword, setShowPassword] = useState({ @@ -112,10 +110,12 @@ export const ProfileSetup: React.FC = observer((props) => { ...defaultValues, first_name: user?.first_name, last_name: user?.last_name, - avatar: user?.avatar, + avatar_url: user?.avatar_url, }, mode: "onChange", }); + // derived values + const userAvatar = watch("avatar_url"); const handleShowPassword = (key: keyof typeof showPassword) => setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); @@ -129,7 +129,7 @@ export const ProfileSetup: React.FC = observer((props) => { const userDetailsPayload: Partial = { first_name: formData.first_name, last_name: formData.last_name, - avatar: formData.avatar, + avatar_url: formData.avatar_url ?? undefined, }; const profileUpdatePayload: Partial = { use_case: formData.use_case, @@ -173,7 +173,7 @@ export const ProfileSetup: React.FC = observer((props) => { const userDetailsPayload: Partial = { first_name: formData.first_name, last_name: formData.last_name, - avatar: formData.avatar, + avatar_url: formData.avatar_url ?? undefined, }; try { await Promise.all([ @@ -240,12 +240,7 @@ export const ProfileSetup: React.FC = observer((props) => { const handleDelete = (url: string | null | undefined) => { if (!url) return; - - setIsRemoving(true); - fileService.deleteUserFile(url).finally(() => { - setValue("avatar", ""); - setIsRemoving(false); - }); + setValue("avatar_url", ""); }; // derived values @@ -302,13 +297,12 @@ export const ProfileSetup: React.FC = observer((props) => { <> ( setIsImageUploadModalOpen(false)} - isRemoving={isRemoving} - handleDelete={() => handleDelete(getValues("avatar"))} + handleRemove={async () => handleDelete(getValues("avatar_url"))} onSuccess={(url) => { onChange(url); setIsImageUploadModalOpen(false); @@ -319,7 +313,7 @@ export const ProfileSetup: React.FC = observer((props) => { />
+
+
+
+

Background colors

+
+ {COLORS_LIST.map((color) => ( + +
+
+ + + ); +}); + +ColorDropdown.displayName = "ColorDropdown"; diff --git a/web/core/components/pages/editor/header/index.ts b/web/core/components/pages/editor/header/index.ts index 219ed44d87..d87f5d1194 100644 --- a/web/core/components/pages/editor/header/index.ts +++ b/web/core/components/pages/editor/header/index.ts @@ -1,3 +1,4 @@ +export * from "./color-dropdown"; export * from "./extra-options"; export * from "./info-popover"; export * from "./options-dropdown"; diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index 0560002d84..c7cf53a5f5 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -1,12 +1,15 @@ "use client"; +import { useState } from "react"; import { observer } from "mobx-react"; import { useParams, useRouter } from "next/navigation"; -import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; +import { ArchiveRestoreIcon, ArrowUpToLine, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react"; // document editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; // ui import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +// components +import { ExportPageModal } from "@/components/pages"; // helpers import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper"; // hooks @@ -27,6 +30,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { const router = useRouter(); // store values const { + name, archived_at, is_locked, id, @@ -38,6 +42,8 @@ export const PageOptionsDropdown: React.FC = observer((props) => { canCurrentUserLockPage, restore, } = page; + // states + const [isExportModalOpen, setIsExportModalOpen] = useState(false); // store hooks const { workspaceSlug, projectId } = useParams(); // page filters @@ -157,26 +163,41 @@ export const PageOptionsDropdown: React.FC = observer((props) => { icon: History, shouldRender: true, }, + { + key: "export", + action: () => setIsExportModalOpen(true), + label: "Export", + icon: ArrowUpToLine, + shouldRender: true, + }, ]; return ( - - handleFullWidth(!isFullWidth)} - > - Full width - {}} /> - - {MENU_ITEMS.map((item) => { - if (!item.shouldRender) return null; - return ( - - - {item.label} - - ); - })} - + <> + setIsExportModalOpen(false)} + pageTitle={name ?? ""} + /> + + handleFullWidth(!isFullWidth)} + > + Full width + {}} /> + + {MENU_ITEMS.map((item) => { + if (!item.shouldRender) return null; + return ( + + + {item.label} + + ); + })} + + ); }); diff --git a/web/core/components/pages/editor/header/toolbar.tsx b/web/core/components/pages/editor/header/toolbar.tsx index 65d484ef15..447616b532 100644 --- a/web/core/components/pages/editor/header/toolbar.tsx +++ b/web/core/components/pages/editor/header/toolbar.tsx @@ -3,9 +3,11 @@ import React, { useEffect, useState, useCallback } from "react"; import { Check, ChevronDown } from "lucide-react"; // editor -import { EditorRefApi, TEditorCommands } from "@plane/editor"; +import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor"; // ui import { CustomMenu, Tooltip } from "@plane/ui"; +// components +import { ColorDropdown } from "@/components/pages"; // constants import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor"; // helpers @@ -18,7 +20,7 @@ type Props = { type ToolbarButtonProps = { item: ToolbarMenuItem; isActive: boolean; - executeCommand: (commandKey: TEditorCommands) => void; + executeCommand: EditorRefApi["executeMenuItemCommand"]; }; const ToolbarButton: React.FC = React.memo((props) => { @@ -36,7 +38,11 @@ const ToolbarButton: React.FC = React.memo((props) => {