diff --git a/.github/workflows/build-aio-base.yml b/.github/workflows/build-aio-base.yml index 3d42f2ecd..301fd8766 100644 --- a/.github/workflows/build-aio-base.yml +++ b/.github/workflows/build-aio-base.yml @@ -2,6 +2,11 @@ name: Build AIO Base Image on: workflow_dispatch: + inputs: + base_tag_name: + description: 'Base Tag Name' + required: false + default: '' env: TARGET_BRANCH: ${{ github.ref_name }} @@ -16,37 +21,46 @@ jobs: gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} - build_base: ${{ steps.changed_files.outputs.base_any_changed }} + image_tag: ${{ steps.set_env_variables.outputs.IMAGE_TAG }} steps: - id: set_env_variables name: Set Environment Variables run: | - echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT - echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT - echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT - echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then + echo "IMAGE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + echo "IMAGE_TAG=latest" >> $GITHUB_OUTPUT + elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then + echo "IMAGE_TAG=preview" >> $GITHUB_OUTPUT + else + echo "IMAGE_TAG=develop" >> $GITHUB_OUTPUT + fi + + + if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + else + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + fi + - id: checkout_files name: Checkout Files uses: actions/checkout@v4 - - name: Get changed files - id: changed_files - uses: tj-actions/changed-files@v42 - with: - files_yaml: | - base: - - aio/Dockerfile.base - - base_build_push: - if: ${{ needs.base_build_setup.outputs.build_base == 'true' || github.event_name == 'workflow_dispatch' || needs.base_build_setup.outputs.gh_branch_name == 'master' }} + full_base_build_push: runs-on: ubuntu-latest needs: [base_build_setup] env: - BASE_IMG_TAG: makeplane/plane-aio-base:${{ needs.base_build_setup.outputs.gh_branch_name }} - TARGET_BRANCH: ${{ needs.base_build_setup.outputs.gh_branch_name }} + BASE_IMG_TAG: makeplane/plane-aio-base:full-${{ needs.base_build_setup.outputs.image_tag }} BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }} BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }} @@ -55,15 +69,6 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 - - name: Set Docker Tag - run: | - if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=makeplane/plane-aio-base:latest - else - TAG=${{ env.BASE_IMG_TAG }} - fi - echo "BASE_IMG_TAG=${TAG}" >> $GITHUB_ENV - - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -81,10 +86,53 @@ jobs: uses: docker/build-push-action@v5.1.0 with: context: ./aio - file: ./aio/Dockerfile.base + file: ./aio/Dockerfile-base-full platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.BASE_IMG_TAG }} push: true + cache-from: type=gha + cache-to: type=gha,mode=max + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + slim_base_build_push: + runs-on: ubuntu-latest + needs: [base_build_setup] + env: + BASE_IMG_TAG: makeplane/plane-aio-base:slim-${{ needs.base_build_setup.outputs.image_tag }} + BUILDX_DRIVER: ${{ needs.base_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.base_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.base_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.base_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: ./aio + file: ./aio/Dockerfile-base-slim + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.BASE_IMG_TAG }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=max env: DOCKER_BUILDKIT: 1 DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/build-aio-branch.yml b/.github/workflows/build-aio-branch.yml new file mode 100644 index 000000000..de68d4b96 --- /dev/null +++ b/.github/workflows/build-aio-branch.yml @@ -0,0 +1,203 @@ +name: Branch Build AIO + +on: + workflow_dispatch: + inputs: + full: + description: 'Run full build' + type: boolean + required: false + default: false + slim: + description: 'Run slim build' + type: boolean + required: false + default: false + base_tag_name: + description: 'Base Tag Name' + required: false + default: '' + release: + types: [released, prereleased] + +env: + TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} + FULL_BUILD_INPUT: ${{ github.event.inputs.full }} + SLIM_BUILD_INPUT: ${{ github.event.inputs.slim }} + +jobs: + branch_build_setup: + name: Build Setup + runs-on: ubuntu-latest + outputs: + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }} + do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }} + do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + + echo "AIO_BASE_TAG=latest" >> $GITHUB_OUTPUT + else + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + + if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then + echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT + elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then + echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT + else + echo "AIO_BASE_TAG=develop" >> $GITHUB_OUTPUT + fi + fi + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + + if [ "${{ env.FULL_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then + echo "DO_FULL_BUILD=true" >> $GITHUB_OUTPUT + else + echo "DO_FULL_BUILD=false" >> $GITHUB_OUTPUT + fi + + if [ "${{ env.SLIM_BUILD_INPUT }}" == "true" ] || [ "${{github.event_name}}" == "push" ] || [ "${{github.event_name}}" == "release" ]; then + echo "DO_SLIM_BUILD=true" >> $GITHUB_OUTPUT + else + echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT + fi + + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + full_build_push: + if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }} + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + BUILD_TYPE: full + AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }} + AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest + else + TAG=${{ env.AIO_IMAGE_TAGS }} + fi + echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./aio/Dockerfile-app + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.AIO_IMAGE_TAGS }} + push: true + build-args: | + BUILD_TAG=${{ env.AIO_BASE_TAG }} + BUILD_TYPE=${{env.BUILD_TYPE}} + cache-from: type=gha + cache-to: type=gha,mode=max + + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + + slim_build_push: + if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }} + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + BUILD_TYPE: slim + AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }} + AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-stable,makeplane/plane-aio:${{env.BUILD_TYPE}}-${{ github.event.release.tag_name }} + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=makeplane/plane-aio:${{env.BUILD_TYPE}}-latest + else + TAG=${{ env.AIO_IMAGE_TAGS }} + fi + echo "AIO_IMAGE_TAGS=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./aio/Dockerfile-app + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.AIO_IMAGE_TAGS }} + push: true + build-args: | + BUILD_TAG=${{ env.AIO_BASE_TAG }} + BUILD_TYPE=${{env.BUILD_TYPE}} + cache-from: type=gha + cache-to: type=gha,mode=max + + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 5b94b215a..2e6f9c642 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -3,10 +3,11 @@ name: Build and Lint on Pull Request on: workflow_dispatch: pull_request: - types: ["opened", "synchronize"] + types: ["opened", "synchronize", "ready_for_review"] jobs: get-changed-files: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest outputs: apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }} diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index c195f8423..24c4fb995 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -29,7 +29,7 @@ jobs: else echo "MATCH=false" >> $GITHUB_OUTPUT fi - Auto_Merge: + Create_PR: if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} needs: [Check_Branch] runs-on: ubuntu-latest diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index e848dc36d..0c71564e1 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -3,189 +3,108 @@ name: Feature Preview on: workflow_dispatch: inputs: - web-build: + base_tag_name: + description: 'Base Tag Name' required: false - description: "Build Web" - type: boolean - default: true - space-build: - required: false - description: "Build Space" - type: boolean - default: false - admin-build: - required: false - description: "Build Admin" - type: boolean - default: false + default: 'preview' env: - BUILD_WEB: ${{ github.event.inputs.web-build }} - BUILD_SPACE: ${{ github.event.inputs.space-build }} - BUILD_ADMIN: ${{ github.event.inputs.admin-build }} + TARGET_BRANCH: ${{ github.ref_name }} jobs: - setup-feature-build: - name: Feature Build Setup + branch_build_setup: + name: Build Setup runs-on: ubuntu-latest - steps: - - name: Checkout - run: | - echo "BUILD_WEB=$BUILD_WEB" - echo "BUILD_SPACE=$BUILD_SPACE" - echo "BUILD_ADMIN=$BUILD_ADMIN" outputs: - web-build: ${{ env.BUILD_WEB}} - space-build: ${{env.BUILD_SPACE}} - admin-build: ${{env.BUILD_ADMIN}} + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} + gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} + gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + aio_base_tag: ${{ steps.set_env_variables.outputs.AIO_BASE_TAG }} + do_full_build: ${{ steps.set_env_variables.outputs.DO_FULL_BUILD }} + do_slim_build: ${{ steps.set_env_variables.outputs.DO_SLIM_BUILD }} - feature-build-web: - if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }} - needs: setup-feature-build - name: Feature Build Web - runs-on: ubuntu-latest - env: - AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} - AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} - NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} steps: - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "18" - - name: Install AWS cli + - id: set_env_variables + name: Set Environment Variables run: | - sudo apt-get update - sudo apt-get install -y python3-pip - pip3 install awscli - - name: Checkout + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + + if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then + echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT + else + echo "AIO_BASE_TAG=develop" >> $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 + + - id: checkout_files + name: Checkout Files uses: actions/checkout@v4 - with: - path: plane - - name: Install Dependencies - run: | - cd $GITHUB_WORKSPACE/plane - yarn install - - name: Build Web - id: build-web - run: | - cd $GITHUB_WORKSPACE/plane - yarn build --filter=web - cd $GITHUB_WORKSPACE - TAR_NAME="web.tar.gz" - tar -czf $TAR_NAME ./plane - - FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") - aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY - - feature-build-space: - if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }} - needs: setup-feature-build - name: Feature Build Space - runs-on: ubuntu-latest + full_build_push: + runs-on: ubuntu-20.04 + needs: [branch_build_setup] env: - AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} - AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} - NEXT_PUBLIC_SPACE_BASE_PATH: "/spaces" - NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + BUILD_TYPE: full + AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }} + AIO_IMAGE_TAGS: makeplane/plane-aio-feature:${{ needs.branch_build_setup.outputs.flat_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: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Build and Push to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./aio/Dockerfile-app + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.AIO_IMAGE_TAGS }} + push: true + build-args: + BUILD_TAG=${{ env.AIO_BASE_TAG }} + BUILD_TYPE=${{env.BUILD_TYPE}} + # cache-from: type=gha + # cache-to: type=gha,mode=max + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} outputs: - do-build: ${{ needs.setup-feature-build.outputs.space-build }} - s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }} - steps: - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "18" - - name: Install AWS cli - run: | - sudo apt-get update - sudo apt-get install -y python3-pip - pip3 install awscli - - name: Checkout - uses: actions/checkout@v4 - with: - path: plane - - name: Install Dependencies - run: | - cd $GITHUB_WORKSPACE/plane - yarn install - - name: Build Space - id: build-space - run: | - cd $GITHUB_WORKSPACE/plane - yarn build --filter=space - cd $GITHUB_WORKSPACE - - TAR_NAME="space.tar.gz" - tar -czf $TAR_NAME ./plane - - FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") - aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY - - feature-build-admin: - if: ${{ needs.setup-feature-build.outputs.admin-build == 'true' }} - needs: setup-feature-build - name: Feature Build Admin - runs-on: ubuntu-latest - env: - AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} - AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} - NEXT_PUBLIC_ADMIN_BASE_PATH: "/god-mode" - NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} - outputs: - do-build: ${{ needs.setup-feature-build.outputs.admin-build }} - s3-url: ${{ steps.build-admin.outputs.S3_PRESIGNED_URL }} - steps: - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "18" - - name: Install AWS cli - run: | - sudo apt-get update - sudo apt-get install -y python3-pip - pip3 install awscli - - name: Checkout - uses: actions/checkout@v4 - with: - path: plane - - name: Install Dependencies - run: | - cd $GITHUB_WORKSPACE/plane - yarn install - - name: Build Admin - id: build-admin - run: | - cd $GITHUB_WORKSPACE/plane - yarn build --filter=admin - cd $GITHUB_WORKSPACE - - TAR_NAME="admin.tar.gz" - tar -czf $TAR_NAME ./plane - - FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") - aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + AIO_IMAGE_TAGS: ${{ env.AIO_IMAGE_TAGS }} feature-deploy: - if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true' || needs.setup-feature-build.outputs.admin-build == 'true') }} - needs: - [ - setup-feature-build, - feature-build-web, - feature-build-space, - feature-build-admin, - ] + needs: [branch_build_setup, full_build_push] name: Feature Deploy runs-on: ubuntu-latest env: - AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} - AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }} + DEPLOYMENT_NAME: ${{ needs.branch_build_setup.outputs.flat_branch_name }} steps: - name: Install AWS cli run: | @@ -213,54 +132,37 @@ jobs: ./get_helm.sh - name: App Deploy run: | - WEB_S3_URL="" - if [ ${{ env.BUILD_WEB }} == true ]; then - WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600) - fi + helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} - SPACE_S3_URL="" - if [ ${{ env.BUILD_SPACE }} == true ]; then - SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600) - fi + APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}" - ADMIN_S3_URL="" - if [ ${{ env.BUILD_ADMIN }} == true ]; then - ADMIN_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/admin.tar.gz --expires-in 3600) - fi + helm --kube-insecure-skip-tls-verify uninstall \ + ${{ env.DEPLOYMENT_NAME }} \ + --namespace $APP_NAMESPACE \ + --timeout 10m0s \ + --wait \ + --ignore-not-found - if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ] || [ ${{ env.BUILD_ADMIN }} == true ]; then + METADATA=$(helm --kube-insecure-skip-tls-verify upgrade \ + --install=true \ + --namespace $APP_NAMESPACE \ + --set dockerhub.loginid=${{ secrets.DOCKERHUB_USERNAME }} \ + --set dockerhub.password=${{ secrets.DOCKERHUB_TOKEN_RO}} \ + --set config.feature_branch=${{ env.DEPLOYMENT_NAME }} \ + --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \ + --set ingress.tls_secret=${{vars.FEATURE_PREVIEW_INGRESS_TLS_SECRET || '' }} \ + --output json \ + --timeout 10m0s \ + --wait \ + ${{ env.DEPLOYMENT_NAME }} feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} ) - helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} + APP_NAME=$(echo $METADATA | jq -r '.name') - APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}" - DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}" + INGRESS_HOSTNAME=$(kubectl get ingress -n $APP_NAMESPACE --insecure-skip-tls-verify \ + -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ + jq -r '.spec.rules[0].host') - METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \ - --generate-name \ - --namespace $APP_NAMESPACE \ - --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \ - --set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ - --set web.enabled=${{ env.BUILD_WEB || false }} \ - --set web.artifact_url=$WEB_S3_URL \ - --set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ - --set space.enabled=${{ env.BUILD_SPACE || false }} \ - --set space.artifact_url=$SPACE_S3_URL \ - --set admin.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ - --set admin.enabled=${{ env.BUILD_ADMIN || false }} \ - --set admin.artifact_url=$ADMIN_S3_URL \ - --set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \ - --set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \ - --output json \ - --timeout 1000s) - - APP_NAME=$(echo $METADATA | jq -r '.name') - - INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \ - -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ - jq -r '.spec.rules[0].host') - - echo "****************************************" - echo "APP NAME ::: $APP_NAME" - echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" - echo "****************************************" - fi + echo "****************************************" + echo "APP NAME ::: $APP_NAME" + echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" + echo "****************************************" diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index cec5c0748..510566e80 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import { useForm } from "react-hook-form"; import { Lightbulb } from "lucide-react"; diff --git a/admin/app/ai/layout.tsx b/admin/app/ai/layout.tsx index 0a0bacac1..d461a626a 100644 --- a/admin/app/ai/layout.tsx +++ b/admin/app/ai/layout.tsx @@ -3,7 +3,7 @@ import { Metadata } from "next"; import { AdminLayout } from "@/layouts/admin-layout"; export const metadata: Metadata = { - title: "AI Settings - God Mode", + title: "Artificial Intelligence Settings - Plane Web", }; export default function AILayout({ children }: { children: ReactNode }) { diff --git a/admin/app/ai/page.tsx b/admin/app/ai/page.tsx index a54ce6d8c..2a0747776 100644 --- a/admin/app/ai/page.tsx +++ b/admin/app/ai/page.tsx @@ -1,10 +1,8 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; -// components -import { PageHeader } from "@/components/core"; // hooks import { useInstance } from "@/hooks/store"; // components @@ -18,7 +16,6 @@ const InstanceAIPage = observer(() => { return ( <> -
AI features for all your workspaces
diff --git a/admin/app/authentication/github/form.tsx b/admin/app/authentication/github/form.tsx index 75c76e7a5..afab9a3c5 100644 --- a/admin/app/authentication/github/form.tsx +++ b/admin/app/authentication/github/form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { FC, useState } from "react"; import isEmpty from "lodash/isEmpty"; import Link from "next/link"; @@ -8,6 +10,7 @@ import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigura import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components import { + CodeBlock, ConfirmDiscardModal, ControllerInput, CopyField, @@ -100,7 +103,8 @@ export const InstanceGithubConfigForm: FC = (props) => { url: originURL, description: ( <> - We will auto-generate this. Paste this into the Authorized origin URL field{" "} + We will auto-generate this. Paste this into the{" "} + Authorized origin URL field{" "} = (props) => { url: `${originURL}/auth/github/callback/`, description: ( <> - We will auto-generate this. Paste this into your Authorized Callback URI field{" "} + We will auto-generate this. Paste this into your{" "} + Authorized Callback URI field{" "} = (props) => { .then((response = []) => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success", - message: "Github Configuration Settings updated successfully", + title: "Done!", + message: "Your GitHub authentication is configured. You should test it now.", }); reset({ GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value, @@ -168,8 +173,8 @@ export const InstanceGithubConfigForm: FC = (props) => { />
-
-
Configuration
+
+
GitHub-provided details for Plane
{GITHUB_FORM_FIELDS.map((field) => ( = (props) => {
-
-
Service provider details
+
+
Plane-provided details for GitHub
{GITHUB_SERVICE_FIELD.map((field) => ( ))} diff --git a/admin/app/authentication/github/page.tsx b/admin/app/authentication/github/page.tsx index 8532910f7..7991fb95f 100644 --- a/admin/app/authentication/github/page.tsx +++ b/admin/app/authentication/github/page.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Image from "next/image"; import { useTheme } from "next-themes"; import useSWR from "swr"; import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components -import { PageHeader } from "@/components/core"; +import { AuthenticationMethodCard } from "@/components/authentication"; +import { PageHeader } from "@/components/common"; // helpers import { resolveGeneralTheme } from "@/helpers/common.helper"; // hooks @@ -16,7 +17,6 @@ import { useInstance } from "@/hooks/store"; import githubLightModeImage from "@/public/logos/github-black.png"; import githubDarkModeImage from "@/public/logos/github-white.png"; // local components -import { AuthenticationMethodCard } from "../components"; import { InstanceGithubConfigForm } from "./form"; const InstanceGithubAuthenticationPage = observer(() => { @@ -63,7 +63,7 @@ const InstanceGithubAuthenticationPage = observer(() => { }; return ( <> - +
{ withBorder={false} />
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/gitlab/form.tsx b/admin/app/authentication/gitlab/form.tsx new file mode 100644 index 000000000..2d5782e10 --- /dev/null +++ b/admin/app/authentication/gitlab/form.tsx @@ -0,0 +1,214 @@ +import { FC, useState } from "react"; +import isEmpty from "lodash/isEmpty"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// types +import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; +// ui +import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +// components +import { + CodeBlock, + ConfirmDiscardModal, + ControllerInput, + CopyField, + TControllerInputFormField, + TCopyField, +} from "@/components/common"; +// helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + config: IFormattedInstanceConfiguration; +}; + +type GitlabConfigFormValues = Record; + +export const InstanceGitlabConfigForm: FC = (props) => { + const { config } = props; + // states + const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false); + // store hooks + const { updateInstanceConfigurations } = useInstance(); + // form data + const { + handleSubmit, + control, + reset, + formState: { errors, isDirty, isSubmitting }, + } = useForm({ + defaultValues: { + GITLAB_HOST: config["GITLAB_HOST"], + GITLAB_CLIENT_ID: config["GITLAB_CLIENT_ID"], + GITLAB_CLIENT_SECRET: config["GITLAB_CLIENT_SECRET"], + }, + }); + + const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : ""; + + const GITLAB_FORM_FIELDS: TControllerInputFormField[] = [ + { + key: "GITLAB_HOST", + type: "text", + label: "Host", + description: ( + <> + This is either https://gitlab.com or the domain.tld where you host GitLab. + + ), + placeholder: "https://gitlab.com", + error: Boolean(errors.GITLAB_HOST), + required: true, + }, + { + key: "GITLAB_CLIENT_ID", + type: "text", + label: "Application ID", + description: ( + <> + Get this from your{" "} + + GitLab OAuth application settings + + . + + ), + placeholder: "c2ef2e7fc4e9d15aa7630f5637d59e8e4a27ff01dceebdb26b0d267b9adcf3c3", + error: Boolean(errors.GITLAB_CLIENT_ID), + required: true, + }, + { + key: "GITLAB_CLIENT_SECRET", + type: "password", + label: "Secret", + description: ( + <> + The client secret is also found in your{" "} + + GitLab OAuth application settings + + . + + ), + placeholder: "gloas-f79cfa9a03c97f6ffab303177a5a6778a53c61e3914ba093412f68a9298a1b28", + error: Boolean(errors.GITLAB_CLIENT_SECRET), + required: true, + }, + ]; + + const GITLAB_SERVICE_FIELD: TCopyField[] = [ + { + key: "Callback_URL", + label: "Callback URL", + url: `${originURL}/auth/gitlab/callback/`, + description: ( + <> + We will auto-generate this. Paste this into the{" "} + Redirect URI field of your{" "} + + GitLab OAuth application + + . + + ), + }, + ]; + + const onSubmit = async (formData: GitlabConfigFormValues) => { + const payload: Partial = { ...formData }; + + await updateInstanceConfigurations(payload) + .then((response = []) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: "Your GitLab authentication is configured. You should test it now.", + }); + reset({ + GITLAB_HOST: response.find((item) => item.key === "GITLAB_HOST")?.value, + GITLAB_CLIENT_ID: response.find((item) => item.key === "GITLAB_CLIENT_ID")?.value, + GITLAB_CLIENT_SECRET: response.find((item) => item.key === "GITLAB_CLIENT_SECRET")?.value, + }); + }) + .catch((err) => console.error(err)); + }; + + const handleGoBack = (e: React.MouseEvent) => { + if (isDirty) { + e.preventDefault(); + setIsDiscardChangesModalOpen(true); + } + }; + + return ( + <> + setIsDiscardChangesModalOpen(false)} + /> +
+
+
+
GitLab-provided details for Plane
+ {GITLAB_FORM_FIELDS.map((field) => ( + + ))} +
+
+ + + Go back + +
+
+
+
+
+
Plane-provided details for GitLab
+ {GITLAB_SERVICE_FIELD.map((field) => ( + + ))} +
+
+
+
+ + ); +}; diff --git a/admin/app/authentication/gitlab/page.tsx b/admin/app/authentication/gitlab/page.tsx new file mode 100644 index 000000000..7a4d8248e --- /dev/null +++ b/admin/app/authentication/gitlab/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +// components +import { AuthenticationMethodCard } from "@/components/authentication"; +import { PageHeader } from "@/components/common"; +// hooks +import { useInstance } from "@/hooks/store"; +// icons +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +// local components +import { InstanceGitlabConfigForm } from "./form"; + +const InstanceGitlabAuthenticationPage = observer(() => { + // store + const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance(); + // state + const [isSubmitting, setIsSubmitting] = useState(false); + // config + const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? ""; + + useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations()); + + const updateConfig = async (key: "IS_GITLAB_ENABLED", value: string) => { + setIsSubmitting(true); + + const payload = { + [key]: value, + }; + + const updateConfigPromise = updateInstanceConfigurations(payload); + + setPromiseToast(updateConfigPromise, { + loading: "Saving Configuration...", + success: { + title: "Configuration saved", + message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`, + }, + error: { + title: "Error", + message: () => "Failed to save configuration", + }, + }); + + await updateConfigPromise + .then(() => { + setIsSubmitting(false); + }) + .catch((err) => { + console.error(err); + setIsSubmitting(false); + }); + }; + return ( + <> + +
+
+ } + config={ + { + Boolean(parseInt(enableGitlabConfig)) === true + ? updateConfig("IS_GITLAB_ENABLED", "0") + : updateConfig("IS_GITLAB_ENABLED", "1"); + }} + size="sm" + disabled={isSubmitting || !formattedConfig} + /> + } + disabled={isSubmitting || !formattedConfig} + withBorder={false} + /> +
+
+ {formattedConfig ? ( + + ) : ( + + + + + + + + )} +
+
+ + ); +}); + +export default InstanceGitlabAuthenticationPage; diff --git a/admin/app/authentication/google/form.tsx b/admin/app/authentication/google/form.tsx index fd2e7c73c..cf5797895 100644 --- a/admin/app/authentication/google/form.tsx +++ b/admin/app/authentication/google/form.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC, useState } from "react"; import isEmpty from "lodash/isEmpty"; import Link from "next/link"; @@ -8,6 +9,7 @@ import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigura import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; // components import { + CodeBlock, ConfirmDiscardModal, ControllerInput, CopyField, @@ -100,7 +102,8 @@ export const InstanceGoogleConfigForm: FC = (props) => { url: originURL, description: (

- We will auto-generate this. Paste this into your Authorized JavaScript origins field. For this OAuth client{" "} + We will auto-generate this. Paste this into your{" "} + Authorized JavaScript origins field. For this OAuth client{" "} = (props) => { url: `${originURL}/auth/google/callback/`, description: (

- We will auto-generate this. Paste this into your Authorized Redirect URI field. For this OAuth client{" "} + We will auto-generate this. Paste this into your Authorized Redirect URI{" "} + field. For this OAuth client{" "} = (props) => { .then((response = []) => { setToast({ type: TOAST_TYPE.SUCCESS, - title: "Success", - message: "Google Configuration Settings updated successfully", + title: "Done!", + message: "Your Google authentication is configured. You should test it now.", }); reset({ GOOGLE_CLIENT_ID: response.find((item) => item.key === "GOOGLE_CLIENT_ID")?.value, @@ -166,8 +170,8 @@ export const InstanceGoogleConfigForm: FC = (props) => { />

-
-
Configuration
+
+
Google-provided details for Plane
{GOOGLE_FORM_FIELDS.map((field) => ( = (props) => {
-
-
Service provider details
+
+
Plane-provided details for Google
{GOOGLE_SERVICE_DETAILS.map((field) => ( ))} diff --git a/admin/app/authentication/google/page.tsx b/admin/app/authentication/google/page.tsx index fcdcd47ad..992c7a8a7 100644 --- a/admin/app/authentication/google/page.tsx +++ b/admin/app/authentication/google/page.tsx @@ -1,18 +1,18 @@ "use client"; import { useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Image from "next/image"; import useSWR from "swr"; import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components -import { PageHeader } from "@/components/core"; +import { AuthenticationMethodCard } from "@/components/authentication"; +import { PageHeader } from "@/components/common"; // hooks import { useInstance } from "@/hooks/store"; // icons import GoogleLogo from "@/public/logos/google-logo.svg"; // local components -import { AuthenticationMethodCard } from "../components"; import { InstanceGoogleConfigForm } from "./form"; const InstanceGoogleAuthenticationPage = observer(() => { @@ -57,7 +57,7 @@ const InstanceGoogleAuthenticationPage = observer(() => { }; return ( <> - +
{ withBorder={false} />
-
+
{formattedConfig ? ( ) : ( diff --git a/admin/app/authentication/layout.tsx b/admin/app/authentication/layout.tsx index 64506ddb4..c5b70030d 100644 --- a/admin/app/authentication/layout.tsx +++ b/admin/app/authentication/layout.tsx @@ -3,7 +3,7 @@ import { Metadata } from "next"; import { AdminLayout } from "@/layouts/admin-layout"; export const metadata: Metadata = { - title: "Authentication Settings - God Mode", + title: "Authentication Settings - Plane Web", }; export default function AuthenticationLayout({ children }: { children: ReactNode }) { diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index d1e6fb0ba..d37b35978 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -1,39 +1,16 @@ "use client"; import { useState } from "react"; -import { observer } from "mobx-react-lite"; -import Image from "next/image"; -import { useTheme } from "next-themes"; +import { observer } from "mobx-react"; import useSWR from "swr"; -import { Mails, KeyRound } from "lucide-react"; import { TInstanceConfigurationKeys } from "@plane/types"; -import { Loader, setPromiseToast } from "@plane/ui"; -// components -import { PageHeader } from "@/components/core"; -// hooks +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // helpers -import { resolveGeneralTheme } from "@/helpers/common.helper"; +import { cn } from "@/helpers/common.helper"; +// hooks import { useInstance } from "@/hooks/store"; -// images -import githubLightModeImage from "@/public/logos/github-black.png"; -import githubDarkModeImage from "@/public/logos/github-white.png"; -import GoogleLogo from "@/public/logos/google-logo.svg"; -// local components -import { - AuthenticationMethodCard, - EmailCodesConfiguration, - PasswordLoginConfiguration, - GithubConfiguration, - GoogleConfiguration, -} from "./components"; - -type TInstanceAuthenticationMethodCard = { - key: string; - name: string; - description: string; - icon: JSX.Element; - config: JSX.Element; -}; +// plane admin components +import { AuthenticationModes } from "@/plane-admin/components/authentication"; const InstanceAuthenticationPage = observer(() => { // store @@ -43,8 +20,8 @@ const InstanceAuthenticationPage = observer(() => { // state const [isSubmitting, setIsSubmitting] = useState(false); - // theme - const { resolvedTheme } = useTheme(); + // derived values + const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { setIsSubmitting(true); @@ -56,7 +33,7 @@ const InstanceAuthenticationPage = observer(() => { const updateConfigPromise = updateInstanceConfigurations(payload); setPromiseToast(updateConfigPromise, { - loading: "Saving Configuration...", + loading: "Saving configuration", success: { title: "Success", message: () => "Configuration saved successfully", @@ -77,51 +54,11 @@ const InstanceAuthenticationPage = observer(() => { }); }; - // Authentication methods - const authenticationMethodsCard: TInstanceAuthenticationMethodCard[] = [ - { - key: "email-codes", - name: "Email codes", - description: "Login or sign up using codes sent via emails. You need to have email setup here and enabled.", - icon: , - config: , - }, - { - key: "password-login", - name: "Password based login", - description: "Allow members to create accounts with passwords for emails to sign in.", - icon: , - config: , - }, - { - key: "google", - name: "Google", - description: "Allow members to login or sign up to plane with their Google accounts.", - icon: Google Logo, - config: , - }, - { - key: "github", - name: "Github", - description: "Allow members to login or sign up to plane with their Github accounts.", - icon: ( - GitHub Logo - ), - config: , - }, - ]; - return ( <> -
-
Manage authentication for your instance
+
Manage authentication modes for your instance
Configure authentication modes for your team and restrict sign ups to be invite only.
@@ -129,17 +66,32 @@ const InstanceAuthenticationPage = observer(() => {
{formattedConfig ? (
-
Authentication modes
- {authenticationMethodsCard.map((method) => ( - - ))} +
+
+
+
Allow anyone to sign up even without an invite
+
+ Toggling this off will only let users sign up when they are invited. +
+
+
+
+
+ { + Boolean(parseInt(enableSignUpConfig)) === true + ? updateConfig("ENABLE_SIGNUP", "0") + : updateConfig("ENABLE_SIGNUP", "1"); + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Authentication modes
+
) : ( diff --git a/admin/app/email/email-config-form.tsx b/admin/app/email/email-config-form.tsx index 8a18b481d..2cc24fc89 100644 --- a/admin/app/email/email-config-form.tsx +++ b/admin/app/email/email-config-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { FC, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; // types diff --git a/admin/app/email/layout.tsx b/admin/app/email/layout.tsx index 64f019ec9..2084af1ea 100644 --- a/admin/app/email/layout.tsx +++ b/admin/app/email/layout.tsx @@ -7,9 +7,9 @@ interface EmailLayoutProps { } export const metadata: Metadata = { - title: "Email Settings - God Mode", + title: "Email Settings - Plane Web", }; -const EmailLayout = ({ children }: EmailLayoutProps) => {children}; - -export default EmailLayout; +export default function EmailLayout({ children }: EmailLayoutProps) { + return {children}; +} diff --git a/admin/app/email/page.tsx b/admin/app/email/page.tsx index 198020d4d..33fedc052 100644 --- a/admin/app/email/page.tsx +++ b/admin/app/email/page.tsx @@ -1,10 +1,8 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; -// components -import { PageHeader } from "@/components/core"; // hooks import { useInstance } from "@/hooks/store"; // components @@ -18,7 +16,6 @@ const InstanceEmailPage = observer(() => { return ( <> -
Secure emails from your own instance
diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx index 5646084e2..310153784 100644 --- a/admin/app/general/form.tsx +++ b/admin/app/general/form.tsx @@ -1,6 +1,6 @@ "use client"; import { FC } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Telescope } from "lucide-react"; // types diff --git a/admin/app/general/layout.tsx b/admin/app/general/layout.tsx index fabbe3640..374257daa 100644 --- a/admin/app/general/layout.tsx +++ b/admin/app/general/layout.tsx @@ -3,7 +3,7 @@ import { Metadata } from "next"; import { AdminLayout } from "@/layouts/admin-layout"; export const metadata: Metadata = { - title: "General Settings - God Mode", + title: "General Settings - Plane Web", }; export default function GeneralLayout({ children }: { children: ReactNode }) { diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index 5aaea9f8e..ba048f9f7 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // hooks import { useInstance } from "@/hooks/store"; // components diff --git a/admin/app/image/form.tsx b/admin/app/image/form.tsx index a6fe2945b..61d2875ed 100644 --- a/admin/app/image/form.tsx +++ b/admin/app/image/form.tsx @@ -1,3 +1,4 @@ +"use client"; import { FC } from "react"; import { useForm } from "react-hook-form"; import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; diff --git a/admin/app/image/layout.tsx b/admin/app/image/layout.tsx index 18e9343b5..32233e078 100644 --- a/admin/app/image/layout.tsx +++ b/admin/app/image/layout.tsx @@ -7,9 +7,9 @@ interface ImageLayoutProps { } export const metadata: Metadata = { - title: "Images Settings - God Mode", + title: "Images Settings - Plane Web", }; -const ImageLayout = ({ children }: ImageLayoutProps) => {children}; - -export default ImageLayout; +export default function ImageLayout({ children }: ImageLayoutProps) { + return {children}; +} diff --git a/admin/app/image/page.tsx b/admin/app/image/page.tsx index ceaad61f2..ade9687d5 100644 --- a/admin/app/image/page.tsx +++ b/admin/app/image/page.tsx @@ -1,10 +1,8 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import useSWR from "swr"; import { Loader } from "@plane/ui"; -// components -import { PageHeader } from "@/components/core"; // hooks import { useInstance } from "@/hooks/store"; // local @@ -18,7 +16,6 @@ const InstanceImagePage = observer(() => { return ( <> -
Third-party image libraries
diff --git a/admin/app/layout.tsx b/admin/app/layout.tsx index e79d0bac8..19aba48e9 100644 --- a/admin/app/layout.tsx +++ b/admin/app/layout.tsx @@ -16,10 +16,12 @@ import { UserProvider } from "@/lib/user-provider"; // styles import "./globals.css"; -function RootLayout({ children }: { children: ReactNode }) { - // themes +const ToastWithTheme = () => { const { resolvedTheme } = useTheme(); + return ; +}; +export default function RootLayout({ children }: { children: ReactNode }) { return ( @@ -31,7 +33,7 @@ function RootLayout({ children }: { children: ReactNode }) { - + @@ -44,5 +46,3 @@ function RootLayout({ children }: { children: ReactNode }) { ); } - -export default RootLayout; diff --git a/admin/ce/components/authentication/authentication-modes.tsx b/admin/ce/components/authentication/authentication-modes.tsx new file mode 100644 index 000000000..e7891e0bc --- /dev/null +++ b/admin/ce/components/authentication/authentication-modes.tsx @@ -0,0 +1,69 @@ +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// types +import { + TGetBaseAuthenticationModeProps, + TInstanceAuthenticationMethodKeys, + TInstanceAuthenticationModes, +} from "@plane/types"; +// components +import { AuthenticationMethodCard } from "@/components/authentication"; +// helpers +import { UpgradeButton } from "@/components/common/upgrade-button"; +import { getBaseAuthenticationModes } from "@/helpers/authentication.helper"; +// images +import OIDCLogo from "@/public/logos/oidc-logo.svg"; +import SAMLLogo from "@/public/logos/saml-logo.svg"; + +export type TAuthenticationModeProps = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +// Authentication methods +export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ + disabled, + updateConfig, + resolvedTheme, +}) => [ + ...getBaseAuthenticationModes({ disabled, updateConfig, resolvedTheme }), + { + key: "oidc", + name: "OIDC", + description: "Authenticate your users via the OpenID Connect protocol.", + icon: OIDC Logo, + config: , + unavailable: true, + }, + { + key: "saml", + name: "SAML", + description: "Authenticate your users via the Security Assertion Markup Language protocol.", + icon: SAML Logo, + config: , + unavailable: true, + }, + ]; + +export const AuthenticationModes: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // next-themes + const { resolvedTheme } = useTheme(); + + return ( + <> + {getAuthenticationModes({ disabled, updateConfig, resolvedTheme }).map((method) => ( + + ))} + + ); +}); diff --git a/admin/ce/components/authentication/index.ts b/admin/ce/components/authentication/index.ts new file mode 100644 index 000000000..d2aa74855 --- /dev/null +++ b/admin/ce/components/authentication/index.ts @@ -0,0 +1 @@ +export * from "./authentication-modes"; diff --git a/admin/components/common/password-strength-meter.tsx b/admin/components/common/password-strength-meter.tsx deleted file mode 100644 index 004a927b2..000000000 --- a/admin/components/common/password-strength-meter.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -// helpers -import { CircleCheck } from "lucide-react"; -import { cn } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; -// icons - -type Props = { - password: string; -}; - -export const PasswordStrengthMeter: React.FC = (props: Props) => { - const { password } = props; - - const strength = getPasswordStrength(password); - let bars = []; - let text = ""; - let textColor = ""; - - if (password.length === 0) { - bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password requirements"; - } else if (password.length < 8) { - bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password is too short"; - textColor = `text-[#DC3E42]`; - } else if (strength < 3) { - bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`]; - text = "Password is weak"; - textColor = `text-[#FFBA18]`; - } else { - bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; - text = "Password is strong"; - textColor = `text-[#3E9B4F]`; - } - - const criteria = [ - { label: "Min 8 characters", isValid: password.length >= 8 }, - { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) }, - { label: "Min 1 number", isValid: /\d/.test(password) }, - { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) }, - ]; - - return ( -
-
- {bars.map((color, index) => ( -
- ))} -
-

{text}

-
- {criteria.map((criterion, index) => ( -
- - {criterion.label} -
- ))} -
-
- ); -}; diff --git a/admin/components/common/toast.tsx b/admin/components/common/toast.tsx deleted file mode 100644 index fe4983db6..000000000 --- a/admin/components/common/toast.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useTheme } from "next-themes"; -// ui -import { Toast as ToastComponent } from "@plane/ui"; -// helpers -import { resolveGeneralTheme } from "@/helpers/common.helper"; - -export const Toast = () => { - const { theme } = useTheme(); - - return ; -}; diff --git a/admin/components/core/index.ts b/admin/components/core/index.ts deleted file mode 100644 index d32aafe96..000000000 --- a/admin/components/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./page-header"; diff --git a/admin/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx similarity index 92% rename from admin/components/admin-sidebar/help-section.tsx rename to admin/core/components/admin-sidebar/help-section.tsx index 56ccbcd84..4b516dff0 100644 --- a/admin/components/admin-sidebar/help-section.tsx +++ b/admin/core/components/admin-sidebar/help-section.tsx @@ -1,13 +1,15 @@ "use client"; import { FC, useState, useRef } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Link from "next/link"; import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; +// ui import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; +// helpers +import { WEB_BASE_URL, cn } from "@/helpers/common.helper"; // hooks -import { WEB_BASE_URL } from "@/helpers/common.helper"; import { useTheme } from "@/hooks/store"; // assets import packageJson from "package.json"; @@ -42,9 +44,12 @@ export const HelpSection: FC = observer(() => { return (
diff --git a/admin/components/admin-sidebar/index.ts b/admin/core/components/admin-sidebar/index.ts similarity index 100% rename from admin/components/admin-sidebar/index.ts rename to admin/core/components/admin-sidebar/index.ts diff --git a/admin/components/admin-sidebar/root.tsx b/admin/core/components/admin-sidebar/root.tsx similarity index 79% rename from admin/components/admin-sidebar/root.tsx rename to admin/core/components/admin-sidebar/root.tsx index ff94bf228..c2a90daa3 100644 --- a/admin/components/admin-sidebar/root.tsx +++ b/admin/core/components/admin-sidebar/root.tsx @@ -1,11 +1,11 @@ "use client"; import { FC, useEffect, useRef } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // hooks import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar"; import { useTheme } from "@/hooks/store"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; // components export interface IInstanceSidebar {} @@ -41,10 +41,10 @@ export const InstanceSidebar: FC = observer(() => {
diff --git a/admin/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx similarity index 99% rename from admin/components/admin-sidebar/sidebar-dropdown.tsx rename to admin/core/components/admin-sidebar/sidebar-dropdown.tsx index 84583e24b..b5a7b4f15 100644 --- a/admin/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -1,7 +1,7 @@ "use client"; import { Fragment, useEffect, useState } from "react"; -import { observer } from "mobx-react-lite"; +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"; diff --git a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx b/admin/core/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx similarity index 93% rename from admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx rename to admin/core/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx index 2e8539488..337d9baaf 100644 --- a/admin/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx +++ b/admin/core/components/admin-sidebar/sidebar-menu-hamburger-toogle.tsx @@ -1,7 +1,7 @@ "use client"; import { FC } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // hooks import { Menu } from "lucide-react"; import { useTheme } from "@/hooks/store"; diff --git a/admin/components/admin-sidebar/sidebar-menu.tsx b/admin/core/components/admin-sidebar/sidebar-menu.tsx similarity index 98% rename from admin/components/admin-sidebar/sidebar-menu.tsx rename to admin/core/components/admin-sidebar/sidebar-menu.tsx index a821243b8..c0f1d0bf2 100644 --- a/admin/components/admin-sidebar/sidebar-menu.tsx +++ b/admin/core/components/admin-sidebar/sidebar-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; diff --git a/admin/components/auth-header.tsx b/admin/core/components/auth-header.tsx similarity index 94% rename from admin/components/auth-header.tsx rename to admin/core/components/auth-header.tsx index 4becf928f..e1de884cf 100644 --- a/admin/components/auth-header.tsx +++ b/admin/core/components/auth-header.tsx @@ -1,7 +1,7 @@ "use client"; import { FC } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; // mobx // ui @@ -10,7 +10,7 @@ import { Settings } from "lucide-react"; import { Breadcrumbs } from "@plane/ui"; // components import { SidebarHamburgerToggle } from "@/components/admin-sidebar"; -import { BreadcrumbLink } from "components/common"; +import { BreadcrumbLink } from "@/components/common"; export const InstanceHeader: FC = observer(() => { const pathName = usePathname(); @@ -31,6 +31,8 @@ export const InstanceHeader: FC = observer(() => { return "Google"; case "github": return "Github"; + case "gitlab": + return "GitLab"; default: return pathName.toUpperCase(); } diff --git a/admin/app/authentication/components/authentication-method-card.tsx b/admin/core/components/authentication/authentication-method-card.tsx similarity index 87% rename from admin/app/authentication/components/authentication-method-card.tsx rename to admin/core/components/authentication/authentication-method-card.tsx index 1346a730e..50895a459 100644 --- a/admin/app/authentication/components/authentication-method-card.tsx +++ b/admin/core/components/authentication/authentication-method-card.tsx @@ -11,10 +11,11 @@ type Props = { config: JSX.Element; disabled?: boolean; withBorder?: boolean; + unavailable?: boolean; }; export const AuthenticationMethodCard: FC = (props) => { - const { name, description, icon, config, disabled = false, withBorder = true } = props; + const { name, description, icon, config, disabled = false, withBorder = true, unavailable = false } = props; return (
= (props) => { "px-4 py-3 border border-custom-border-200": withBorder, })} > -
+
{icon}
diff --git a/admin/app/authentication/components/email-config-switch.tsx b/admin/core/components/authentication/email-config-switch.tsx similarity index 95% rename from admin/app/authentication/components/email-config-switch.tsx rename to admin/core/components/authentication/email-config-switch.tsx index 0f09cf82c..783810e2f 100644 --- a/admin/app/authentication/components/email-config-switch.tsx +++ b/admin/core/components/authentication/email-config-switch.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // hooks import { TInstanceAuthenticationMethodKeys } from "@plane/types"; import { ToggleSwitch } from "@plane/ui"; diff --git a/admin/app/authentication/components/github-config.tsx b/admin/core/components/authentication/github-config.tsx similarity index 97% rename from admin/app/authentication/components/github-config.tsx rename to admin/core/components/authentication/github-config.tsx index 27264d460..07c566d68 100644 --- a/admin/app/authentication/components/github-config.tsx +++ b/admin/core/components/authentication/github-config.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; diff --git a/admin/core/components/authentication/gitlab-config.tsx b/admin/core/components/authentication/gitlab-config.tsx new file mode 100644 index 000000000..735201025 --- /dev/null +++ b/admin/core/components/authentication/gitlab-config.tsx @@ -0,0 +1,59 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// icons +import { Settings2 } from "lucide-react"; +// types +import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +// ui +import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useInstance } from "@/hooks/store"; + +type Props = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; +}; + +export const GitlabConfiguration: React.FC = observer((props) => { + const { disabled, updateConfig } = props; + // store + const { formattedConfig } = useInstance(); + // derived values + const enableGitlabConfig = formattedConfig?.IS_GITLAB_ENABLED ?? ""; + const isGitlabConfigured = !!formattedConfig?.GITLAB_CLIENT_ID && !!formattedConfig?.GITLAB_CLIENT_SECRET; + + return ( + <> + {isGitlabConfigured ? ( +
+ + Edit + + { + Boolean(parseInt(enableGitlabConfig)) === true + ? updateConfig("IS_GITLAB_ENABLED", "0") + : updateConfig("IS_GITLAB_ENABLED", "1"); + }} + size="sm" + disabled={disabled} + /> +
+ ) : ( + + + Configure + + )} + + ); +}); diff --git a/admin/app/authentication/components/google-config.tsx b/admin/core/components/authentication/google-config.tsx similarity index 97% rename from admin/app/authentication/components/google-config.tsx rename to admin/core/components/authentication/google-config.tsx index 9fde70dac..12d11a2f8 100644 --- a/admin/app/authentication/components/google-config.tsx +++ b/admin/core/components/authentication/google-config.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; diff --git a/admin/app/authentication/components/index.ts b/admin/core/components/authentication/index.ts similarity index 85% rename from admin/app/authentication/components/index.ts rename to admin/core/components/authentication/index.ts index d76d61f57..2c13b7728 100644 --- a/admin/app/authentication/components/index.ts +++ b/admin/core/components/authentication/index.ts @@ -1,5 +1,6 @@ export * from "./email-config-switch"; export * from "./password-config-switch"; export * from "./authentication-method-card"; +export * from "./gitlab-config"; export * from "./github-config"; export * from "./google-config"; diff --git a/admin/app/authentication/components/password-config-switch.tsx b/admin/core/components/authentication/password-config-switch.tsx similarity index 95% rename from admin/app/authentication/components/password-config-switch.tsx rename to admin/core/components/authentication/password-config-switch.tsx index 901cce862..00aa62825 100644 --- a/admin/app/authentication/components/password-config-switch.tsx +++ b/admin/core/components/authentication/password-config-switch.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // hooks import { TInstanceAuthenticationMethodKeys } from "@plane/types"; import { ToggleSwitch } from "@plane/ui"; diff --git a/admin/components/common/banner.tsx b/admin/core/components/common/banner.tsx similarity index 100% rename from admin/components/common/banner.tsx rename to admin/core/components/common/banner.tsx diff --git a/admin/components/common/breadcrumb-link.tsx b/admin/core/components/common/breadcrumb-link.tsx similarity index 98% rename from admin/components/common/breadcrumb-link.tsx rename to admin/core/components/common/breadcrumb-link.tsx index dfa437231..d5a00ccaa 100644 --- a/admin/components/common/breadcrumb-link.tsx +++ b/admin/core/components/common/breadcrumb-link.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { Tooltip } from "@plane/ui"; diff --git a/admin/core/components/common/code-block.tsx b/admin/core/components/common/code-block.tsx new file mode 100644 index 000000000..55f8b4afb --- /dev/null +++ b/admin/core/components/common/code-block.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/helpers/common.helper"; + +type TProps = { + children: React.ReactNode; + className?: string; + darkerShade?: boolean; +}; + +export const CodeBlock = ({ children, className, darkerShade }: TProps) => ( + + {children} + +); diff --git a/admin/components/common/confirm-discard-modal.tsx b/admin/core/components/common/confirm-discard-modal.tsx similarity index 82% rename from admin/components/common/confirm-discard-modal.tsx rename to admin/core/components/common/confirm-discard-modal.tsx index 64e4d7a08..d0ca21fc2 100644 --- a/admin/components/common/confirm-discard-modal.tsx +++ b/admin/core/components/common/confirm-discard-modal.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import Link from "next/link"; // headless ui @@ -43,33 +45,22 @@ export const ConfirmDiscardModal: React.FC = (props) => {
- + You have unsaved changes

- Changes you made will be lost if you go back. Do you - wish to go back? + Changes you made will be lost if you go back. Do you wish to go back?

- - + Go back
diff --git a/admin/components/common/controller-input.tsx b/admin/core/components/common/controller-input.tsx similarity index 94% rename from admin/components/common/controller-input.tsx rename to admin/core/components/common/controller-input.tsx index 0eb215095..4d0eade08 100644 --- a/admin/components/common/controller-input.tsx +++ b/admin/core/components/common/controller-input.tsx @@ -38,7 +38,7 @@ export const ControllerInput: React.FC = (props) => { return (

- {label} {!required && "(optional)"} + {label}

= (props) => { ))}
- {description &&

{description}

} + {description &&

{description}

}
); }; diff --git a/admin/components/common/copy-field.tsx b/admin/core/components/common/copy-field.tsx similarity index 100% rename from admin/components/common/copy-field.tsx rename to admin/core/components/common/copy-field.tsx diff --git a/admin/components/common/empty-state.tsx b/admin/core/components/common/empty-state.tsx similarity index 98% rename from admin/components/common/empty-state.tsx rename to admin/core/components/common/empty-state.tsx index fbbe0bc0f..57489ccc6 100644 --- a/admin/components/common/empty-state.tsx +++ b/admin/core/components/common/empty-state.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import Image from "next/image"; import { Button } from "@plane/ui"; diff --git a/admin/components/common/index.ts b/admin/core/components/common/index.ts similarity index 74% rename from admin/components/common/index.ts rename to admin/core/components/common/index.ts index c810cac69..2043926ac 100644 --- a/admin/components/common/index.ts +++ b/admin/core/components/common/index.ts @@ -6,4 +6,6 @@ export * from "./password-strength-meter"; export * from "./banner"; export * from "./empty-state"; export * from "./logo-spinner"; -export * from "./toast"; +export * from "./page-header"; +export * from "./code-block"; +export * from "./upgrade-button"; diff --git a/admin/components/common/logo-spinner.tsx b/admin/core/components/common/logo-spinner.tsx similarity index 100% rename from admin/components/common/logo-spinner.tsx rename to admin/core/components/common/logo-spinner.tsx diff --git a/admin/components/core/page-header.tsx b/admin/core/components/common/page-header.tsx similarity index 100% rename from admin/components/core/page-header.tsx rename to admin/core/components/common/page-header.tsx diff --git a/admin/core/components/common/password-strength-meter.tsx b/admin/core/components/common/password-strength-meter.tsx new file mode 100644 index 000000000..342f77efb --- /dev/null +++ b/admin/core/components/common/password-strength-meter.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, useMemo } from "react"; +// import { CircleCheck } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { + E_PASSWORD_STRENGTH, + // PASSWORD_CRITERIA, + getPasswordStrength, +} from "@/helpers/password.helper"; + +type TPasswordStrengthMeter = { + password: string; + isFocused?: boolean; +}; + +export const PasswordStrengthMeter: FC = (props) => { + const { password, isFocused = false } = props; + // derived values + const strength = useMemo(() => getPasswordStrength(password), [password]); + const strengthBars = useMemo(() => { + switch (strength) { + case E_PASSWORD_STRENGTH.EMPTY: { + return { + bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Please enter your password.", + textColor: "text-custom-text-100", + }; + } + case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: { + return { + bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Password length should me more than 8 characters.", + textColor: "text-red-500", + }; + } + case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: { + return { + bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Password is weak.", + textColor: "text-red-500", + }; + } + case E_PASSWORD_STRENGTH.STRENGTH_VALID: { + return { + bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`], + text: "Password is strong.", + textColor: "text-green-500", + }; + } + default: { + return { + bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Please enter your password.", + textColor: "text-custom-text-100", + }; + } + } + }, [strength]); + + const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true; + + if (!isPasswordMeterVisible) return <>; + return ( +
+
+
+ {strengthBars?.bars.map((color, index) => ( +
+ ))} +
+
+ {strengthBars?.text} +
+
+ + {/*
+ {PASSWORD_CRITERIA.map((criteria) => ( +
+ + {criteria.label} +
+ ))} +
*/} +
+ ); +}; diff --git a/admin/core/components/common/upgrade-button.tsx b/admin/core/components/common/upgrade-button.tsx new file mode 100644 index 000000000..aa3c95fdb --- /dev/null +++ b/admin/core/components/common/upgrade-button.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +// icons +import { SquareArrowOutUpRight } from "lucide-react"; +// ui +import { getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; + +export const UpgradeButton: React.FC = () => ( + + Available on One + + +); diff --git a/admin/components/instance/index.ts b/admin/core/components/instance/index.ts similarity index 100% rename from admin/components/instance/index.ts rename to admin/core/components/instance/index.ts diff --git a/admin/components/instance/instance-failure-view.tsx b/admin/core/components/instance/instance-failure-view.tsx similarity index 100% rename from admin/components/instance/instance-failure-view.tsx rename to admin/core/components/instance/instance-failure-view.tsx diff --git a/admin/components/instance/instance-not-ready.tsx b/admin/core/components/instance/instance-not-ready.tsx similarity index 100% rename from admin/components/instance/instance-not-ready.tsx rename to admin/core/components/instance/instance-not-ready.tsx diff --git a/admin/components/instance/setup-form.tsx b/admin/core/components/instance/setup-form.tsx similarity index 97% rename from admin/components/instance/setup-form.tsx rename to admin/core/components/instance/setup-form.tsx index 77bf8e562..ec3919896 100644 --- a/admin/components/instance/setup-form.tsx +++ b/admin/core/components/instance/setup-form.tsx @@ -10,7 +10,7 @@ import { Button, Checkbox, Input, Spinner } from "@plane/ui"; import { Banner, PasswordStrengthMeter } from "@/components/common"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // services import { AuthService } from "@/services/auth.service"; @@ -121,7 +121,7 @@ export const InstanceSetupForm: FC = (props) => { formData.first_name && formData.email && formData.password && - getPasswordStrength(formData.password) >= 3 && + getPasswordStrength(formData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && formData.password === formData.confirm_password ? false : true, @@ -271,7 +271,7 @@ export const InstanceSetupForm: FC = (props) => { {errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (

{errorData.message}

)} - {isPasswordInputFocused && } +
@@ -319,6 +319,8 @@ export const InstanceSetupForm: FC = (props) => {
handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)} checked={formData.is_telemetry_enabled} diff --git a/admin/components/login/index.ts b/admin/core/components/login/index.ts similarity index 100% rename from admin/components/login/index.ts rename to admin/core/components/login/index.ts diff --git a/admin/components/login/sign-in-form.tsx b/admin/core/components/login/sign-in-form.tsx similarity index 100% rename from admin/components/login/sign-in-form.tsx rename to admin/core/components/login/sign-in-form.tsx diff --git a/admin/components/new-user-popup.tsx b/admin/core/components/new-user-popup.tsx similarity index 97% rename from admin/components/new-user-popup.tsx rename to admin/core/components/new-user-popup.tsx index 840de0c3a..9fc3938ff 100644 --- a/admin/components/new-user-popup.tsx +++ b/admin/core/components/new-user-popup.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Image from "next/image"; import { useTheme as nextUseTheme } from "next-themes"; // ui diff --git a/admin/constants/seo.ts b/admin/core/constants/seo.ts similarity index 100% rename from admin/constants/seo.ts rename to admin/core/constants/seo.ts diff --git a/admin/constants/swr-config.ts b/admin/core/constants/swr-config.ts similarity index 100% rename from admin/constants/swr-config.ts rename to admin/core/constants/swr-config.ts diff --git a/admin/hooks/store/index.ts b/admin/core/hooks/store/index.ts similarity index 100% rename from admin/hooks/store/index.ts rename to admin/core/hooks/store/index.ts diff --git a/admin/hooks/store/use-instance.tsx b/admin/core/hooks/store/use-instance.tsx similarity index 100% rename from admin/hooks/store/use-instance.tsx rename to admin/core/hooks/store/use-instance.tsx diff --git a/admin/hooks/store/use-theme.tsx b/admin/core/hooks/store/use-theme.tsx similarity index 100% rename from admin/hooks/store/use-theme.tsx rename to admin/core/hooks/store/use-theme.tsx diff --git a/admin/hooks/store/use-user.tsx b/admin/core/hooks/store/use-user.tsx similarity index 100% rename from admin/hooks/store/use-user.tsx rename to admin/core/hooks/store/use-user.tsx diff --git a/admin/hooks/use-outside-click-detector.tsx b/admin/core/hooks/use-outside-click-detector.tsx similarity index 100% rename from admin/hooks/use-outside-click-detector.tsx rename to admin/core/hooks/use-outside-click-detector.tsx diff --git a/admin/layouts/admin-layout.tsx b/admin/core/layouts/admin-layout.tsx similarity index 96% rename from admin/layouts/admin-layout.tsx rename to admin/core/layouts/admin-layout.tsx index bcc103217..6308aecd1 100644 --- a/admin/layouts/admin-layout.tsx +++ b/admin/core/layouts/admin-layout.tsx @@ -1,6 +1,6 @@ "use client"; import { FC, ReactNode, useEffect } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; // components import { InstanceSidebar } from "@/components/admin-sidebar"; diff --git a/admin/layouts/default-layout.tsx b/admin/core/layouts/default-layout.tsx similarity index 100% rename from admin/layouts/default-layout.tsx rename to admin/core/layouts/default-layout.tsx diff --git a/admin/lib/instance-provider.tsx b/admin/core/lib/instance-provider.tsx similarity index 97% rename from admin/lib/instance-provider.tsx rename to admin/core/lib/instance-provider.tsx index fbcf27d82..d021e3b83 100644 --- a/admin/lib/instance-provider.tsx +++ b/admin/core/lib/instance-provider.tsx @@ -1,5 +1,5 @@ import { FC, ReactNode } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import useSWR from "swr"; // components import { LogoSpinner } from "@/components/common"; diff --git a/admin/lib/store-provider.tsx b/admin/core/lib/store-provider.tsx similarity index 100% rename from admin/lib/store-provider.tsx rename to admin/core/lib/store-provider.tsx diff --git a/admin/lib/user-provider.tsx b/admin/core/lib/user-provider.tsx similarity index 95% rename from admin/lib/user-provider.tsx rename to admin/core/lib/user-provider.tsx index d8448d13e..17d702627 100644 --- a/admin/lib/user-provider.tsx +++ b/admin/core/lib/user-provider.tsx @@ -1,7 +1,7 @@ "use client"; import { FC, ReactNode, useEffect } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import useSWR from "swr"; // hooks import { useInstance, useTheme, useUser } from "@/hooks/store"; diff --git a/admin/services/api.service.ts b/admin/core/services/api.service.ts similarity index 100% rename from admin/services/api.service.ts rename to admin/core/services/api.service.ts diff --git a/admin/services/auth.service.ts b/admin/core/services/auth.service.ts similarity index 89% rename from admin/services/auth.service.ts rename to admin/core/services/auth.service.ts index ef7b7b151..2454473ca 100644 --- a/admin/services/auth.service.ts +++ b/admin/core/services/auth.service.ts @@ -1,7 +1,7 @@ // helpers import { API_BASE_URL } from "helpers/common.helper"; // services -import { APIService } from "services/api.service"; +import { APIService } from "@/services/api.service"; type TCsrfTokenResponse = { csrf_token: string; diff --git a/admin/services/instance.service.ts b/admin/core/services/instance.service.ts similarity index 100% rename from admin/services/instance.service.ts rename to admin/core/services/instance.service.ts diff --git a/admin/services/user.service.ts b/admin/core/services/user.service.ts similarity index 93% rename from admin/services/user.service.ts rename to admin/core/services/user.service.ts index bef384daf..992ed7c08 100644 --- a/admin/services/user.service.ts +++ b/admin/core/services/user.service.ts @@ -1,9 +1,9 @@ // helpers import { API_BASE_URL } from "helpers/common.helper"; -// services -import { APIService } from "services/api.service"; // types import type { IUser } from "@plane/types"; +// services +import { APIService } from "@/services/api.service"; interface IUserSession extends IUser { isAuthenticated: boolean; diff --git a/admin/store/instance.store.ts b/admin/core/store/instance.store.ts similarity index 98% rename from admin/store/instance.store.ts rename to admin/core/store/instance.store.ts index a99cd808c..7be8deec6 100644 --- a/admin/store/instance.store.ts +++ b/admin/core/store/instance.store.ts @@ -9,7 +9,7 @@ import { IInstanceConfig, } from "@plane/types"; // helpers -import { EInstanceStatus, TInstanceStatus } from "@/helpers"; +import { EInstanceStatus, TInstanceStatus } from "@/helpers/instance.helper"; // services import { InstanceService } from "@/services/instance.service"; // root store diff --git a/admin/store/root.store.ts b/admin/core/store/root.store.ts similarity index 93% rename from admin/store/root.store.ts rename to admin/core/store/root.store.ts index 553a22200..32977422e 100644 --- a/admin/store/root.store.ts +++ b/admin/core/store/root.store.ts @@ -1,4 +1,4 @@ -import { enableStaticRendering } from "mobx-react-lite"; +import { enableStaticRendering } from "mobx-react"; // stores import { IInstanceStore, InstanceStore } from "./instance.store"; import { IThemeStore, ThemeStore } from "./theme.store"; diff --git a/admin/store/theme.store.ts b/admin/core/store/theme.store.ts similarity index 100% rename from admin/store/theme.store.ts rename to admin/core/store/theme.store.ts diff --git a/admin/store/user.store.ts b/admin/core/store/user.store.ts similarity index 97% rename from admin/store/user.store.ts rename to admin/core/store/user.store.ts index 60638f0cd..fd3132169 100644 --- a/admin/store/user.store.ts +++ b/admin/core/store/user.store.ts @@ -1,7 +1,7 @@ import { action, observable, runInAction, makeObservable } from "mobx"; import { IUser } from "@plane/types"; // helpers -import { EUserStatus, TUserStatus } from "@/helpers"; +import { EUserStatus, TUserStatus } from "@/helpers/user.helper"; // services import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; diff --git a/admin/ee/components/authentication/authentication-modes.tsx b/admin/ee/components/authentication/authentication-modes.tsx new file mode 100644 index 000000000..3a8ab7d1d --- /dev/null +++ b/admin/ee/components/authentication/authentication-modes.tsx @@ -0,0 +1 @@ +export * from "ce/components/authentication/authentication-modes"; \ No newline at end of file diff --git a/admin/ee/components/authentication/index.ts b/admin/ee/components/authentication/index.ts new file mode 100644 index 000000000..d2aa74855 --- /dev/null +++ b/admin/ee/components/authentication/index.ts @@ -0,0 +1 @@ +export * from "./authentication-modes"; diff --git a/admin/helpers/authentication.helper.tsx b/admin/helpers/authentication.helper.tsx index cc9058611..627ff182c 100644 --- a/admin/helpers/authentication.helper.tsx +++ b/admin/helpers/authentication.helper.tsx @@ -1,7 +1,24 @@ import { ReactNode } from "react"; +import Image from "next/image"; import Link from "next/link"; +import { KeyRound, Mails } from "lucide-react"; +// types +import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; +// components +import { + EmailCodesConfiguration, + GithubConfiguration, + GitlabConfiguration, + GoogleConfiguration, + PasswordLoginConfiguration, +} from "@/components/authentication"; // helpers -import { SUPPORT_EMAIL } from "./common.helper"; +import { SUPPORT_EMAIL, resolveGeneralTheme } from "@/helpers/common.helper"; +// images +import githubLightModeImage from "@/public/logos/github-black.png"; +import githubDarkModeImage from "@/public/logos/github-white.png"; +import GitlabLogo from "@/public/logos/gitlab-logo.svg"; +import GoogleLogo from "@/public/logos/google-logo.svg"; export enum EPageTypes { PUBLIC = "PUBLIC", @@ -134,3 +151,53 @@ export const authErrorHandler = ( return undefined; }; + +export const getBaseAuthenticationModes: (props: TGetBaseAuthenticationModeProps) => TInstanceAuthenticationModes[] = ({ + disabled, + updateConfig, + resolvedTheme, +}) => [ + { + key: "unique-codes", + name: "Unique codes", + description: + "Log in or sign up for Plane using codes sent via email. You need to have set up SMTP to use this method.", + icon: , + config: , + }, + { + key: "passwords-login", + name: "Passwords", + description: "Allow members to create accounts with passwords and use it with their email addresses to sign in.", + icon: , + config: , + }, + { + key: "google", + name: "Google", + description: "Allow members to log in or sign up for Plane with their Google accounts.", + icon: Google Logo, + config: , + }, + { + key: "github", + name: "GitHub", + description: "Allow members to log in or sign up for Plane with their GitHub accounts.", + icon: ( + GitHub Logo + ), + config: , + }, + { + key: "gitlab", + name: "GitLab", + description: "Allow members to log in or sign up to plane with their GitLab accounts.", + icon: GitLab Logo, + config: , + }, + ]; diff --git a/admin/helpers/password.helper.ts b/admin/helpers/password.helper.ts index 8d80b3402..dfe9a5c65 100644 --- a/admin/helpers/password.helper.ts +++ b/admin/helpers/password.helper.ts @@ -1,16 +1,67 @@ import zxcvbn from "zxcvbn"; -export const isPasswordCriteriaMet = (password: string) => { - const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)]; +export enum E_PASSWORD_STRENGTH { + EMPTY = "empty", + LENGTH_NOT_VALID = "length_not_valid", + STRENGTH_NOT_VALID = "strength_not_valid", + STRENGTH_VALID = "strength_valid", +} - return criteria.every((criterion) => criterion); -}; - -export const getPasswordStrength = (password: string) => { - if (password.length === 0) return 0; - if (password.length < 8) return 1; - if (!isPasswordCriteriaMet(password)) return 2; - - const result = zxcvbn(password); - return result.score; +const PASSWORD_MIN_LENGTH = 8; +// const PASSWORD_NUMBER_REGEX = /\d/; +// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/; +// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/; + +export const PASSWORD_CRITERIA = [ + { + key: "min_8_char", + label: "Min 8 characters", + isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH, + }, + // { + // key: "min_1_upper_case", + // label: "Min 1 upper-case letter", + // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password), + // }, + // { + // key: "min_1_number", + // label: "Min 1 number", + // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password), + // }, + // { + // key: "min_1_special_char", + // label: "Min 1 special character", + // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password), + // }, +]; + +export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { + let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY; + + if (!password || password === "" || password.length <= 0) { + return passwordStrength; + } + + if (password.length >= PASSWORD_MIN_LENGTH) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; + } else { + passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID; + return passwordStrength; + } + + const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every( + (criterion) => criterion + ); + const passwordStrengthScore = zxcvbn(password).score; + + if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; + return passwordStrength; + } + + if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID; + } + + return passwordStrength; }; diff --git a/admin/package.json b/admin/package.json index 9c4567070..c8b072626 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "0.21.0", + "version": "0.22.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -14,7 +14,6 @@ "@headlessui/react": "^1.7.19", "@plane/types": "*", "@plane/ui": "*", - "@plane/constants": "*", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", @@ -23,7 +22,7 @@ "lodash": "^4.17.21", "lucide-react": "^0.356.0", "mobx": "^6.12.0", - "mobx-react-lite": "^4.0.5", + "mobx-react": "^9.1.1", "next": "^14.2.3", "next-themes": "^0.2.1", "postcss": "^8.4.38", diff --git a/admin/public/logos/gitlab-logo.svg b/admin/public/logos/gitlab-logo.svg new file mode 100644 index 000000000..dab4d8b74 --- /dev/null +++ b/admin/public/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/admin/public/logos/oidc-logo.svg b/admin/public/logos/oidc-logo.svg new file mode 100644 index 000000000..68bc72d01 --- /dev/null +++ b/admin/public/logos/oidc-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/admin/public/logos/saml-logo.svg b/admin/public/logos/saml-logo.svg new file mode 100644 index 000000000..4cbb4f81d --- /dev/null +++ b/admin/public/logos/saml-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/admin/tsconfig.json b/admin/tsconfig.json index 5bc5a5684..120275283 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -7,7 +7,10 @@ "jsx": "preserve", "esModuleInterop": true, "paths": { - "@/*": ["*"] + "@/*": ["core/*"], + "@/helpers/*": ["helpers/*"], + "@/public/*": ["public/*"], + "@/plane-admin/*": ["ce/*"] }, "plugins": [ { diff --git a/aio/Dockerfile b/aio/Dockerfile-app similarity index 64% rename from aio/Dockerfile rename to aio/Dockerfile-app index 94d61b866..54b5269e3 100644 --- a/aio/Dockerfile +++ b/aio/Dockerfile-app @@ -1,3 +1,5 @@ +ARG BASE_TAG=develop +ARG BUILD_TYPE=full # ***************************************************************************** # STAGE 1: Build the project # ***************************************************************************** @@ -5,7 +7,6 @@ FROM node:18-alpine AS builder RUN apk add --no-cache libc6-compat # Set working directory WORKDIR /app -ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER RUN yarn global add turbo COPY . . @@ -46,16 +47,18 @@ ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL ARG NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ARG NEXT_PUBLIC_WEB_BASE_URL="" +ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -RUN yarn turbo run build +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 + +RUN yarn turbo run build --filter=web --filter=space --filter=admin # ***************************************************************************** # STAGE 3: Copy the project and start it # ***************************************************************************** -# FROM makeplane/plane-aio-base AS runner -FROM makeplane/plane-aio-base:develop AS runner +FROM makeplane/plane-aio-base:${BUILD_TYPE}-${BASE_TAG} AS runner WORKDIR /app @@ -63,17 +66,14 @@ SHELL [ "/bin/bash", "-c" ] # PYTHON APPLICATION SETUP -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 COPY apiserver/requirements.txt ./api/ COPY apiserver/requirements ./api/requirements -RUN python3.12 -m venv /app/venv && \ - source /app/venv/bin/activate && \ - /app/venv/bin/pip install --upgrade pip && \ - /app/venv/bin/pip install -r ./api/requirements.txt --compile --no-cache-dir +RUN pip install -r ./api/requirements.txt --compile --no-cache-dir # Add in Django deps and generate Django's static files COPY apiserver/manage.py ./api/manage.py @@ -87,7 +87,6 @@ RUN chmod +x ./api/bin/* RUN chmod -R 777 ./api/ # NEXTJS BUILDS - COPY --from=installer /app/web/next.config.js ./web/ COPY --from=installer /app/web/package.json ./web/ COPY --from=installer /app/web/.next/standalone ./web @@ -124,26 +123,63 @@ ENV NEXT_PUBLIC_SPACE_BASE_PATH=$NEXT_PUBLIC_SPACE_BASE_PATH ARG NEXT_PUBLIC_WEB_BASE_URL="" ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL -ENV NEXT_TELEMETRY_DISABLED 1 -ENV TURBO_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 -COPY aio/supervisord.conf /app/supervisord.conf +ARG BUILD_TYPE=full +ENV BUILD_TYPE=$BUILD_TYPE -COPY aio/aio.sh /app/aio.sh -RUN chmod +x /app/aio.sh +COPY aio/supervisord-${BUILD_TYPE}-base /app/supervisord.conf +COPY aio/supervisord-app /app/supervisord-app +RUN cat /app/supervisord-app >> /app/supervisord.conf && \ + rm /app/supervisord-app +COPY ./aio/nginx.conf /etc/nginx/nginx.conf.template + +# if build type is full, run the below copy pg-setup.sh +COPY aio/postgresql.conf /etc/postgresql/postgresql.conf COPY aio/pg-setup.sh /app/pg-setup.sh RUN chmod +x /app/pg-setup.sh -COPY deploy/selfhost/variables.env /app/plane.env +# ***************************************************************************** +# APPLICATION ENVIRONMENT SETTINGS +# ***************************************************************************** +ENV APP_DOMAIN=localhost -# NGINX Conf Copy -COPY ./aio/nginx.conf.aio /etc/nginx/nginx.conf.template -COPY ./nginx/env.sh /app/nginx-start.sh -RUN chmod +x /app/nginx-start.sh +ENV WEB_URL=http://${APP_DOMAIN} +ENV DEBUG=0 +ENV SENTRY_DSN= +ENV SENTRY_ENVIRONMENT=production +ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN} +# Secret Key +ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 +# Gunicorn Workers +ENV GUNICORN_WORKERS=1 -RUN ./pg-setup.sh +ENV POSTGRES_USER="plane" +ENV POSTGRES_PASSWORD="plane" +ENV POSTGRES_DB="plane" +ENV POSTGRES_HOST="localhost" +ENV POSTGRES_PORT="5432" +ENV DATABASE_URL="postgresql://plane:plane@localhost:5432/plane" -VOLUME [ "/app/data/minio/uploads", "/var/lib/postgresql/data" ] +ENV REDIS_HOST="localhost" +ENV REDIS_PORT="6379" +ENV REDIS_URL="redis://localhost:6379" + +ENV USE_MINIO="1" +ENV AWS_REGION="" +ENV AWS_ACCESS_KEY_ID="access-key" +ENV AWS_SECRET_ACCESS_KEY="secret-key" +ENV AWS_S3_ENDPOINT_URL="http://localhost:9000" +ENV AWS_S3_BUCKET_NAME="uploads" +ENV MINIO_ROOT_USER="access-key" +ENV MINIO_ROOT_PASSWORD="secret-key" +ENV BUCKET_NAME="uploads" +ENV FILE_SIZE_LIMIT="5242880" + +# ***************************************************************************** + +RUN /app/pg-setup.sh CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/Dockerfile.base b/aio/Dockerfile-base-full similarity index 71% rename from aio/Dockerfile.base rename to aio/Dockerfile-base-full index 092deb797..218530948 100644 --- a/aio/Dockerfile.base +++ b/aio/Dockerfile-base-full @@ -1,19 +1,28 @@ -FROM --platform=$BUILDPLATFORM tonistiigi/binfmt as binfmt +FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt -FROM debian:12-slim +FROM python:3.12-slim # Set environment variables to non-interactive for apt ENV DEBIAN_FRONTEND=noninteractive +ENV BUILD_TYPE=full SHELL [ "/bin/bash", "-c" ] +WORKDIR /app + +RUN mkdir -p /app/{data,logs} && \ + mkdir -p /app/data/{redis,pg,minio,nginx} && \ + mkdir -p /app/logs/{access,error} && \ + mkdir -p /etc/supervisor/conf.d + # Update the package list and install prerequisites RUN apt-get update && \ apt-get install -y \ gnupg2 curl ca-certificates lsb-release software-properties-common \ build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \ - tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu + tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \ + sudo lsof net-tools libpq-dev procps gettext # Install Redis 7.2 RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > /etc/apt/sources.list.d/backports.list && \ @@ -23,13 +32,15 @@ RUN echo "deb http://deb.debian.org/debian $(lsb_release -cs)-backports main" > apt-get install -y redis-server # Install PostgreSQL 15 -ENV POSTGRES_VERSION 15 +ENV POSTGRES_VERSION=15 RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg-archive-keyring.gpg && \ echo "deb [signed-by=/usr/share/keyrings/pgdg-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \ apt-get update && \ apt-get install -y postgresql-$POSTGRES_VERSION postgresql-client-$POSTGRES_VERSION && \ mkdir -p /var/lib/postgresql/data && \ chown -R postgres:postgres /var/lib/postgresql +COPY postgresql.conf /etc/postgresql/postgresql.conf +RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data # Install MinIO ARG TARGETARCH @@ -42,51 +53,21 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ fi && \ chmod +x /usr/local/bin/minio - # Install Node.js 18 RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs - -# Install Python 3.12 from source -RUN cd /usr/src && \ - wget https://www.python.org/ftp/python/3.12.0/Python-3.12.0.tgz && \ - tar xzf Python-3.12.0.tgz && \ - cd Python-3.12.0 && \ - ./configure --enable-optimizations && \ - make altinstall && \ - rm -f /usr/src/Python-3.12.0.tgz - -RUN python3.12 -m pip install --upgrade pip - -RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \ - echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc - -# Clean up -RUN apt-get clean && \ - rm -rf /var/lib/apt/lists/* /usr/src/Python-3.12.0 - -WORKDIR /app - -RUN mkdir -p /app/{data,logs} && \ - mkdir -p /app/data/{redis,pg,minio,nginx} && \ - mkdir -p /app/logs/{access,error} && \ - mkdir -p /etc/supervisor/conf.d + apt-get install -y nodejs && \ + python -m pip install --upgrade pip && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* # Create Supervisor configuration file -COPY supervisord.base /app/supervisord.conf - -RUN apt-get update && \ - apt-get install -y sudo lsof net-tools libpq-dev procps gettext && \ - apt-get clean - -RUN sudo -u postgres /usr/lib/postgresql/$POSTGRES_VERSION/bin/initdb -D /var/lib/postgresql/data -COPY postgresql.conf /etc/postgresql/postgresql.conf - -RUN echo "alias python=/usr/local/bin/python3.12" >> ~/.bashrc && \ - echo "alias pip=/usr/local/bin/pip3.12" >> ~/.bashrc +COPY supervisord-full-base /app/supervisord.conf +COPY nginx.conf /etc/nginx/nginx.conf.template +COPY env.sh /app/nginx-start.sh +RUN chmod +x /app/nginx-start.sh # Expose ports for Redis, PostgreSQL, and MinIO -EXPOSE 6379 5432 9000 80 +EXPOSE 6379 5432 9000 80 443 # Start Supervisor CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/Dockerfile-base-slim b/aio/Dockerfile-base-slim new file mode 100644 index 000000000..c6bc249de --- /dev/null +++ b/aio/Dockerfile-base-slim @@ -0,0 +1,45 @@ +FROM --platform=$BUILDPLATFORM tonistiigi/binfmt AS binfmt + +FROM python:3.12-slim + +# Set environment variables to non-interactive for apt +ENV DEBIAN_FRONTEND=noninteractive +ENV BUILD_TYPE=slim + +SHELL [ "/bin/bash", "-c" ] + +WORKDIR /app + +RUN mkdir -p /app/{data,logs} && \ + mkdir -p /app/data/{nginx} && \ + mkdir -p /app/logs/{access,error} && \ + mkdir -p /etc/supervisor/conf.d + +# Update the package list and install prerequisites +RUN apt-get update && \ + apt-get install -y \ + gnupg2 curl ca-certificates lsb-release software-properties-common \ + build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev \ + libsqlite3-dev wget llvm libncurses5-dev libncursesw5-dev xz-utils \ + tk-dev libffi-dev liblzma-dev supervisor nginx nano vim ncdu \ + sudo lsof net-tools libpq-dev procps gettext + +# Install Node.js 18 +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ + apt-get install -y nodejs + +RUN python -m pip install --upgrade pip && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Create Supervisor configuration file +COPY supervisord-slim-base /app/supervisord.conf +COPY nginx.conf /etc/nginx/nginx.conf.template +COPY env.sh /app/nginx-start.sh +RUN chmod +x /app/nginx-start.sh + +# Expose ports for Redis, PostgreSQL, and MinIO +EXPOSE 80 443 + +# Start Supervisor +CMD ["/usr/bin/supervisord", "-c", "/app/supervisord.conf"] diff --git a/aio/aio.sh b/aio/aio.sh deleted file mode 100644 index 53adbf42b..000000000 --- a/aio/aio.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -set -e - - -if [ "$1" = 'api' ]; then - source /app/venv/bin/activate - cd /app/api - exec ./bin/docker-entrypoint-api.sh -elif [ "$1" = 'worker' ]; then - source /app/venv/bin/activate - cd /app/api - exec ./bin/docker-entrypoint-worker.sh -elif [ "$1" = 'beat' ]; then - source /app/venv/bin/activate - cd /app/api - exec ./bin/docker-entrypoint-beat.sh -elif [ "$1" = 'migrator' ]; then - source /app/venv/bin/activate - cd /app/api - exec ./bin/docker-entrypoint-migrator.sh -elif [ "$1" = 'web' ]; then - node /app/web/web/server.js -elif [ "$1" = 'space' ]; then - node /app/space/space/server.js -elif [ "$1" = 'admin' ]; then - node /app/admin/admin/server.js -else - echo "Command not found" - exit 1 -fi \ No newline at end of file diff --git a/aio/env.sh b/aio/env.sh new file mode 100644 index 000000000..ff5f769fd --- /dev/null +++ b/aio/env.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +export dollar="$" +export http_upgrade="http_upgrade" +export scheme="scheme" +envsubst < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf +exec nginx -g 'daemon off;' diff --git a/aio/nginx.conf.aio b/aio/nginx.conf similarity index 99% rename from aio/nginx.conf.aio rename to aio/nginx.conf index 1a1f3c0b8..78ae00d28 100644 --- a/aio/nginx.conf.aio +++ b/aio/nginx.conf @@ -37,7 +37,6 @@ http { proxy_pass http://localhost:3002/spaces/; } - location /god-mode/ { proxy_http_version 1.1; proxy_set_header Upgrade ${dollar}http_upgrade; diff --git a/aio/pg-setup.sh b/aio/pg-setup.sh index 6f6ea88e6..b830acc5e 100644 --- a/aio/pg-setup.sh +++ b/aio/pg-setup.sh @@ -1,14 +1,14 @@ #!/bin/bash +if [ "$BUILD_TYPE" == "full" ]; then -# Variables -set -o allexport -source plane.env set -set +o allexport + export PGHOST=localhost -export PGHOST=localhost + sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start + sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \ + sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \ + sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;" && \ + sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop + +fi -sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data start -sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/psql" --command "CREATE USER $POSTGRES_USER WITH SUPERUSER PASSWORD '$POSTGRES_PASSWORD';" && \ -sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/createdb" -O "$POSTGRES_USER" "$POSTGRES_DB" && \ -sudo -u postgres "/usr/lib/postgresql/${POSTGRES_VERSION}/bin/pg_ctl" -D /var/lib/postgresql/data stop diff --git a/aio/postgresql.conf b/aio/postgresql.conf index 8c6223fc4..8f3c4e8a4 100644 --- a/aio/postgresql.conf +++ b/aio/postgresql.conf @@ -1,12 +1,815 @@ +# ----------------------------- # PostgreSQL configuration file +# ----------------------------- +# +# This file consists of lines of the form: +# +# name = value +# +# (The "=" is optional.) Whitespace may be used. Comments are introduced with +# "#" anywhere on a line. The complete list of parameter names and allowed +# values can be found in the PostgreSQL documentation. +# +# The commented-out settings shown in this file represent the default values. +# Re-commenting a setting is NOT sufficient to revert it to the default value; +# you need to reload the server. +# +# This file is read on server startup and when the server receives a SIGHUP +# signal. If you edit the file on a running system, you have to SIGHUP the +# server for the changes to take effect, run "pg_ctl reload", or execute +# "SELECT pg_reload_conf()". Some parameters, which are marked below, +# require a server shutdown and restart to take effect. +# +# Any parameter can also be given as a command-line option to the server, e.g., +# "postgres -c log_connections=on". Some parameters can be changed at run time +# with the "SET" SQL command. +# +# Memory units: B = bytes Time units: us = microseconds +# kB = kilobytes ms = milliseconds +# MB = megabytes s = seconds +# GB = gigabytes min = minutes +# TB = terabytes h = hours +# d = days -# Allow connections from any IP address -listen_addresses = '*' -# Set the maximum number of connections -max_connections = 100 +#------------------------------------------------------------------------------ +# FILE LOCATIONS +#------------------------------------------------------------------------------ -# Set the shared buffers size -shared_buffers = 128MB +# The default values of these variables are driven from the -D command-line +# option or PGDATA environment variable, represented here as ConfigDir. -# Other custom configurations can be added here +data_directory = '/var/lib/postgresql/data' # use data in another directory + # (change requires restart) +hba_file = '/etc/postgresql/15/main/pg_hba.conf' # host-based authentication file + # (change requires restart) +ident_file = '/etc/postgresql/15/main/pg_ident.conf' # ident configuration file + # (change requires restart) + +# If external_pid_file is not explicitly set, no extra PID file is written. +external_pid_file = '/var/run/postgresql/15-main.pid' # write an extra PID file + # (change requires restart) + + +#------------------------------------------------------------------------------ +# CONNECTIONS AND AUTHENTICATION +#------------------------------------------------------------------------------ + +# - Connection Settings - + +listen_addresses = 'localhost' # what IP address(es) to listen on; + # comma-separated list of addresses; + # defaults to 'localhost'; use '*' for all + # (change requires restart) +port = 5432 # (change requires restart) +max_connections = 200 # (change requires restart) +#superuser_reserved_connections = 3 # (change requires restart) +unix_socket_directories = '/var/run/postgresql' # comma-separated list of directories + # (change requires restart) +#unix_socket_group = '' # (change requires restart) +#unix_socket_permissions = 0777 # begin with 0 to use octal notation + # (change requires restart) +#bonjour = off # advertise server via Bonjour + # (change requires restart) +#bonjour_name = '' # defaults to the computer name + # (change requires restart) + +# - TCP settings - +# see "man tcp" for details + +#tcp_keepalives_idle = 0 # TCP_KEEPIDLE, in seconds; + # 0 selects the system default +#tcp_keepalives_interval = 0 # TCP_KEEPINTVL, in seconds; + # 0 selects the system default +#tcp_keepalives_count = 0 # TCP_KEEPCNT; + # 0 selects the system default +#tcp_user_timeout = 0 # TCP_USER_TIMEOUT, in milliseconds; + # 0 selects the system default + +#client_connection_check_interval = 0 # time between checks for client + # disconnection while running queries; + # 0 for never + +# - Authentication - + +#authentication_timeout = 1min # 1s-600s +#password_encryption = scram-sha-256 # scram-sha-256 or md5 +#db_user_namespace = off + +# GSSAPI using Kerberos +#krb_server_keyfile = 'FILE:${sysconfdir}/krb5.keytab' +#krb_caseins_users = off + +# - SSL - + +ssl = on +#ssl_ca_file = '' +ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem' +#ssl_crl_file = '' +#ssl_crl_dir = '' +ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key' +#ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL' # allowed SSL ciphers +#ssl_prefer_server_ciphers = on +#ssl_ecdh_curve = 'prime256v1' +#ssl_min_protocol_version = 'TLSv1.2' +#ssl_max_protocol_version = '' +#ssl_dh_params_file = '' +#ssl_passphrase_command = '' +#ssl_passphrase_command_supports_reload = off + + +#------------------------------------------------------------------------------ +# RESOURCE USAGE (except WAL) +#------------------------------------------------------------------------------ + +# - Memory - + +shared_buffers = 256MB # min 128kB + # (change requires restart) +#huge_pages = try # on, off, or try + # (change requires restart) +#huge_page_size = 0 # zero for system default + # (change requires restart) +#temp_buffers = 8MB # min 800kB +#max_prepared_transactions = 0 # zero disables the feature + # (change requires restart) +# Caution: it is not advisable to set max_prepared_transactions nonzero unless +# you actively intend to use prepared transactions. +#work_mem = 4MB # min 64kB +#hash_mem_multiplier = 2.0 # 1-1000.0 multiplier on hash table work_mem +#maintenance_work_mem = 64MB # min 1MB +#autovacuum_work_mem = -1 # min 1MB, or -1 to use maintenance_work_mem +#logical_decoding_work_mem = 64MB # min 64kB +#max_stack_depth = 2MB # min 100kB +#shared_memory_type = mmap # the default is the first option + # supported by the operating system: + # mmap + # sysv + # windows + # (change requires restart) +dynamic_shared_memory_type = posix # the default is usually the first option + # supported by the operating system: + # posix + # sysv + # windows + # mmap + # (change requires restart) +#min_dynamic_shared_memory = 0MB # (change requires restart) + +# - Disk - + +#temp_file_limit = -1 # limits per-process temp file space + # in kilobytes, or -1 for no limit + +# - Kernel Resources - + +#max_files_per_process = 1000 # min 64 + # (change requires restart) + +# - Cost-Based Vacuum Delay - + +#vacuum_cost_delay = 0 # 0-100 milliseconds (0 disables) +#vacuum_cost_page_hit = 1 # 0-10000 credits +#vacuum_cost_page_miss = 2 # 0-10000 credits +#vacuum_cost_page_dirty = 20 # 0-10000 credits +#vacuum_cost_limit = 200 # 1-10000 credits + +# - Background Writer - + +#bgwriter_delay = 200ms # 10-10000ms between rounds +#bgwriter_lru_maxpages = 100 # max buffers written/round, 0 disables +#bgwriter_lru_multiplier = 2.0 # 0-10.0 multiplier on buffers scanned/round +#bgwriter_flush_after = 512kB # measured in pages, 0 disables + +# - Asynchronous Behavior - + +#backend_flush_after = 0 # measured in pages, 0 disables +#effective_io_concurrency = 1 # 1-1000; 0 disables prefetching +#maintenance_io_concurrency = 10 # 1-1000; 0 disables prefetching +#max_worker_processes = 8 # (change requires restart) +#max_parallel_workers_per_gather = 2 # limited by max_parallel_workers +#max_parallel_maintenance_workers = 2 # limited by max_parallel_workers +#max_parallel_workers = 8 # number of max_worker_processes that + # can be used in parallel operations +#parallel_leader_participation = on +#old_snapshot_threshold = -1 # 1min-60d; -1 disables; 0 is immediate + # (change requires restart) + + +#------------------------------------------------------------------------------ +# WRITE-AHEAD LOG +#------------------------------------------------------------------------------ + +# - Settings - + +#wal_level = replica # minimal, replica, or logical + # (change requires restart) +#fsync = on # flush data to disk for crash safety + # (turning this off can cause + # unrecoverable data corruption) +#synchronous_commit = on # synchronization level; + # off, local, remote_write, remote_apply, or on +#wal_sync_method = fsync # the default is the first option + # supported by the operating system: + # open_datasync + # fdatasync (default on Linux and FreeBSD) + # fsync + # fsync_writethrough + # open_sync +#full_page_writes = on # recover from partial page writes +#wal_log_hints = off # also do full page writes of non-critical updates + # (change requires restart) +#wal_compression = off # enables compression of full-page writes; + # off, pglz, lz4, zstd, or on +#wal_init_zero = on # zero-fill new WAL files +#wal_recycle = on # recycle WAL files +#wal_buffers = -1 # min 32kB, -1 sets based on shared_buffers + # (change requires restart) +#wal_writer_delay = 200ms # 1-10000 milliseconds +#wal_writer_flush_after = 1MB # measured in pages, 0 disables +#wal_skip_threshold = 2MB + +#commit_delay = 0 # range 0-100000, in microseconds +#commit_siblings = 5 # range 1-1000 + +# - Checkpoints - + +#checkpoint_timeout = 5min # range 30s-1d +#checkpoint_completion_target = 0.9 # checkpoint target duration, 0.0 - 1.0 +#checkpoint_flush_after = 256kB # measured in pages, 0 disables +#checkpoint_warning = 30s # 0 disables +max_wal_size = 1GB +min_wal_size = 80MB + +# - Prefetching during recovery - + +#recovery_prefetch = try # prefetch pages referenced in the WAL? +#wal_decode_buffer_size = 512kB # lookahead window used for prefetching + # (change requires restart) + +# - Archiving - + +#archive_mode = off # enables archiving; off, on, or always + # (change requires restart) +#archive_library = '' # library to use to archive a logfile segment + # (empty string indicates archive_command should + # be used) +#archive_command = '' # command to use to archive a logfile segment + # placeholders: %p = path of file to archive + # %f = file name only + # e.g. 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f' +#archive_timeout = 0 # force a logfile segment switch after this + # number of seconds; 0 disables + +# - Archive Recovery - + +# These are only used in recovery mode. + +#restore_command = '' # command to use to restore an archived logfile segment + # placeholders: %p = path of file to restore + # %f = file name only + # e.g. 'cp /mnt/server/archivedir/%f %p' +#archive_cleanup_command = '' # command to execute at every restartpoint +#recovery_end_command = '' # command to execute at completion of recovery + +# - Recovery Target - + +# Set these only when performing a targeted recovery. + +#recovery_target = '' # 'immediate' to end recovery as soon as a + # consistent state is reached + # (change requires restart) +#recovery_target_name = '' # the named restore point to which recovery will proceed + # (change requires restart) +#recovery_target_time = '' # the time stamp up to which recovery will proceed + # (change requires restart) +#recovery_target_xid = '' # the transaction ID up to which recovery will proceed + # (change requires restart) +#recovery_target_lsn = '' # the WAL LSN up to which recovery will proceed + # (change requires restart) +#recovery_target_inclusive = on # Specifies whether to stop: + # just after the specified recovery target (on) + # just before the recovery target (off) + # (change requires restart) +#recovery_target_timeline = 'latest' # 'current', 'latest', or timeline ID + # (change requires restart) +#recovery_target_action = 'pause' # 'pause', 'promote', 'shutdown' + # (change requires restart) + + +#------------------------------------------------------------------------------ +# REPLICATION +#------------------------------------------------------------------------------ + +# - Sending Servers - + +# Set these on the primary and on any standby that will send replication data. + +#max_wal_senders = 10 # max number of walsender processes + # (change requires restart) +#max_replication_slots = 10 # max number of replication slots + # (change requires restart) +#wal_keep_size = 0 # in megabytes; 0 disables +#max_slot_wal_keep_size = -1 # in megabytes; -1 disables +#wal_sender_timeout = 60s # in milliseconds; 0 disables +#track_commit_timestamp = off # collect timestamp of transaction commit + # (change requires restart) + +# - Primary Server - + +# These settings are ignored on a standby server. + +#synchronous_standby_names = '' # standby servers that provide sync rep + # method to choose sync standbys, number of sync standbys, + # and comma-separated list of application_name + # from standby(s); '*' = all +#vacuum_defer_cleanup_age = 0 # number of xacts by which cleanup is delayed + +# - Standby Servers - + +# These settings are ignored on a primary server. + +#primary_conninfo = '' # connection string to sending server +#primary_slot_name = '' # replication slot on sending server +#promote_trigger_file = '' # file name whose presence ends recovery +#hot_standby = on # "off" disallows queries during recovery + # (change requires restart) +#max_standby_archive_delay = 30s # max delay before canceling queries + # when reading WAL from archive; + # -1 allows indefinite delay +#max_standby_streaming_delay = 30s # max delay before canceling queries + # when reading streaming WAL; + # -1 allows indefinite delay +#wal_receiver_create_temp_slot = off # create temp slot if primary_slot_name + # is not set +#wal_receiver_status_interval = 10s # send replies at least this often + # 0 disables +#hot_standby_feedback = off # send info from standby to prevent + # query conflicts +#wal_receiver_timeout = 60s # time that receiver waits for + # communication from primary + # in milliseconds; 0 disables +#wal_retrieve_retry_interval = 5s # time to wait before retrying to + # retrieve WAL after a failed attempt +#recovery_min_apply_delay = 0 # minimum delay for applying changes during recovery + +# - Subscribers - + +# These settings are ignored on a publisher. + +#max_logical_replication_workers = 4 # taken from max_worker_processes + # (change requires restart) +#max_sync_workers_per_subscription = 2 # taken from max_logical_replication_workers + + +#------------------------------------------------------------------------------ +# QUERY TUNING +#------------------------------------------------------------------------------ + +# - Planner Method Configuration - + +#enable_async_append = on +#enable_bitmapscan = on +#enable_gathermerge = on +#enable_hashagg = on +#enable_hashjoin = on +#enable_incremental_sort = on +#enable_indexscan = on +#enable_indexonlyscan = on +#enable_material = on +#enable_memoize = on +#enable_mergejoin = on +#enable_nestloop = on +#enable_parallel_append = on +#enable_parallel_hash = on +#enable_partition_pruning = on +#enable_partitionwise_join = off +#enable_partitionwise_aggregate = off +#enable_seqscan = on +#enable_sort = on +#enable_tidscan = on + +# - Planner Cost Constants - + +#seq_page_cost = 1.0 # measured on an arbitrary scale +#random_page_cost = 4.0 # same scale as above +#cpu_tuple_cost = 0.01 # same scale as above +#cpu_index_tuple_cost = 0.005 # same scale as above +#cpu_operator_cost = 0.0025 # same scale as above +#parallel_setup_cost = 1000.0 # same scale as above +#parallel_tuple_cost = 0.1 # same scale as above +#min_parallel_table_scan_size = 8MB +#min_parallel_index_scan_size = 512kB +#effective_cache_size = 4GB + +#jit_above_cost = 100000 # perform JIT compilation if available + # and query more expensive than this; + # -1 disables +#jit_inline_above_cost = 500000 # inline small functions if query is + # more expensive than this; -1 disables +#jit_optimize_above_cost = 500000 # use expensive JIT optimizations if + # query is more expensive than this; + # -1 disables + +# - Genetic Query Optimizer - + +#geqo = on +#geqo_threshold = 12 +#geqo_effort = 5 # range 1-10 +#geqo_pool_size = 0 # selects default based on effort +#geqo_generations = 0 # selects default based on effort +#geqo_selection_bias = 2.0 # range 1.5-2.0 +#geqo_seed = 0.0 # range 0.0-1.0 + +# - Other Planner Options - + +#default_statistics_target = 100 # range 1-10000 +#constraint_exclusion = partition # on, off, or partition +#cursor_tuple_fraction = 0.1 # range 0.0-1.0 +#from_collapse_limit = 8 +#jit = on # allow JIT compilation +#join_collapse_limit = 8 # 1 disables collapsing of explicit + # JOIN clauses +#plan_cache_mode = auto # auto, force_generic_plan or + # force_custom_plan +#recursive_worktable_factor = 10.0 # range 0.001-1000000 + + +#------------------------------------------------------------------------------ +# REPORTING AND LOGGING +#------------------------------------------------------------------------------ + +# - Where to Log - + +#log_destination = 'stderr' # Valid values are combinations of + # stderr, csvlog, jsonlog, syslog, and + # eventlog, depending on platform. + # csvlog and jsonlog require + # logging_collector to be on. + +# This is used when logging to stderr: +#logging_collector = off # Enable capturing of stderr, jsonlog, + # and csvlog into log files. Required + # to be on for csvlogs and jsonlogs. + # (change requires restart) + +# These are only used if logging_collector is on: +#log_directory = 'log' # directory where log files are written, + # can be absolute or relative to PGDATA +#log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # log file name pattern, + # can include strftime() escapes +#log_file_mode = 0600 # creation mode for log files, + # begin with 0 to use octal notation +#log_rotation_age = 1d # Automatic rotation of logfiles will + # happen after that time. 0 disables. +#log_rotation_size = 10MB # Automatic rotation of logfiles will + # happen after that much log output. + # 0 disables. +#log_truncate_on_rotation = off # If on, an existing log file with the + # same name as the new log file will be + # truncated rather than appended to. + # But such truncation only occurs on + # time-driven rotation, not on restarts + # or size-driven rotation. Default is + # off, meaning append to existing files + # in all cases. + +# These are relevant when logging to syslog: +#syslog_facility = 'LOCAL0' +#syslog_ident = 'postgres' +#syslog_sequence_numbers = on +#syslog_split_messages = on + +# This is only relevant when logging to eventlog (Windows): +# (change requires restart) +#event_source = 'PostgreSQL' + +# - When to Log - + +#log_min_messages = warning # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic + +#log_min_error_statement = error # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # info + # notice + # warning + # error + # log + # fatal + # panic (effectively off) + +#log_min_duration_statement = -1 # -1 is disabled, 0 logs all statements + # and their durations, > 0 logs only + # statements running at least this number + # of milliseconds + +#log_min_duration_sample = -1 # -1 is disabled, 0 logs a sample of statements + # and their durations, > 0 logs only a sample of + # statements running at least this number + # of milliseconds; + # sample fraction is determined by log_statement_sample_rate + +#log_statement_sample_rate = 1.0 # fraction of logged statements exceeding + # log_min_duration_sample to be logged; + # 1.0 logs all such statements, 0.0 never logs + + +#log_transaction_sample_rate = 0.0 # fraction of transactions whose statements + # are logged regardless of their duration; 1.0 logs all + # statements from all transactions, 0.0 never logs + +#log_startup_progress_interval = 10s # Time between progress updates for + # long-running startup operations. + # 0 disables the feature, > 0 indicates + # the interval in milliseconds. + +# - What to Log - + +#debug_print_parse = off +#debug_print_rewritten = off +#debug_print_plan = off +#debug_pretty_print = on +#log_autovacuum_min_duration = 10min # log autovacuum activity; + # -1 disables, 0 logs all actions and + # their durations, > 0 logs only + # actions running at least this number + # of milliseconds. +#log_checkpoints = on +#log_connections = off +#log_disconnections = off +#log_duration = off +#log_error_verbosity = default # terse, default, or verbose messages +#log_hostname = off +log_line_prefix = '%m [%p] %q%u@%d ' # special values: + # %a = application name + # %u = user name + # %d = database name + # %r = remote host and port + # %h = remote host + # %b = backend type + # %p = process ID + # %P = process ID of parallel group leader + # %t = timestamp without milliseconds + # %m = timestamp with milliseconds + # %n = timestamp with milliseconds (as a Unix epoch) + # %Q = query ID (0 if none or not computed) + # %i = command tag + # %e = SQL state + # %c = session ID + # %l = session line number + # %s = session start timestamp + # %v = virtual transaction ID + # %x = transaction ID (0 if none) + # %q = stop here in non-session + # processes + # %% = '%' + # e.g. '<%u%%%d> ' +#log_lock_waits = off # log lock waits >= deadlock_timeout +#log_recovery_conflict_waits = off # log standby recovery conflict waits + # >= deadlock_timeout +#log_parameter_max_length = -1 # when logging statements, limit logged + # bind-parameter values to N bytes; + # -1 means print in full, 0 disables +#log_parameter_max_length_on_error = 0 # when logging an error, limit logged + # bind-parameter values to N bytes; + # -1 means print in full, 0 disables +#log_statement = 'none' # none, ddl, mod, all +#log_replication_commands = off +#log_temp_files = -1 # log temporary files equal or larger + # than the specified size in kilobytes; + # -1 disables, 0 logs all temp files +log_timezone = 'Etc/UTC' + + +#------------------------------------------------------------------------------ +# PROCESS TITLE +#------------------------------------------------------------------------------ + +cluster_name = '15/main' # added to process titles if nonempty + # (change requires restart) +#update_process_title = on + + +#------------------------------------------------------------------------------ +# STATISTICS +#------------------------------------------------------------------------------ + +# - Cumulative Query and Index Statistics - + +#track_activities = on +#track_activity_query_size = 1024 # (change requires restart) +#track_counts = on +#track_io_timing = off +#track_wal_io_timing = off +#track_functions = none # none, pl, all +#stats_fetch_consistency = cache + + +# - Monitoring - + +#compute_query_id = auto +#log_statement_stats = off +#log_parser_stats = off +#log_planner_stats = off +#log_executor_stats = off + + +#------------------------------------------------------------------------------ +# AUTOVACUUM +#------------------------------------------------------------------------------ + +#autovacuum = on # Enable autovacuum subprocess? 'on' + # requires track_counts to also be on. +#autovacuum_max_workers = 3 # max number of autovacuum subprocesses + # (change requires restart) +#autovacuum_naptime = 1min # time between autovacuum runs +#autovacuum_vacuum_threshold = 50 # min number of row updates before + # vacuum +#autovacuum_vacuum_insert_threshold = 1000 # min number of row inserts + # before vacuum; -1 disables insert + # vacuums +#autovacuum_analyze_threshold = 50 # min number of row updates before + # analyze +#autovacuum_vacuum_scale_factor = 0.2 # fraction of table size before vacuum +#autovacuum_vacuum_insert_scale_factor = 0.2 # fraction of inserts over table + # size before insert vacuum +#autovacuum_analyze_scale_factor = 0.1 # fraction of table size before analyze +#autovacuum_freeze_max_age = 200000000 # maximum XID age before forced vacuum + # (change requires restart) +#autovacuum_multixact_freeze_max_age = 400000000 # maximum multixact age + # before forced vacuum + # (change requires restart) +#autovacuum_vacuum_cost_delay = 2ms # default vacuum cost delay for + # autovacuum, in milliseconds; + # -1 means use vacuum_cost_delay +#autovacuum_vacuum_cost_limit = -1 # default vacuum cost limit for + # autovacuum, -1 means use + # vacuum_cost_limit + + +#------------------------------------------------------------------------------ +# CLIENT CONNECTION DEFAULTS +#------------------------------------------------------------------------------ + +# - Statement Behavior - + +#client_min_messages = notice # values in order of decreasing detail: + # debug5 + # debug4 + # debug3 + # debug2 + # debug1 + # log + # notice + # warning + # error +#search_path = '"$user", public' # schema names +#row_security = on +#default_table_access_method = 'heap' +#default_tablespace = '' # a tablespace name, '' uses the default +#default_toast_compression = 'pglz' # 'pglz' or 'lz4' +#temp_tablespaces = '' # a list of tablespace names, '' uses + # only default tablespace +#check_function_bodies = on +#default_transaction_isolation = 'read committed' +#default_transaction_read_only = off +#default_transaction_deferrable = off +#session_replication_role = 'origin' +#statement_timeout = 0 # in milliseconds, 0 is disabled +#lock_timeout = 0 # in milliseconds, 0 is disabled +#idle_in_transaction_session_timeout = 0 # in milliseconds, 0 is disabled +#idle_session_timeout = 0 # in milliseconds, 0 is disabled +#vacuum_freeze_table_age = 150000000 +#vacuum_freeze_min_age = 50000000 +#vacuum_failsafe_age = 1600000000 +#vacuum_multixact_freeze_table_age = 150000000 +#vacuum_multixact_freeze_min_age = 5000000 +#vacuum_multixact_failsafe_age = 1600000000 +#bytea_output = 'hex' # hex, escape +#xmlbinary = 'base64' +#xmloption = 'content' +#gin_pending_list_limit = 4MB + +# - Locale and Formatting - + +datestyle = 'iso, mdy' +#intervalstyle = 'postgres' +timezone = 'Etc/UTC' +#timezone_abbreviations = 'Default' # Select the set of available time zone + # abbreviations. Currently, there are + # Default + # Australia (historical usage) + # India + # You can create your own file in + # share/timezonesets/. +#extra_float_digits = 1 # min -15, max 3; any value >0 actually + # selects precise output mode +#client_encoding = sql_ascii # actually, defaults to database + # encoding + +# These settings are initialized by initdb, but they can be changed. +lc_messages = 'C.UTF-8' # locale for system error message + # strings +lc_monetary = 'C.UTF-8' # locale for monetary formatting +lc_numeric = 'C.UTF-8' # locale for number formatting +lc_time = 'C.UTF-8' # locale for time formatting + +# default configuration for text search +default_text_search_config = 'pg_catalog.english' + +# - Shared Library Preloading - + +#local_preload_libraries = '' +#session_preload_libraries = '' +#shared_preload_libraries = '' # (change requires restart) +#jit_provider = 'llvmjit' # JIT library to use + +# - Other Defaults - + +#dynamic_library_path = '$libdir' +#extension_destdir = '' # prepend path when loading extensions + # and shared objects (added by Debian) +#gin_fuzzy_search_limit = 0 + + +#------------------------------------------------------------------------------ +# LOCK MANAGEMENT +#------------------------------------------------------------------------------ + +#deadlock_timeout = 1s +#max_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_transaction = 64 # min 10 + # (change requires restart) +#max_pred_locks_per_relation = -2 # negative values mean + # (max_pred_locks_per_transaction + # / -max_pred_locks_per_relation) - 1 +#max_pred_locks_per_page = 2 # min 0 + + +#------------------------------------------------------------------------------ +# VERSION AND PLATFORM COMPATIBILITY +#------------------------------------------------------------------------------ + +# - Previous PostgreSQL Versions - + +#array_nulls = on +#backslash_quote = safe_encoding # on, off, or safe_encoding +#escape_string_warning = on +#lo_compat_privileges = off +#quote_all_identifiers = off +#standard_conforming_strings = on +#synchronize_seqscans = on + +# - Other Platforms and Clients - + +#transform_null_equals = off + + +#------------------------------------------------------------------------------ +# ERROR HANDLING +#------------------------------------------------------------------------------ + +#exit_on_error = off # terminate session on any error? +#restart_after_crash = on # reinitialize after backend crash? +#data_sync_retry = off # retry or panic on failure to fsync + # data? + # (change requires restart) +#recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+) + + +#------------------------------------------------------------------------------ +# CONFIG FILE INCLUDES +#------------------------------------------------------------------------------ + +# These options allow settings to be loaded from files other than the +# default postgresql.conf. Note that these are directives, not variable +# assignments, so they can usefully be given more than once. + +# include_dir = 'conf.d' # include files ending in '.conf' from + # a directory, e.g., 'conf.d' +#include_if_exists = '...' # include file only if it exists +#include = '...' # include file + + +#------------------------------------------------------------------------------ +# CUSTOMIZED OPTIONS +#------------------------------------------------------------------------------ + +# Add settings for extensions here diff --git a/aio/supervisord-app b/aio/supervisord-app new file mode 100644 index 000000000..e2cf1f047 --- /dev/null +++ b/aio/supervisord-app @@ -0,0 +1,71 @@ + +[program:web] +command=node /app/web/web/server.js +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3001,HOSTNAME=0.0.0.0 + +[program:space] +command=node /app/space/space/server.js +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3002,HOSTNAME=0.0.0.0 + +[program:admin] +command=node /app/admin/admin/server.js +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +environment=PORT=3003,HOSTNAME=0.0.0.0 + +[program:migrator] +directory=/app/api +command=sh -c "./bin/docker-entrypoint-migrator.sh" +autostart=true +autorestart=false +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:api] +directory=/app/api +command=sh -c "./bin/docker-entrypoint-api.sh" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:worker] +directory=/app/api +command=sh -c "./bin/docker-entrypoint-worker.sh" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + +[program:beat] +directory=/app/api +command=sh -c "./bin/docker-entrypoint-beat.sh" +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 + diff --git a/aio/supervisord.base b/aio/supervisord-full-base similarity index 68% rename from aio/supervisord.base rename to aio/supervisord-full-base index fe6a76e41..0a6c27e13 100644 --- a/aio/supervisord.base +++ b/aio/supervisord-full-base @@ -2,7 +2,7 @@ user=root nodaemon=true stderr_logfile=/app/logs/error/supervisor.err.log -stdout_logfile=/app/logs/access/supervisor.out.log +stdout_logfile=/app/logs/access/supervisor.log [program:redis] directory=/app/data/redis @@ -10,15 +10,15 @@ command=redis-server autostart=true autorestart=true stderr_logfile=/app/logs/error/redis.err.log -stdout_logfile=/app/logs/access/redis.out.log +stdout_logfile=/app/logs/access/redis.log [program:postgresql] user=postgres -command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/15/main/postgresql.conf +command=/usr/lib/postgresql/15/bin/postgres --config-file=/etc/postgresql/postgresql.conf autostart=true autorestart=true stderr_logfile=/app/logs/error/postgresql.err.log -stdout_logfile=/app/logs/access/postgresql.out.log +stdout_logfile=/app/logs/access/postgresql.log [program:minio] directory=/app/data/minio @@ -26,12 +26,13 @@ command=minio server /app/data/minio autostart=true autorestart=true stderr_logfile=/app/logs/error/minio.err.log -stdout_logfile=/app/logs/access/minio.out.log +stdout_logfile=/app/logs/access/minio.log [program:nginx] directory=/app/data/nginx -command=/usr/sbin/nginx -g 'daemon off;' +command=/app/nginx-start.sh autostart=true autorestart=true stderr_logfile=/app/logs/error/nginx.err.log -stdout_logfile=/app/logs/access/nginx.out.log +stdout_logfile=/app/logs/access/nginx.log + diff --git a/aio/supervisord-slim-base b/aio/supervisord-slim-base new file mode 100644 index 000000000..24509216e --- /dev/null +++ b/aio/supervisord-slim-base @@ -0,0 +1,14 @@ +[supervisord] +user=root +nodaemon=true +stderr_logfile=/app/logs/error/supervisor.err.log +stdout_logfile=/app/logs/access/supervisor.log + +[program:nginx] +directory=/app/data/nginx +command=/app/nginx-start.sh +autostart=true +autorestart=true +stderr_logfile=/app/logs/error/nginx.err.log +stdout_logfile=/app/logs/access/nginx.log + diff --git a/aio/supervisord.conf b/aio/supervisord.conf deleted file mode 100644 index 46ef1b4fa..000000000 --- a/aio/supervisord.conf +++ /dev/null @@ -1,115 +0,0 @@ -[supervisord] -user=root -nodaemon=true -priority=1 -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:redis] -directory=/app/data/redis -command=redis-server -autostart=true -autorestart=true -priority=1 -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:postgresql] -user=postgres -command=/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/data --config-file=/etc/postgresql/postgresql.conf -autostart=true -autorestart=true -priority=1 -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:minio] -directory=/app/data/minio -command=minio server /app/data/minio -autostart=true -autorestart=true -priority=1 -stdout_logfile=/app/logs/access/minio.log -stderr_logfile=/app/logs/error/minio.err.log - -[program:nginx] -command=/app/nginx-start.sh -autostart=true -autorestart=true -priority=1 -stdout_logfile=/app/logs/access/nginx.log -stderr_logfile=/app/logs/error/nginx.err.log - - -[program:web] -command=/app/aio.sh web -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 -environment=PORT=3001,HOSTNAME=0.0.0.0 - -[program:space] -command=/app/aio.sh space -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 -environment=PORT=3002,HOSTNAME=0.0.0.0 - -[program:admin] -command=/app/aio.sh admin -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 -environment=PORT=3003,HOSTNAME=0.0.0.0 - -[program:migrator] -command=/app/aio.sh migrator -autostart=true -autorestart=false -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:api] -command=/app/aio.sh api -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:worker] -command=/app/aio.sh worker -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - -[program:beat] -command=/app/aio.sh beat -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stdout -stderr_logfile_maxbytes=0 - diff --git a/apiserver/bin/docker-entrypoint-beat.sh b/apiserver/bin/docker-entrypoint-beat.sh old mode 100644 new mode 100755 diff --git a/apiserver/bin/docker-entrypoint-migrator.sh b/apiserver/bin/docker-entrypoint-migrator.sh old mode 100644 new mode 100755 diff --git a/apiserver/package.json b/apiserver/package.json index ecaf1194a..9bf1c232a 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.21.0" + "version": "0.22.0" } diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 6e1e5e057..a95a4d5f4 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -3,9 +3,18 @@ import json # Django imports from django.core import serializers -from django.db.models import Count, F, Func, OuterRef, Q, Sum from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Count, + F, + Func, + OuterRef, + Q, + Sum, + FloatField, +) +from django.db.models.functions import Cast # Third party imports from rest_framework import status @@ -22,6 +31,7 @@ from plane.db.models import ( Cycle, CycleIssue, Issue, + Project, IssueAttachment, IssueLink, ) @@ -115,29 +125,6 @@ class CycleAPIEndpoint(BaseAPIView): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) @@ -791,9 +778,9 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.get( + new_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ) + ).first() old_cycle = ( Cycle.objects.filter( @@ -860,17 +847,131 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ) ) - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle.first(), - slug=slug, - project_id=project_id, - cycle_id=cycle_id, - ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + if estimate_type: + assignee_estimate_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) + if item["assignee_id"] + else None + ), + "avatar": item["avatar"], + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + label_distribution_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in label_distribution_data + ] # Get the assignee distribution assignee_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, workspace__slug=slug, project_id=project_id, @@ -882,7 +983,10 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -924,7 +1028,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): # Get the label distribution label_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, workspace__slug=slug, project_id=project_id, @@ -936,8 +1040,11 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -977,26 +1084,42 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): for item in label_distribution ] + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + current_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=cycle_id ).first() - if current_cycle: - current_cycle.progress_snapshot = { - "total_issues": old_cycle.first().total_issues, - "completed_issues": old_cycle.first().completed_issues, - "cancelled_issues": old_cycle.first().cancelled_issues, - "started_issues": old_cycle.first().started_issues, - "unstarted_issues": old_cycle.first().unstarted_issues, - "backlog_issues": old_cycle.first().backlog_issues, - "distribution": { - "labels": label_distribution_data, - "assignees": assignee_distribution_data, - "completion_chart": completion_chart, - }, - } - # Save the snapshot of the current cycle - current_cycle.save(update_fields=["progress_snapshot"]) + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "distribution": { + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), + } + current_cycle.save(update_fields=["progress_snapshot"]) if ( new_cycle.end_date is not None diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 8987e4f63..43c9d5652 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -149,7 +149,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): description_html=request.data.get("issue", {}).get( "description_html", "

" ), - priority=request.data.get("issue", {}).get("priority", "low"), + priority=request.data.get("issue", {}).get("priority", "none"), project_id=project_id, state=state, ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 019ab704e..408e14fed 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -22,7 +22,7 @@ from plane.db.models import ( IssueProperty, Module, Project, - ProjectDeployBoard, + DeployBoard, ProjectMember, State, Workspace, @@ -99,7 +99,7 @@ class ProjectAPIEndpoint(BaseAPIView): ) .annotate( is_deployed=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index bdcdf6c0d..0511f315e 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -30,14 +30,13 @@ from .project import ( ProjectIdentifierSerializer, ProjectLiteSerializer, ProjectMemberLiteSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ProjectMemberAdminSerializer, ProjectPublicMemberSerializer, ProjectMemberRoleSerializer, ) from .state import StateSerializer, StateLiteSerializer from .view import ( - GlobalViewSerializer, IssueViewSerializer, ) from .cycle import ( diff --git a/apiserver/plane/app/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py index d28f38c75..8cb083ca5 100644 --- a/apiserver/plane/app/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -2,19 +2,11 @@ from .base import BaseSerializer from plane.db.models import Estimate, EstimatePoint -from plane.app.serializers import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, -) from rest_framework import serializers class EstimateSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = Estimate @@ -48,10 +40,6 @@ class EstimatePointSerializer(BaseSerializer): class EstimateReadSerializer(BaseSerializer): points = EstimatePointSerializer(read_only=True, many=True) - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = Estimate diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 28d28d7db..222c95150 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -177,6 +177,8 @@ class ModuleSerializer(DynamicBaseSerializer): started_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) + total_estimate_points = serializers.FloatField(read_only=True) + completed_estimate_points = serializers.FloatField(read_only=True) class Meta: model = Module @@ -201,6 +203,8 @@ class ModuleSerializer(DynamicBaseSerializer): "external_id", "logo_props", # computed fields + "total_estimate_points", + "completed_estimate_points", "is_favorite", "total_issues", "cancelled_issues", @@ -218,9 +222,13 @@ class ModuleSerializer(DynamicBaseSerializer): class ModuleDetailSerializer(ModuleSerializer): link_module = ModuleLinkSerializer(read_only=True, many=True) sub_issues = serializers.IntegerField(read_only=True) + backlog_estimate_points = serializers.FloatField(read_only=True) + unstarted_estimate_points = serializers.FloatField(read_only=True) + started_estimate_points = serializers.FloatField(read_only=True) + cancelled_estimate_points = serializers.FloatField(read_only=True) class Meta(ModuleSerializer.Meta): - fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"] + fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"] class ModuleUserPropertiesSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/notification.py b/apiserver/plane/app/serializers/notification.py index c6713a354..248441bc8 100644 --- a/apiserver/plane/app/serializers/notification.py +++ b/apiserver/plane/app/serializers/notification.py @@ -3,11 +3,15 @@ from .base import BaseSerializer from .user import UserLiteSerializer from plane.db.models import Notification, UserNotificationPreference +# Third Party imports +from rest_framework import serializers + class NotificationSerializer(BaseSerializer): triggered_by_details = UserLiteSerializer( read_only=True, source="triggered_by" ) + is_inbox_issue = serializers.BooleanField(read_only=True) class Meta: model = Notification diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index f13923831..6e38537f0 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -8,6 +8,8 @@ from plane.db.models import ( PageLog, PageLabel, Label, + ProjectPage, + Project, ) @@ -18,6 +20,15 @@ class PageSerializer(BaseSerializer): write_only=True, required=False, ) + # Many to many + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + project_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) class Meta: model = Page @@ -33,44 +44,53 @@ class PageSerializer(BaseSerializer): "is_locked", "archived_at", "workspace", - "project", "created_at", "updated_at", "created_by", "updated_by", "view_props", "logo_props", + "label_ids", + "project_ids", ] read_only_fields = [ "workspace", - "project", "owned_by", ] - def to_representation(self, instance): - data = super().to_representation(instance) - data["labels"] = [str(label.id) for label in instance.labels.all()] - return data - def create(self, validated_data): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] description_html = self.context["description_html"] + + # Get the workspace id from the project + project = Project.objects.get(pk=project_id) + + # Create the page page = Page.objects.create( **validated_data, description_html=description_html, - project_id=project_id, owned_by_id=owned_by_id, + workspace_id=project.workspace_id, ) + # Create the project page + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + # Create page labels if labels is not None: PageLabel.objects.bulk_create( [ PageLabel( label=label, page=page, - project_id=project_id, workspace_id=page.workspace_id, created_by_id=page.created_by_id, updated_by_id=page.updated_by_id, @@ -90,7 +110,6 @@ class PageSerializer(BaseSerializer): PageLabel( label=label, page=instance, - project_id=instance.project_id, workspace_id=instance.workspace_id, created_by_id=instance.created_by_id, updated_by_id=instance.updated_by_id, @@ -120,7 +139,6 @@ class SubPageSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project", "page", ] @@ -141,6 +159,5 @@ class PageLogSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "workspace", - "project", "page", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 96d92f340..1bbc580c1 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -13,7 +13,7 @@ from plane.db.models import ( ProjectMember, ProjectMemberInvite, ProjectIdentifier, - ProjectDeployBoard, + DeployBoard, ProjectPublicMember, ) @@ -114,7 +114,7 @@ class ProjectListSerializer(DynamicBaseSerializer): is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) + anchor = serializers.CharField(read_only=True) members = serializers.SerializerMethodField() def get_members(self, obj): @@ -148,7 +148,7 @@ class ProjectDetailSerializer(BaseSerializer): is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) - is_deployed = serializers.BooleanField(read_only=True) + anchor = serializers.CharField(read_only=True) class Meta: model = Project @@ -206,14 +206,14 @@ class ProjectMemberLiteSerializer(BaseSerializer): read_only_fields = fields -class ProjectDeployBoardSerializer(BaseSerializer): +class DeployBoardSerializer(BaseSerializer): project_details = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer( read_only=True, source="workspace" ) class Meta: - model = ProjectDeployBoard + model = DeployBoard fields = "__all__" read_only_fields = [ "workspace", diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py index c46a545d0..6b08ae80b 100644 --- a/apiserver/plane/app/serializers/view.py +++ b/apiserver/plane/app/serializers/view.py @@ -2,50 +2,13 @@ from rest_framework import serializers # Module imports -from .base import BaseSerializer, DynamicBaseSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer -from plane.db.models import GlobalView, IssueView +from .base import DynamicBaseSerializer +from plane.db.models import IssueView from plane.utils.issue_filters import issue_filters -class GlobalViewSerializer(BaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) - - class Meta: - model = GlobalView - fields = "__all__" - read_only_fields = [ - "workspace", - "query", - ] - - def create(self, validated_data): - query_params = validated_data.get("query_data", {}) - if bool(query_params): - validated_data["query"] = issue_filters(query_params, "POST") - else: - validated_data["query"] = dict() - return GlobalView.objects.create(**validated_data) - - def update(self, instance, validated_data): - query_params = validated_data.get("query_data", {}) - if bool(query_params): - validated_data["query"] = issue_filters(query_params, "POST") - else: - validated_data["query"] = dict() - validated_data["query"] = issue_filters(query_params, "PATCH") - return super().update(instance, validated_data) - - class IssueViewSerializer(DynamicBaseSerializer): is_favorite = serializers.BooleanField(read_only=True) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) class Meta: model = IssueView @@ -54,6 +17,9 @@ class IssueViewSerializer(DynamicBaseSerializer): "workspace", "project", "query", + "owned_by", + "access", + "is_locked", ] def create(self, validated_data): diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 69f827c24..96ee7dce3 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -15,6 +15,7 @@ from plane.db.models import ( WorkspaceTheme, WorkspaceUserProperties, ) +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS class WorkSpaceSerializer(DynamicBaseSerializer): @@ -22,22 +23,11 @@ class WorkSpaceSerializer(DynamicBaseSerializer): total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) - def validated(self, data): - if data.get("slug") in [ - "404", - "accounts", - "api", - "create-workspace", - "god-mode", - "installations", - "invitations", - "onboarding", - "profile", - "spaces", - "workspace-invitations", - "password", - ]: - raise serializers.ValidationError({"slug": "Slug is not valid"}) + def validate_slug(self, value): + # Check if the slug is restricted + if value in RESTRICTED_WORKSPACE_SLUGS: + raise serializers.ValidationError("Slug is not valid") + return value class Meta: model = Workspace diff --git a/apiserver/plane/app/urls/estimate.py b/apiserver/plane/app/urls/estimate.py index d8571ff0c..7db94aa46 100644 --- a/apiserver/plane/app/urls/estimate.py +++ b/apiserver/plane/app/urls/estimate.py @@ -4,6 +4,7 @@ from django.urls import path from plane.app.views import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, + EstimatePointEndpoint, ) @@ -34,4 +35,23 @@ urlpatterns = [ ), name="bulk-create-estimate-points", ), + path( + "workspaces//projects//estimates//estimate-points/", + EstimatePointEndpoint.as_view( + { + "post": "create", + } + ), + name="estimate-points", + ), + path( + "workspaces//projects//estimates//estimate-points//", + EstimatePointEndpoint.as_view( + { + "patch": "partial_update", + "delete": "destroy", + } + ), + name="estimate-points", + ), ] diff --git a/apiserver/plane/app/urls/external.py b/apiserver/plane/app/urls/external.py index 8db87a249..744c646ca 100644 --- a/apiserver/plane/app/urls/external.py +++ b/apiserver/plane/app/urls/external.py @@ -2,7 +2,7 @@ from django.urls import path from plane.app.views import UnsplashEndpoint -from plane.app.views import GPTIntegrationEndpoint +from plane.app.views import GPTIntegrationEndpoint, WorkspaceGPTIntegrationEndpoint urlpatterns = [ @@ -16,4 +16,9 @@ urlpatterns = [ GPTIntegrationEndpoint.as_view(), name="importer", ), + path( + "workspaces//ai-assistant/", + WorkspaceGPTIntegrationEndpoint.as_view(), + name="importer", + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 0d3b9e063..b7a4eaa48 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -19,6 +19,8 @@ from plane.app.views import ( IssueUserDisplayPropertyEndpoint, IssueViewSet, LabelViewSet, + BulkIssueOperationsEndpoint, + BulkArchiveIssuesEndpoint, ) urlpatterns = [ @@ -81,6 +83,11 @@ urlpatterns = [ BulkDeleteIssuesEndpoint.as_view(), name="project-issues-bulk", ), + path( + "workspaces//projects//bulk-archive-issues/", + BulkArchiveIssuesEndpoint.as_view(), + name="bulk-archive-issues", + ), ## path( "workspaces//projects//issues//sub-issues/", @@ -298,4 +305,9 @@ urlpatterns = [ ), name="project-issue-draft", ), + path( + "workspaces//projects//bulk-operation-issues/", + BulkIssueOperationsEndpoint.as_view(), + name="bulk-operations-issues", + ), ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 7ea636df8..0807c7616 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -2,6 +2,7 @@ from django.urls import path from plane.app.views import ( ProjectViewSet, + DeployBoardViewSet, ProjectInvitationsViewset, ProjectMemberViewSet, ProjectMemberUserEndpoint, @@ -12,7 +13,6 @@ from plane.app.views import ( ProjectFavoritesViewSet, UserProjectInvitationsViewset, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, ) @@ -157,7 +157,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards/", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "list", "post": "create", @@ -167,7 +167,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards//", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "retrieve", "patch": "partial_update", diff --git a/apiserver/plane/app/urls/views.py b/apiserver/plane/app/urls/views.py index 36372c03a..a2f8e2ac8 100644 --- a/apiserver/plane/app/urls/views.py +++ b/apiserver/plane/app/urls/views.py @@ -3,8 +3,8 @@ from django.urls import path from plane.app.views import ( IssueViewViewSet, - GlobalViewViewSet, - GlobalViewIssuesViewSet, + WorkspaceViewViewSet, + WorkspaceViewIssuesViewSet, IssueViewFavoriteViewSet, ) @@ -34,7 +34,7 @@ urlpatterns = [ ), path( "workspaces//views/", - GlobalViewViewSet.as_view( + WorkspaceViewViewSet.as_view( { "get": "list", "post": "create", @@ -44,7 +44,7 @@ urlpatterns = [ ), path( "workspaces//views//", - GlobalViewViewSet.as_view( + WorkspaceViewViewSet.as_view( { "get": "retrieve", "put": "update", @@ -56,7 +56,7 @@ urlpatterns = [ ), path( "workspaces//issues/", - GlobalViewIssuesViewSet.as_view( + WorkspaceViewIssuesViewSet.as_view( { "get": "list", } diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0c489593d..913385fa6 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -4,7 +4,7 @@ from .project.base import ( ProjectUserViewsEndpoint, ProjectFavoritesViewSet, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, + DeployBoardViewSet, ProjectArchiveUnarchiveEndpoint, ) @@ -80,8 +80,8 @@ from .workspace.cycle import ( from .state.base import StateViewSet from .view.base import ( - GlobalViewViewSet, - GlobalViewIssuesViewSet, + WorkspaceViewViewSet, + WorkspaceViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet, ) @@ -113,9 +113,7 @@ from .issue.activity import ( IssueActivityEndpoint, ) -from .issue.archive import ( - IssueArchiveViewSet, -) +from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint from .issue.attachment import ( IssueAttachmentEndpoint, @@ -154,6 +152,8 @@ from .issue.subscriber import ( ) +from .issue.bulk_operations import BulkIssueOperationsEndpoint + from .module.base import ( ModuleViewSet, ModuleLinkViewSet, @@ -180,16 +180,19 @@ from .page.base import ( PagesDescriptionViewSet, ) -from .search import GlobalSearchEndpoint, IssueSearchEndpoint +from .search.base import GlobalSearchEndpoint +from .search.issue import IssueSearchEndpoint from .external.base import ( GPTIntegrationEndpoint, UnsplashEndpoint, + WorkspaceGPTIntegrationEndpoint, ) from .estimate.base import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, + EstimatePointEndpoint, ) from .inbox.base import InboxViewSet, InboxIssueViewSet diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 256d3cae5..3d27641e3 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -33,7 +33,7 @@ class AnalyticsEndpoint(BaseAPIView): "state__group", "labels__id", "assignees__id", - "estimate_point", + "estimate_point__value", "issue_cycle__cycle_id", "issue_module__module_id", "priority", @@ -381,9 +381,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ) open_estimate_sum = open_issues_queryset.aggregate( - sum=Sum("estimate_point") + sum=Sum("point") )["sum"] - total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[ + total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[ "sum" ] diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 8f21f5fe1..45488b64e 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -1,4 +1,6 @@ # Python imports +import traceback + import zoneinfo from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -76,7 +78,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): response = super().handle_exception(exc) return response except Exception as e: - print(e) if settings.DEBUG else print("Server Error") + ( + print(e, traceback.format_exc()) + if settings.DEBUG + else print("Server Error") + ) if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 5e1241b08..dc5f6a573 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -278,7 +278,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): # Assignee Distribution assignee_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_cycle__cycle_id=pk, workspace__slug=slug, project_id=project_id, @@ -326,7 +326,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): # Label Distribution label_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_cycle__cycle_id=pk, workspace__slug=slug, project_id=project_id, @@ -375,6 +375,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): queryset=queryset, slug=slug, project_id=project_id, + plot_type="issues", cycle_id=pk, ) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 5982daf7f..65d65e525 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -17,8 +17,11 @@ from django.db.models import ( UUIDField, Value, When, + Subquery, + Sum, + FloatField, ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder @@ -43,6 +46,7 @@ from plane.db.models import ( Issue, Label, User, + Project, ) from plane.utils.analytics_plot import burndown_plot @@ -73,6 +77,89 @@ class CycleViewSet(BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + backlog_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + unstarted_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + started_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + cancelled_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("cancelled_estimate_point")[:1] + ) + completed_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="completed", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + completed_estimate_points=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("completed_estimate_points")[:1] + ) + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_cycle__cycle_id=OuterRef("pk"), + ) + .values("issue_cycle__cycle_id") + .annotate( + total_estimate_points=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("total_estimate_points")[:1] + ) return self.filter_queryset( super() .get_queryset() @@ -197,6 +284,42 @@ class CycleViewSet(BaseViewSet): Value([], output_field=ArrayField(UUIDField())), ) ) + .annotate( + backlog_estimate_points=Coalesce( + Subquery(backlog_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + unstarted_estimate_points=Coalesce( + Subquery(unstarted_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + started_estimate_points=Coalesce( + Subquery(started_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + cancelled_estimate_points=Coalesce( + Subquery(cancelled_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + completed_estimate_points=Coalesce( + Subquery(completed_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + total_estimate_points=Coalesce( + Subquery(total_estimate_point), + Value(0, output_field=FloatField()), + ), + ) .order_by("-is_favorite", "name") .distinct() ) @@ -233,6 +356,12 @@ class CycleViewSet(BaseViewSet): "progress_snapshot", "logo_props", # meta fields + "backlog_estimate_points", + "unstarted_estimate_points", + "started_estimate_points", + "cancelled_estimate_points", + "completed_estimate_points", + "total_estimate_points", "is_favorite", "total_issues", "cancelled_issues", @@ -244,8 +373,108 @@ class CycleViewSet(BaseViewSet): "status", "created_by", ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() if data: + data[0]["estimate_distribution"] = {} + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data[0]["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data[0]["start_date"] and data[0]["end_date"]: + data[0]["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=data[0]["id"], + ) + ) + assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=data[0]["id"], @@ -259,7 +488,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -298,8 +530,11 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -335,6 +570,7 @@ class CycleViewSet(BaseViewSet): queryset=queryset.first(), slug=slug, project_id=project_id, + plot_type="issues", cycle_id=data[0]["id"], ) ) @@ -359,6 +595,8 @@ class CycleViewSet(BaseViewSet): "progress_snapshot", "logo_props", # meta fields + "completed_estimate_points", + "total_estimate_points", "is_favorite", "total_issues", "cancelled_issues", @@ -577,12 +815,107 @@ class CycleViewSet(BaseViewSet): ) queryset = queryset.first() - if data is None: - return Response( - {"error": "Cycle does not exist"}, - status=status.HTTP_400_BAD_REQUEST, + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data["estimate_distribution"] = {} + if estimate_type: + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") ) + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if data["start_date"] and data["end_date"]: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset, + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=pk, + ) + ) + # Assignee Distribution assignee_distribution = ( Issue.objects.filter( @@ -605,7 +938,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -645,7 +981,10 @@ class CycleViewSet(BaseViewSet): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -682,6 +1021,7 @@ class CycleViewSet(BaseViewSet): queryset=queryset, slug=slug, project_id=project_id, + plot_type="issues", cycle_id=pk, ) @@ -874,17 +1214,131 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) ) - # Pass the new_cycle queryset to burndown_plot - completion_chart = burndown_plot( - queryset=old_cycle.first(), - slug=slug, - project_id=project_id, - cycle_id=cycle_id, - ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + if estimate_type: + assignee_estimate_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + # assignee distribution serialization + assignee_estimate_distribution = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) + if item["assignee_id"] + else None + ), + "avatar": item["avatar"], + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in assignee_estimate_data + ] + + label_distribution_data = ( + Issue.issue_objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + estimate_completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="points", + cycle_id=cycle_id, + ) + # Label distribution serialization + label_estimate_distribution = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), + "total_estimates": item["total_estimates"], + "completed_estimates": item["completed_estimates"], + "pending_estimates": item["pending_estimates"], + } + for item in label_distribution_data + ] # Get the assignee distribution assignee_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, workspace__slug=slug, project_id=project_id, @@ -896,7 +1350,10 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), ), ) .annotate( @@ -938,7 +1395,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): # Get the label distribution label_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_cycle__cycle_id=cycle_id, workspace__slug=slug, project_id=project_id, @@ -950,8 +1407,11 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate( total_issues=Count( "id", - filter=Q(archived_at__isnull=True, is_draft=False), - ) + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), ) .annotate( completed_issues=Count( @@ -976,20 +1436,6 @@ class TransferCycleIssueEndpoint(BaseAPIView): .order_by("label_name") ) - assignee_distribution_data = [ - { - "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), - "avatar": item["avatar"], - "total_issues": item["total_issues"], - "completed_issues": item["completed_issues"], - "pending_issues": item["pending_issues"], - } - for item in assignee_distribution - ] - # Label distribution serilization label_distribution_data = [ { @@ -1005,6 +1451,15 @@ class TransferCycleIssueEndpoint(BaseAPIView): for item in label_distribution ] + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + plot_type="issues", + cycle_id=cycle_id, + ) + current_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=cycle_id ).first() @@ -1021,6 +1476,15 @@ class TransferCycleIssueEndpoint(BaseAPIView): "assignees": assignee_distribution_data, "completion_chart": completion_chart, }, + "estimate_distribution": ( + {} + if not estimate_type + else { + "labels": label_estimate_distribution, + "assignees": assignee_estimate_distribution, + "completion_chart": estimate_completion_chart, + } + ), } current_cycle.save(update_fields=["progress_snapshot"]) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index fdc998f6d..1932ae169 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -2,43 +2,50 @@ import json # Django imports -from django.db.models import ( - Func, - F, - Q, - OuterRef, - Value, - UUIDField, -) from django.core import serializers +from django.db.models import ( + F, + Func, + OuterRef, + Q, +) from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ( + ProjectEntityPermission, +) # Module imports from .. import BaseViewSet from plane.app.serializers import ( - IssueSerializer, CycleIssueSerializer, ) -from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Cycle, CycleIssue, Issue, - IssueLink, IssueAttachment, + IssueLink, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, ) -from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -86,14 +93,9 @@ class CycleIssueViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug, project_id, cycle_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - order_by = request.GET.get("order_by", "created_at") + order_by_param = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") - queryset = ( + issue_queryset = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .filter(project_id=project_id) .filter(workspace__slug=slug) @@ -105,7 +107,6 @@ class CycleIssueViewSet(BaseViewSet): "issue_module__module", "issue_cycle__cycle", ) - .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( @@ -130,73 +131,112 @@ class CycleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) - .order_by(order_by) ) - if self.fields: - issues = IssueSerializer( - queryset, many=True, fields=fields if fields else None - ).data - else: - issues = queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + filters = issue_filters(request.query_params, "GET") - return Response(issues, status=status.HTTP_200_OK) + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = issue_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, cycle_id): issues = request.data.get("issues", []) diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 9558348d9..76a3563de 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -1,52 +1,53 @@ # Django imports -from django.db.models import ( - Q, - Case, - When, - Value, - CharField, - Count, - F, - Exists, - OuterRef, - Subquery, - JSONField, - Func, - Prefetch, - IntegerField, -) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + IntegerField, + JSONField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, + When, +) from django.db.models.functions import Coalesce from django.utils import timezone +from rest_framework import status # Third Party imports from rest_framework.response import Response -from rest_framework import status + +from plane.app.serializers import ( + DashboardSerializer, + IssueActivitySerializer, + IssueSerializer, + WidgetSerializer, +) +from plane.db.models import ( + Dashboard, + DashboardWidget, + Issue, + IssueActivity, + IssueAttachment, + IssueLink, + IssueRelation, + Project, + ProjectMember, + User, + Widget, +) +from plane.utils.issue_filters import issue_filters # Module imports from .. import BaseAPIView -from plane.db.models import ( - Issue, - IssueActivity, - ProjectMember, - Widget, - DashboardWidget, - Dashboard, - Project, - IssueLink, - IssueAttachment, - IssueRelation, - User, -) -from plane.app.serializers import ( - IssueActivitySerializer, - IssueSerializer, - DashboardSerializer, - WidgetSerializer, -) -from plane.utils.issue_filters import issue_filters def dashboard_overview_stats(self, request, slug): diff --git a/apiserver/plane/app/views/estimate/base.py b/apiserver/plane/app/views/estimate/base.py index 7ac3035a9..d70d4b869 100644 --- a/apiserver/plane/app/views/estimate/base.py +++ b/apiserver/plane/app/views/estimate/base.py @@ -1,3 +1,6 @@ +import random +import string + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -5,7 +8,7 @@ from rest_framework import status # Module imports from ..base import BaseViewSet, BaseAPIView from plane.app.permissions import ProjectEntityPermission -from plane.db.models import Project, Estimate, EstimatePoint +from plane.db.models import Project, Estimate, EstimatePoint, Issue from plane.app.serializers import ( EstimateSerializer, EstimatePointSerializer, @@ -13,6 +16,12 @@ from plane.app.serializers import ( ) from plane.utils.cache import invalidate_cache + +def generate_random_name(length=10): + letters = string.ascii_lowercase + return "".join(random.choice(letters) for i in range(length)) + + class ProjectEstimatePointEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, @@ -49,13 +58,20 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer = EstimateReadSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/estimates/", url_params=True, user=False + ) def create(self, request, slug, project_id): - if not request.data.get("estimate", False): - return Response( - {"error": "Estimate is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) + estimate = request.data.get("estimate") + estimate_name = estimate.get("name", generate_random_name()) + estimate_type = estimate.get("type", "categories") + last_used = estimate.get("last_used", False) + estimate = Estimate.objects.create( + name=estimate_name, + project_id=project_id, + last_used=last_used, + type=estimate_type, + ) estimate_points = request.data.get("estimate_points", []) @@ -67,14 +83,6 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - estimate_serializer = EstimateSerializer( - data=request.data.get("estimate") - ) - if not estimate_serializer.is_valid(): - return Response( - estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) - estimate = estimate_serializer.save(project_id=project_id) estimate_points = EstimatePoint.objects.bulk_create( [ EstimatePoint( @@ -93,17 +101,8 @@ class BulkEstimatePointEndpoint(BaseViewSet): ignore_conflicts=True, ) - estimate_point_serializer = EstimatePointSerializer( - estimate_points, many=True - ) - - return Response( - { - "estimate": estimate_serializer.data, - "estimate_points": estimate_point_serializer.data, - }, - status=status.HTTP_200_OK, - ) + serializer = EstimateReadSerializer(estimate) + return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, estimate_id): estimate = Estimate.objects.get( @@ -115,13 +114,10 @@ class BulkEstimatePointEndpoint(BaseViewSet): status=status.HTTP_200_OK, ) - @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/estimates/", url_params=True, user=False + ) def partial_update(self, request, slug, project_id, estimate_id): - if not request.data.get("estimate", False): - return Response( - {"error": "Estimate is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) if not len(request.data.get("estimate_points", [])): return Response( @@ -131,15 +127,14 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate = Estimate.objects.get(pk=estimate_id) - estimate_serializer = EstimateSerializer( - estimate, data=request.data.get("estimate"), partial=True - ) - if not estimate_serializer.is_valid(): - return Response( - estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST + if request.data.get("estimate"): + estimate.name = request.data.get("estimate").get( + "name", estimate.name ) - - estimate = estimate_serializer.save() + estimate.type = request.data.get("estimate").get( + "type", estimate.type + ) + estimate.save() estimate_points_data = request.data.get("estimate_points", []) @@ -165,29 +160,115 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate_point.value = estimate_point_data[0].get( "value", estimate_point.value ) + estimate_point.key = estimate_point_data[0].get( + "key", estimate_point.key + ) updated_estimate_points.append(estimate_point) EstimatePoint.objects.bulk_update( updated_estimate_points, - ["value"], + ["key", "value"], batch_size=10, ) - estimate_point_serializer = EstimatePointSerializer( - estimate_points, many=True - ) + estimate_serializer = EstimateReadSerializer(estimate) return Response( - { - "estimate": estimate_serializer.data, - "estimate_points": estimate_point_serializer.data, - }, + estimate_serializer.data, status=status.HTTP_200_OK, ) - @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) + @invalidate_cache( + path="/api/workspaces/:slug/estimates/", url_params=True, user=False + ) def destroy(self, request, slug, project_id, estimate_id): estimate = Estimate.objects.get( pk=estimate_id, workspace__slug=slug, project_id=project_id ) estimate.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class EstimatePointEndpoint(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + def create(self, request, slug, project_id, estimate_id): + # TODO: add a key validation if the same key already exists + if not request.data.get("key") or not request.data.get("value"): + return Response( + {"error": "Key and value are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + key = request.data.get("key", 0) + value = request.data.get("value", "") + estimate_point = EstimatePoint.objects.create( + estimate_id=estimate_id, + project_id=project_id, + key=key, + value=value, + ) + serializer = EstimatePointSerializer(estimate_point).data + return Response(serializer, status=status.HTTP_200_OK) + + def partial_update( + self, request, slug, project_id, estimate_id, estimate_point_id + ): + # TODO: add a key validation if the same key already exists + estimate_point = EstimatePoint.objects.get( + pk=estimate_point_id, + estimate_id=estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + serializer = EstimatePointSerializer( + estimate_point, data=request.data, partial=True + ) + if not serializer.is_valid(): + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy( + self, request, slug, project_id, estimate_id, estimate_point_id + ): + new_estimate_id = request.data.get("new_estimate_id", None) + estimate_points = EstimatePoint.objects.filter( + estimate_id=estimate_id, + project_id=project_id, + workspace__slug=slug, + ) + # update all the issues with the new estimate + if new_estimate_id: + _ = Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + estimate_point_id=estimate_point_id, + ).update(estimate_point_id=new_estimate_id) + + # delete the estimate point + old_estimate_point = EstimatePoint.objects.filter( + pk=estimate_point_id + ).first() + + # rearrange the estimate points + updated_estimate_points = [] + for estimate_point in estimate_points: + if estimate_point.key > old_estimate_point.key: + estimate_point.key -= 1 + updated_estimate_points.append(estimate_point) + + EstimatePoint.objects.bulk_update( + updated_estimate_points, + ["key"], + batch_size=10, + ) + + old_estimate_point.delete() + + return Response( + EstimatePointSerializer(updated_estimate_points, many=True).data, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/exporter/base.py b/apiserver/plane/app/views/exporter/base.py index 698d9eb99..dba61d728 100644 --- a/apiserver/plane/app/views/exporter/base.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -1,14 +1,14 @@ # Third Party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.serializers import ExporterHistorySerializer +from plane.bgtasks.export_task import issue_export_task +from plane.db.models import ExporterHistory, Project, Workspace # Module imports from .. import BaseAPIView -from plane.app.permissions import WorkSpaceAdminPermission -from plane.bgtasks.export_task import issue_export_task -from plane.db.models import Project, ExporterHistory, Workspace - -from plane.app.serializers import ExporterHistorySerializer class ExportIssuesEndpoint(BaseAPIView): @@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=exporter_history, on_results=lambda exporter_history: ExporterHistorySerializer( diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index 2d5d2c7aa..d9a66b850 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -11,7 +11,7 @@ from rest_framework import status # Module imports from ..base import BaseAPIView -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission from plane.db.models import Workspace, Project from plane.app.serializers import ( ProjectLiteSerializer, @@ -83,6 +83,64 @@ class GPTIntegrationEndpoint(BaseAPIView): ) +class WorkspaceGPTIntegrationEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def post(self, request, slug): + OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( + [ + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", None), + }, + { + "key": "GPT_ENGINE", + "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + }, + ] + ) + + # Get the configuration value + # Check the keys + if not OPENAI_API_KEY or not GPT_ENGINE: + return Response( + {"error": "OpenAI API key and engine is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + prompt = request.data.get("prompt", False) + task = request.data.get("task", False) + + if not task: + return Response( + {"error": "Task is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + final_text = task + "\n" + prompt + + client = OpenAI( + api_key=OPENAI_API_KEY, + ) + + response = client.chat.completions.create( + model=GPT_ENGINE, + messages=[{"role": "user", "content": final_text}], + ) + + text = response.choices[0].message.content.strip() + text_html = text.replace("\n", "
") + return Response( + { + "response": text, + "response_html": text_html, + }, + status=status.HTTP_200_OK, + ) + + class UnsplashEndpoint(BaseAPIView): def get(self, request): (UNSPLASH_ACCESS_KEY,) = get_configuration_value( diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index cc3a343d2..584edd8f9 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -2,52 +2,54 @@ import json # Django imports -from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - UUIDField, -) from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + F, + Func, + OuterRef, + Q, + Prefetch, + Exists, +) +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce # Third Party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response -# Module imports -from .. import BaseViewSet -from plane.app.serializers import ( - IssueSerializer, - IssueFlatSerializer, - IssueDetailSerializer, -) from plane.app.permissions import ( ProjectEntityPermission, ) +from plane.app.serializers import ( + IssueFlatSerializer, + IssueSerializer, + IssueDetailSerializer +) +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Issue, - IssueLink, IssueAttachment, + IssueLink, IssueSubscriber, IssueReaction, ) -from plane.bgtasks.issue_activites_task import issue_activity +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.user_timezone_converter import user_timezone_converter +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports +from .. import BaseViewSet, BaseAPIView + class IssueArchiveViewSet(BaseViewSet): permission_classes = [ @@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) ) @method_decorator(gzip_page) @@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = self.get_queryset().filter(**filters) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - issue_queryset = ( issue_queryset if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) ) - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - return Response(issues, status=status.HTTP_200_OK) + # 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 retrieve(self, request, slug, project_id, pk=None): issue = ( @@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet): issue.save() return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkArchiveIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ).select_related("state") + bulk_archive_issues = [] + for issue in issues: + if issue.state.group not in ["completed", "cancelled"]: + return Response( + { + "error_code": 4091, + "error_message": "INVALID_ARCHIVE_STATE_GROUP" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps( + { + "archived_at": str(timezone.now().date()), + "automation": False, + } + ), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue.archived_at = timezone.now().date() + bulk_archive_issues.append(issue) + Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"]) + + return Response( + {"archived_at": str(timezone.now().date())}, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index fad85b79d..e7b12a528 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -1,34 +1,30 @@ # 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 ( - Case, - CharField, Exists, F, Func, - Max, OuterRef, Prefetch, Q, UUIDField, Value, - When, ) from django.db.models.functions import Coalesce - -# Django imports from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from rest_framework import status # Third Party imports +from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, @@ -49,11 +45,21 @@ from plane.db.models import ( IssueSubscriber, Project, ) +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 BaseAPIView, BaseViewSet from plane.utils.user_timezone_converter import user_timezone_converter # Module imports -from .. import BaseAPIView, BaseViewSet class IssueListEndpoint(BaseAPIView): @@ -105,110 +111,28 @@ class IssueListEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) ).distinct() filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = queryset.filter(**filters) + # Issue queryset + issue_queryset, _ = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) if self.fields or self.expand: issues = IssueSerializer( @@ -304,33 +228,6 @@ class IssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) ).distinct() @method_decorator(gzip_page) @@ -340,116 +237,104 @@ class IssueViewSet(BaseViewSet): issue_queryset = self.get_queryset().filter(**filters) # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # 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: + 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: + 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, + ), + ) + 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, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - # Only use serializer when expand or fields else return by values - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - expand=self.expand, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + 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 + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -481,8 +366,13 @@ class IssueViewSet(BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) issue = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) + issue_queryset_grouper( + queryset=self.get_queryset().filter( + pk=serializer.data["id"] + ), + group_by=None, + sub_group_by=None, + ) .values( "id", "name", @@ -523,6 +413,33 @@ class IssueViewSet(BaseViewSet): 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", diff --git a/apiserver/plane/app/views/issue/bulk_operations.py b/apiserver/plane/app/views/issue/bulk_operations.py new file mode 100644 index 000000000..ea6637826 --- /dev/null +++ b/apiserver/plane/app/views/issue/bulk_operations.py @@ -0,0 +1,288 @@ +# Python imports +import json +from datetime import datetime + +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseAPIView +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.db.models import ( + Project, + Issue, + IssueLabel, + IssueAssignee, +) +from plane.bgtasks.issue_activites_task import issue_activity + + +class BulkIssueOperationsEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all the issues + issues = ( + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .select_related("state") + .prefetch_related("labels", "assignees") + ) + # Current epoch + epoch = int(timezone.now().timestamp()) + + # Project details + project = Project.objects.get(workspace__slug=slug, pk=project_id) + workspace_id = project.workspace_id + + # Initialize arrays + bulk_update_issues = [] + bulk_issue_activities = [] + bulk_update_issue_labels = [] + bulk_update_issue_assignees = [] + + properties = request.data.get("properties", {}) + + if properties.get("start_date", False) and properties.get("target_date", False): + if ( + datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date() + > datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date() + ): + return Response( + { + "error_code": 4100, + "error_message": "INVALID_ISSUE_DATES", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + for issue in issues: + + # Priority + if properties.get("priority", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"priority": properties.get("priority")} + ), + "current_instance": json.dumps( + {"priority": (issue.priority)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.priority = properties.get("priority") + + # State + if properties.get("state_id", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"state": properties.get("state")} + ), + "current_instance": json.dumps( + {"state": str(issue.state_id)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.state_id = properties.get("state_id") + + # Start date + if properties.get("start_date", False): + if ( + issue.target_date + and not properties.get("target_date", False) + and issue.target_date + <= datetime.strptime( + properties.get("start_date"), "%Y-%m-%d" + ).date() + ): + return Response( + { + "error_code": 4101, + "error_message": "INVALID_ISSUE_START_DATE", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"start_date": properties.get("start_date")} + ), + "current_instance": json.dumps( + {"start_date": str(issue.start_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.start_date = properties.get("start_date") + + # Target date + if properties.get("target_date", False): + if ( + issue.start_date + and not properties.get("start_date", False) + and issue.start_date + >= datetime.strptime( + properties.get("target_date"), "%Y-%m-%d" + ).date() + ): + return Response( + { + "error_code": 4102, + "error_message": "INVALID_ISSUE_TARGET_DATE", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"target_date": properties.get("target_date")} + ), + "current_instance": json.dumps( + {"target_date": str(issue.target_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.target_date = properties.get("target_date") + + bulk_update_issues.append(issue) + + # Labels + if properties.get("label_ids", []): + for label_id in properties.get("label_ids", []): + bulk_update_issue_labels.append( + IssueLabel( + issue=issue, + label_id=label_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"label_ids": properties.get("label_ids", [])} + ), + "current_instance": json.dumps( + { + "label_ids": [ + str(label.id) + for label in issue.labels.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Assignees + if properties.get("assignee_ids", []): + for assignee_id in properties.get( + "assignee_ids", issue.assignees + ): + bulk_update_issue_assignees.append( + IssueAssignee( + issue=issue, + assignee_id=assignee_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + { + "assignee_ids": properties.get( + "assignee_ids", [] + ) + } + ), + "current_instance": json.dumps( + { + "assignee_ids": [ + str(assignee.id) + for assignee in issue.assignees.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Bulk update all the objects + Issue.objects.bulk_update( + bulk_update_issues, + [ + "priority", + "start_date", + "target_date", + "state", + ], + batch_size=100, + ) + + # Create new labels + IssueLabel.objects.bulk_create( + bulk_update_issue_labels, + ignore_conflicts=True, + batch_size=100, + ) + + # Create new assignees + IssueAssignee.objects.bulk_create( + bulk_update_issue_assignees, + ignore_conflicts=True, + batch_size=100, + ) + # update the issue activity + [ + issue_activity.delay(**activity) + for activity in bulk_issue_activities + ] + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index 610c3c468..6944f40f7 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -6,18 +6,14 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( - Case, - CharField, Exists, F, Func, - Max, OuterRef, Prefetch, Q, UUIDField, Value, - When, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -28,6 +24,7 @@ from django.views.decorators.gzip import gzip_page 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, @@ -44,10 +41,17 @@ from plane.db.models import ( IssueSubscriber, Project, ) +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.user_timezone_converter import user_timezone_converter - -# Module imports +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) from .. import BaseViewSet @@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = self.get_queryset().filter(**filters) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # 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, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - # Only use serializer when expand else return by values - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - expand=self.expand, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + # 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 + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) + issue = ( - self.get_queryset().filter(pk=serializer.data["id"]).first() - ) - return Response( - IssueSerializer(issue).data, status=status.HTTP_201_CREATED + 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): @@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet): 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", diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 2ee4574eb..a010eff9b 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -99,6 +99,7 @@ class SubIssuesEndpoint(BaseAPIView): ), ) .annotate(state_group=F("state__group")) + .order_by("-created_at") ) # create's a dict with state group name with their respective issue id's diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index 2cac5f366..8f6d9c6b0 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -12,8 +12,9 @@ from django.db.models import ( Subquery, UUIDField, Value, + Sum ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.utils import timezone # Third party imports @@ -25,7 +26,7 @@ from plane.app.permissions import ( from plane.app.serializers import ( ModuleDetailSerializer, ) -from plane.db.models import Issue, Module, ModuleLink, UserFavorite +from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project from plane.utils.analytics_plot import burndown_plot from plane.utils.user_timezone_converter import user_timezone_converter @@ -217,8 +218,118 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .values("count") ) ) + + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + label_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + assignee_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", IntegerField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", IntegerField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignee"] = assignee_distribution + data["estimate_distribution"]["label"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + ) assignee_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_module__module_id=pk, workspace__slug=slug, project_id=project_id, @@ -268,7 +379,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) label_distribution = ( - Issue.objects.filter( + Issue.issue_objects.filter( issue_module__module_id=pk, workspace__slug=slug, project_id=project_id, @@ -309,7 +420,6 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .order_by("label_name") ) - data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -317,12 +427,12 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): } # Fetch the modules - modules = queryset.first() if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, project_id=project_id, + plot_type="issues", module_id=pk, ) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 56267554d..084204595 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -16,8 +16,10 @@ from django.db.models import ( Subquery, UUIDField, Value, + Sum, + FloatField, ) -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Cast from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone @@ -128,6 +130,90 @@ class ModuleViewSet(BaseViewSet): .annotate(cnt=Count("pk")) .values("cnt") ) + completed_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="completed", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + completed_estimate_points=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("completed_estimate_points")[:1] + ) + + total_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + total_estimate_points=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("total_estimate_points")[:1] + ) + backlog_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + backlog_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("backlog_estimate_point")[:1] + ) + unstarted_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + unstarted_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("unstarted_estimate_point")[:1] + ) + started_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + started_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("started_estimate_point")[:1] + ) + cancelled_estimate_point = ( + Issue.issue_objects.filter( + estimate_point__estimate__type="points", + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate( + cancelled_estimate_point=Sum( + Cast("estimate_point__value", FloatField()) + ) + ) + .values("cancelled_estimate_point")[:1] + ) return ( super() .get_queryset() @@ -182,6 +268,42 @@ class ModuleViewSet(BaseViewSet): Value(0, output_field=IntegerField()), ) ) + .annotate( + backlog_estimate_points=Coalesce( + Subquery(backlog_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + unstarted_estimate_points=Coalesce( + Subquery(unstarted_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + started_estimate_points=Coalesce( + Subquery(started_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + cancelled_estimate_points=Coalesce( + Subquery(cancelled_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + completed_estimate_points=Coalesce( + Subquery(completed_estimate_point), + Value(0, output_field=FloatField()), + ), + ) + .annotate( + total_estimate_points=Coalesce( + Subquery(total_estimate_point), + Value(0, output_field=FloatField()), + ), + ) .annotate( member_ids=Coalesce( ArrayAgg( @@ -233,6 +355,8 @@ class ModuleViewSet(BaseViewSet): "total_issues", "started_issues", "unstarted_issues", + "completed_estimate_points", + "total_estimate_points", "backlog_issues", "created_at", "updated_at", @@ -284,6 +408,8 @@ class ModuleViewSet(BaseViewSet): "external_id", "logo_props", # computed fields + "completed_estimate_points", + "total_estimate_points", "total_issues", "is_favorite", "cancelled_issues", @@ -317,6 +443,116 @@ class ModuleViewSet(BaseViewSet): ) ) + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + + data = ModuleDetailSerializer(queryset.first()).data + modules = queryset.first() + + data["estimate_distribution"] = {} + + if estimate_type: + assignee_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(display_name=F("assignees__display_name")) + .annotate(avatar=F("assignees__avatar")) + .values( + "first_name", + "last_name", + "assignee_id", + "avatar", + "display_name", + ) + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.issue_objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_estimates=Sum( + Cast("estimate_point__value", FloatField()) + ), + ) + .annotate( + completed_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_estimates=Sum( + Cast("estimate_point__value", FloatField()), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"]["labels"] = label_distribution + + if modules and modules.start_date and modules.target_date: + data["estimate_distribution"]["completion_chart"] = ( + burndown_plot( + queryset=modules, + slug=slug, + project_id=project_id, + plot_type="points", + module_id=pk, + ) + ) + assignee_distribution = ( Issue.objects.filter( issue_module__module_id=pk, @@ -342,7 +578,7 @@ class ModuleViewSet(BaseViewSet): archived_at__isnull=True, is_draft=False, ), - ) + ), ) .annotate( completed_issues=Count( @@ -409,20 +645,17 @@ class ModuleViewSet(BaseViewSet): .order_by("label_name") ) - data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, "completion_chart": {}, } - - # Fetch the modules - modules = queryset.first() if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, project_id=project_id, + plot_type="issues", module_id=pk, ) @@ -469,6 +702,8 @@ class ModuleViewSet(BaseViewSet): "external_id", "logo_props", # computed fields + "completed_estimate_points", + "total_estimate_points", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 879ab7e47..53665b943 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -1,37 +1,50 @@ # Python imports import json +from django.db.models import ( + F, + Func, + OuterRef, + Q, +) + # Django Imports from django.utils import timezone -from django.db.models import F, OuterRef, Func, Q from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models import Value, UUIDField -from django.db.models.functions import Coalesce # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.app.serializers import ( + ModuleIssueSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + ModuleIssue, + Project, +) +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, +) # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - ModuleIssueSerializer, - IssueSerializer, -) -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - ModuleIssue, - Project, - Issue, - IssueLink, - IssueAttachment, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer @@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id, module_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) - if self.fields or self.expand: - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + order_by_param = request.GET.get("order_by", "created_at") - return Response(issues, status=status.HTTP_200_OK) + # 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 + ), + ) # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py index 8dae618db..12997241c 100644 --- a/apiserver/plane/app/views/notification/base.py +++ b/apiserver/plane/app/views/notification/base.py @@ -1,26 +1,27 @@ # Django imports -from django.db.models import Q, OuterRef, Exists +from django.db.models import Exists, OuterRef, Q from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response -from plane.utils.paginator import BasePaginator -# Module imports -from ..base import BaseViewSet, BaseAPIView -from plane.db.models import ( - Notification, - IssueAssignee, - IssueSubscriber, - Issue, - WorkspaceMember, - UserNotificationPreference, -) from plane.app.serializers import ( NotificationSerializer, UserNotificationPreferenceSerializer, ) +from plane.db.models import ( + Issue, + IssueAssignee, + IssueSubscriber, + Notification, + UserNotificationPreference, + WorkspaceMember, +) +from plane.utils.paginator import BasePaginator + +# Module imports +from ..base import BaseAPIView, BaseViewSet class NotificationViewSet(BaseViewSet, BasePaginator): @@ -42,13 +43,22 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Get query parameters snoozed = request.GET.get("snoozed", "false") archived = request.GET.get("archived", "false") - read = request.GET.get("read", "true") + read = request.GET.get("read", None) type = request.GET.get("type", "all") + q_filters = Q() + + inbox_issue = Issue.objects.filter( + pk=OuterRef("entity_identifier"), + issue_inbox__status__in=[0, 2, -2], + workspace__slug=self.kwargs.get("slug"), + ) notifications = ( Notification.objects.filter( workspace__slug=slug, receiver_id=request.user.id ) + .filter(entity_name="issue") + .annotate(is_inbox_issue=Exists(inbox_issue)) .select_related("workspace", "project", "triggered_by", "receiver") .order_by("snoozed_till", "-created_at") ) @@ -73,8 +83,12 @@ class NotificationViewSet(BaseViewSet, BasePaginator): if read == "false": notifications = notifications.filter(read_at__isnull=True) + if read == "true": + notifications = notifications.filter(read_at__isnull=False) + + type = type.split(",") # Subscribed issues - if type == "watching": + if "subscribed" in type: issue_ids = ( IssueSubscriber.objects.filter( workspace__slug=slug, subscriber_id=request.user.id @@ -96,41 +110,39 @@ class NotificationViewSet(BaseViewSet, BasePaginator): .filter(created=False, assigned=False) .values_list("issue_id", flat=True) ) - notifications = notifications.filter( - entity_identifier__in=issue_ids, - ) + q_filters |= Q(entity_identifier__in=issue_ids) # Assigned Issues - if type == "assigned": + if "assigned" in type: issue_ids = IssueAssignee.objects.filter( workspace__slug=slug, assignee_id=request.user.id ).values_list("issue_id", flat=True) - notifications = notifications.filter( - entity_identifier__in=issue_ids - ) + q_filters |= Q(entity_identifier__in=issue_ids) # Created issues - if type == "created": + if "created" in type: if WorkspaceMember.objects.filter( workspace__slug=slug, member=request.user, role__lt=15, is_active=True, ).exists(): - notifications = Notification.objects.none() + notifications = notifications.none() else: issue_ids = Issue.objects.filter( workspace__slug=slug, created_by=request.user ).values_list("pk", flat=True) - notifications = notifications.filter( - entity_identifier__in=issue_ids - ) + q_filters |= Q(entity_identifier__in=issue_ids) + + # Apply the combined Q object filters + notifications = notifications.filter(q_filters) # Pagination if request.GET.get("per_page", False) and request.GET.get( "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(notifications), on_results=lambda notifications: NotificationSerializer( @@ -198,43 +210,19 @@ class NotificationViewSet(BaseViewSet, BasePaginator): class UnreadNotificationEndpoint(BaseAPIView): def get(self, request, slug): # Watching Issues Count - watching_issues_count = Notification.objects.filter( + unread_notifications_count = Notification.objects.filter( workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True, archived_at__isnull=True, - entity_identifier__in=IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ).values_list("issue_id", flat=True), - ).count() - - # My Issues Count - my_issues_count = Notification.objects.filter( - workspace__slug=slug, - receiver_id=request.user.id, - read_at__isnull=True, - archived_at__isnull=True, - entity_identifier__in=IssueAssignee.objects.filter( - workspace__slug=slug, assignee_id=request.user.id - ).values_list("issue_id", flat=True), - ).count() - - # Created Issues Count - created_issues_count = Notification.objects.filter( - workspace__slug=slug, - receiver_id=request.user.id, - read_at__isnull=True, - archived_at__isnull=True, - entity_identifier__in=Issue.objects.filter( - workspace__slug=slug, created_by=request.user - ).values_list("pk", flat=True), + snoozed_till__isnull=True, ).count() return Response( { - "watching_issues": watching_issues_count, - "my_issues": my_issues_count, - "created_issues": created_issues_count, + "total_unread_notifications_count": int( + unread_notifications_count + ) }, status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index c7f53b9fe..60fb81eeb 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -6,15 +6,19 @@ from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, OuterRef, Q, Value, UUIDField from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.http import StreamingHttpResponse +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce # Third party imports from rest_framework import status from rest_framework.response import Response + from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ( PageLogSerializer, @@ -27,6 +31,7 @@ from plane.db.models import ( PageLog, UserFavorite, ProjectMember, + ProjectPage, ) # Module imports @@ -66,28 +71,53 @@ class PageViewSet(BaseViewSet): user=self.request.user, entity_type="page", entity_identifier=OuterRef("pk"), - project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) return self.filter_queryset( super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) .filter( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) - .select_related("project") + .prefetch_related("projects") .select_related("workspace") .select_related("owned_by") .annotate(is_favorite=Exists(subquery)) .order_by(self.request.GET.get("order_by", "-created_at")) .prefetch_related("labels") .order_by("-is_favorite", "-created_at") + .annotate( + project=Exists( + ProjectPage.objects.filter( + page_id=OuterRef("id"), + project_id=self.kwargs.get("project_id"), + ) + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg( + "projects__id", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .filter(project=True) .distinct() ) @@ -107,7 +137,7 @@ class PageViewSet(BaseViewSet): serializer.save() # capture the page transaction page_transaction.delay(request.data, None, serializer.data["id"]) - page = Page.objects.get(pk=serializer.data["id"]) + page = self.get_queryset().get(pk=serializer.data["id"]) serializer = PageDetailSerializer(page) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -115,7 +145,9 @@ class PageViewSet(BaseViewSet): def partial_update(self, request, slug, project_id, pk): try: page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, + workspace__slug=slug, + projects__id=project_id, ) if page.is_locked: @@ -127,7 +159,9 @@ class PageViewSet(BaseViewSet): parent = request.data.get("parent", None) if parent: _ = Page.objects.get( - pk=parent, workspace__slug=slug, project_id=project_id + pk=parent, + workspace__slug=slug, + projects__id=project_id, ) # Only update access if the page owner is the requesting user @@ -187,7 +221,7 @@ class PageViewSet(BaseViewSet): def lock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = True @@ -196,7 +230,7 @@ class PageViewSet(BaseViewSet): def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ).first() page.is_locked = False @@ -211,7 +245,7 @@ class PageViewSet(BaseViewSet): def archive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner or admin can archive the page @@ -238,7 +272,7 @@ class PageViewSet(BaseViewSet): def unarchive(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner or admin can un archive the page @@ -267,7 +301,7 @@ class PageViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + pk=pk, workspace__slug=slug, projects__id=project_id ) # only the owner and admin can delete the page @@ -293,7 +327,7 @@ class PageViewSet(BaseViewSet): # remove parent from all the children _ = Page.objects.filter( - parent_id=pk, project_id=project_id, workspace__slug=slug + parent_id=pk, projects__id=project_id, workspace__slug=slug ).update(parent=None) page.delete() @@ -380,7 +414,6 @@ class SubPagesEndpoint(BaseAPIView): pages = ( PageLog.objects.filter( page_id=page_id, - project_id=project_id, workspace__slug=slug, entity_name__in=["forward_link", "back_link"], ) @@ -398,8 +431,12 @@ class PagesDescriptionViewSet(BaseViewSet): ] def retrieve(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + page = ( + Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() ) binary_data = page.description_binary @@ -418,10 +455,26 @@ class PagesDescriptionViewSet(BaseViewSet): return response def partial_update(self, request, slug, project_id, pk): - page = Page.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id + page = ( + Page.objects.filter( + pk=pk, workspace__slug=slug, projects__id=project_id + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() ) + if page.is_locked: + return Response( + {"error": "Page is locked"}, + status=471, + ) + + if page.archived_at: + return Response( + {"error": "Page is archived"}, + status=472, + ) + base64_data = request.data.get("description_binary") if base64_data: diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 39db11871..55471e674 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -1,26 +1,25 @@ # Python imports import boto3 +from django.conf import settings +from django.utils import timezone import json # Django imports from django.db import IntegrityError from django.db.models import ( - Prefetch, - Q, Exists, - OuterRef, F, Func, + OuterRef, + Prefetch, + Q, Subquery, ) -from django.conf import settings -from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response -from rest_framework import status -from rest_framework import serializers +from rest_framework import serializers, status from rest_framework.permissions import AllowAny # Module imports @@ -28,27 +27,26 @@ from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ) from plane.app.permissions import ( ProjectBasePermission, ProjectMemberPermission, ) - from plane.db.models import ( - Project, - ProjectMember, - Workspace, - State, UserFavorite, - ProjectIdentifier, - Module, Cycle, Inbox, - ProjectDeployBoard, + DeployBoard, IssueProperty, Issue, + Module, + Project, + ProjectIdentifier, + ProjectMember, + State, + Workspace, ) from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity @@ -137,12 +135,11 @@ class ProjectViewSet(BaseViewSet): ).values("role") ) .annotate( - is_deployed=Exists( - ProjectDeployBoard.objects.filter( - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - ) - ) + anchor=DeployBoard.objects.filter( + entity_name="project", + entity_identifier=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ).values("anchor") ) .annotate(sort_order=Subquery(sort_order)) .prefetch_related( @@ -169,6 +166,7 @@ class ProjectViewSet(BaseViewSet): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(projects), on_results=lambda projects: ProjectListSerializer( @@ -242,6 +240,12 @@ class ProjectViewSet(BaseViewSet): ) ).first() + if project is None: + return Response( + {"error": "Project does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) @@ -639,29 +643,28 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): return Response(files, status=status.HTTP_200_OK) -class ProjectDeployBoardViewSet(BaseViewSet): +class DeployBoardViewSet(BaseViewSet): permission_classes = [ ProjectMemberPermission, ] - serializer_class = ProjectDeployBoardSerializer - model = ProjectDeployBoard + serializer_class = DeployBoardSerializer + model = DeployBoard - def get_queryset(self): - return ( - super() - .get_queryset() - .filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), - ) - .select_related("project") - ) + def list(self, request, slug, project_id): + project_deploy_board = DeployBoard.objects.filter( + entity_name="project", + entity_identifier=project_id, + workspace__slug=slug, + ).first() + + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) def create(self, request, slug, project_id): - comments = request.data.get("comments", False) - reactions = request.data.get("reactions", False) + comments = request.data.get("is_comments_enabled", False) + reactions = request.data.get("is_reactions_enabled", False) inbox = request.data.get("inbox", None) - votes = request.data.get("votes", False) + votes = request.data.get("is_votes_enabled", False) views = request.data.get( "views", { @@ -673,17 +676,18 @@ class ProjectDeployBoardViewSet(BaseViewSet): }, ) - project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( - anchor=f"{slug}/{project_id}", + project_deploy_board, _ = DeployBoard.objects.get_or_create( + entity_name="project", + entity_identifier=project_id, project_id=project_id, ) - project_deploy_board.comments = comments - project_deploy_board.reactions = reactions project_deploy_board.inbox = inbox - project_deploy_board.votes = votes - project_deploy_board.views = views + project_deploy_board.view_props = views + project_deploy_board.is_votes_enabled = votes + project_deploy_board.is_comments_enabled = comments + project_deploy_board.is_reactions_enabled = reactions project_deploy_board.save() - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index 187dfc8d0..9ecb512d3 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -24,6 +24,8 @@ from plane.db.models import ( TeamMember, IssueProperty, ) +from plane.bgtasks.project_add_user_email_task import project_add_user_email +from plane.utils.host import base_host class ProjectMemberViewSet(BaseViewSet): @@ -64,33 +66,29 @@ class ProjectMemberViewSet(BaseViewSet): ) def create(self, request, slug, project_id): + # Get the list of members to be added to the project and their roles i.e. the user_id and the role members = request.data.get("members", []) # get the project project = Project.objects.get(pk=project_id, workspace__slug=slug) + # Check if the members array is empty if not len(members): return Response( {"error": "Atleast one member is required"}, status=status.HTTP_400_BAD_REQUEST, ) + + # Initialize the bulk arrays bulk_project_members = [] bulk_issue_props = [] - project_members = ( - ProjectMember.objects.filter( - workspace__slug=slug, - member_id__in=[member.get("member_id") for member in members], - ) - .values("member_id", "sort_order") - .order_by("sort_order") - ) - - bulk_project_members = [] + # Create a dictionary of the member_id and their roles member_roles = { member.get("member_id"): member.get("role") for member in members } - # Update roles in the members array based on the member_roles dictionary + + # Update roles in the members array based on the member_roles dictionary and set is_active to True for project_member in ProjectMember.objects.filter( project_id=project_id, member_id__in=[member.get("member_id") for member in members], @@ -104,13 +102,27 @@ class ProjectMemberViewSet(BaseViewSet): bulk_project_members, ["is_active", "role"], batch_size=100 ) + # Get the list of project members of the requested workspace with the given slug + project_members = ( + ProjectMember.objects.filter( + workspace__slug=slug, + member_id__in=[member.get("member_id") for member in members], + ) + .values("member_id", "sort_order") + .order_by("sort_order") + ) + + # Loop through requested members for member in members: + + # Get the sort orders of the member sort_order = [ project_member.get("sort_order") for project_member in project_members if str(project_member.get("member_id")) == str(member.get("member_id")) ] + # Create a new project member bulk_project_members.append( ProjectMember( member_id=member.get("member_id"), @@ -122,6 +134,7 @@ class ProjectMemberViewSet(BaseViewSet): ), ) ) + # Create a new issue property bulk_issue_props.append( IssueProperty( user_id=member.get("member_id"), @@ -130,6 +143,7 @@ class ProjectMemberViewSet(BaseViewSet): ) ) + # Bulk create the project members and issue properties project_members = ProjectMember.objects.bulk_create( bulk_project_members, batch_size=10, @@ -144,7 +158,18 @@ class ProjectMemberViewSet(BaseViewSet): project_id=project_id, member_id__in=[member.get("member_id") for member in members], ) + # Send emails to notify the users + [ + project_add_user_email.delay( + base_host(request=request, is_app=True), + project_member.id, + request.user.id, + ) + for project_member in project_members + ] + # Serialize the project members serializer = ProjectMemberRoleSerializer(project_members, many=True) + # Return the serialized data return Response(serializer.data, status=status.HTTP_201_CREATED) def list(self, request, slug, project_id): diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search/base.py similarity index 68% rename from apiserver/plane/app/views/search.py rename to apiserver/plane/app/views/search/base.py index 93bab2de3..8a7b9d908 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search/base.py @@ -2,14 +2,17 @@ import re # Django imports -from django.db.models import Q +from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports -from .base import BaseAPIView +from plane.app.views.base import BaseAPIView from plane.db.models import ( Workspace, Project, @@ -18,8 +21,8 @@ from plane.db.models import ( Module, Page, IssueView, + ProjectPage, ) -from plane.utils.issue_search import search_issues class GlobalSearchEndpoint(BaseAPIView): @@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView): for field in fields: q |= Q(**{f"{field}__icontains": query}) - pages = Page.objects.filter( - q, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, - workspace__slug=slug, + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + workspace__slug=slug, + ) + .annotate( + project_ids=Coalesce( + ArrayAgg( + "projects__id", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .annotate( + project_identifiers=Coalesce( + ArrayAgg( + "projects__identifier", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(CharField())), + ), + ) ) if workspace_search == "false" and project_id: - pages = pages.filter(project_id=project_id) + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), + project_id=project_id, + ).values_list("project_id", flat=True)[:1] + + pages = pages.annotate( + project_id=Subquery(project_subquery) + ).filter(project_id=project_id) return pages.distinct().values( "name", "id", - "project_id", - "project__identifier", + "project_ids", + "project_identifiers", "workspace__slug", ) @@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView): func = MODELS_MAPPER.get(model, None) results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) - - -class IssueSearchEndpoint(BaseAPIView): - def get(self, request, slug, project_id): - query = request.query_params.get("search", False) - workspace_search = request.query_params.get( - "workspace_search", "false" - ) - parent = request.query_params.get("parent", "false") - issue_relation = request.query_params.get("issue_relation", "false") - cycle = request.query_params.get("cycle", "false") - module = request.query_params.get("module", False) - sub_issue = request.query_params.get("sub_issue", "false") - target_date = request.query_params.get("target_date", True) - - issue_id = request.query_params.get("issue_id", False) - - issues = Issue.issue_objects.filter( - workspace__slug=slug, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True - ) - - if workspace_search == "false": - issues = issues.filter(project_id=project_id) - - if query: - issues = search_issues(query, issues) - - if parent == "true" and issue_id: - issue = Issue.issue_objects.get(pk=issue_id) - issues = issues.filter( - ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) - ) - if issue_relation == "true" and issue_id: - issue = Issue.issue_objects.get(pk=issue_id) - issues = issues.filter( - ~Q(pk=issue_id), - ~Q(issue_related__issue=issue), - ~Q(issue_relation__related_issue=issue), - ) - if sub_issue == "true" and issue_id: - issue = Issue.issue_objects.get(pk=issue_id) - issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) - if issue.parent: - issues = issues.filter(~Q(pk=issue.parent_id)) - - if cycle == "true": - issues = issues.exclude(issue_cycle__isnull=False) - - if module: - issues = issues.exclude(issue_module__module=module) - - if target_date == "none": - issues = issues.filter(target_date__isnull=True) - - return Response( - issues.values( - "name", - "id", - "start_date", - "sequence_id", - "project__name", - "project__identifier", - "project_id", - "workspace__slug", - "state__name", - "state__group", - "state__color", - ), - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py new file mode 100644 index 000000000..50b468715 --- /dev/null +++ b/apiserver/plane/app/views/search/issue.py @@ -0,0 +1,95 @@ +# Python imports +import re + +# Django imports +from django.db.models import Q + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import ( + Workspace, + Project, + Issue, + Cycle, + Module, + Page, + IssueView, +) +from plane.utils.issue_search import search_issues + + +class IssueSearchEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + query = request.query_params.get("search", False) + workspace_search = request.query_params.get( + "workspace_search", "false" + ) + parent = request.query_params.get("parent", "false") + issue_relation = request.query_params.get("issue_relation", "false") + cycle = request.query_params.get("cycle", "false") + module = request.query_params.get("module", False) + sub_issue = request.query_params.get("sub_issue", "false") + target_date = request.query_params.get("target_date", True) + + issue_id = request.query_params.get("issue_id", False) + + issues = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + + if workspace_search == "false": + issues = issues.filter(project_id=project_id) + + if query: + issues = search_issues(query, issues) + + if parent == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter( + ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) + ) + if issue_relation == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter( + ~Q(pk=issue_id), + ~Q(issue_related__issue=issue), + ~Q(issue_relation__related_issue=issue), + ) + if sub_issue == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) + if issue.parent: + issues = issues.filter(~Q(pk=issue.parent_id)) + + if cycle == "true": + issues = issues.exclude(issue_cycle__isnull=False) + + if module: + issues = issues.exclude(issue_module__module=module) + + if target_date == "none": + issues = issues.filter(target_date__isnull=True) + + return Response( + issues.values( + "name", + "id", + "start_date", + "sequence_id", + "project__name", + "project__identifier", + "project_id", + "workspace__slug", + "state__name", + "state__group", + "state__color", + ), + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index de1559b0c..ac0c3d711 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -35,6 +35,8 @@ from plane.license.models import Instance, InstanceAdmin from plane.utils.cache import cache_response, invalidate_cache from plane.utils.paginator import BasePaginator from plane.authentication.utils.host import user_ip +from plane.bgtasks.user_deactivation_email_task import user_deactivation_email +from plane.utils.host import base_host class UserEndpoint(BaseViewSet): @@ -192,6 +194,11 @@ class UserEndpoint(BaseViewSet): user.last_logout_time = timezone.now() user.save() + # Send an email to the user + user_deactivation_email.delay( + base_host(request=request, is_app=True), user.id + ) + # Logout the user logout(request) return Response(status=status.HTTP_204_NO_CONTENT) @@ -250,6 +257,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): ).select_related("actor", "workspace", "issue", "project") return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, on_results=lambda issue_activities: IssueActivitySerializer( diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 72c27d20a..079430129 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -1,49 +1,61 @@ # Django imports -from django.db.models import ( - Q, - OuterRef, - Func, - F, - Case, - Value, - CharField, - When, - Exists, - Max, -) -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Exists, + F, + Func, + OuterRef, + Q, + UUIDField, + Value, +) from django.db.models.functions import Coalesce +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from rest_framework import status +from django.db import transaction # Third party imports from rest_framework.response import Response -from rest_framework import status + +from plane.app.permissions import ( + ProjectEntityPermission, + WorkspaceEntityPermission, +) +from plane.app.serializers import ( + IssueViewSerializer, +) +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueView, + Workspace, + WorkspaceMember, + 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, +) # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueViewSerializer, - IssueSerializer, -) -from plane.app.permissions import ( - WorkspaceEntityPermission, - ProjectEntityPermission, -) -from plane.db.models import ( - Workspace, - IssueView, - Issue, - UserFavorite, - IssueLink, - IssueAttachment, -) -from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter -class GlobalViewViewSet(BaseViewSet): +from plane.db.models import ( + UserFavorite, +) + + +class WorkspaceViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer model = IssueView permission_classes = [ @@ -52,7 +64,7 @@ class GlobalViewViewSet(BaseViewSet): def perform_create(self, serializer): workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) - serializer.save(workspace_id=workspace.id) + serializer.save(workspace_id=workspace.id, owned_by=self.request.user) def get_queryset(self): return self.filter_queryset( @@ -60,13 +72,70 @@ class GlobalViewViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project__isnull=True) + .filter(Q(owned_by=self.request.user) | Q(access=1)) .select_related("workspace") .order_by(self.request.GET.get("order_by", "-created_at")) .distinct() ) + def partial_update(self, request, slug, pk): + with transaction.atomic(): + workspace_view = IssueView.objects.select_for_update().get( + pk=pk, + workspace__slug=slug, + ) -class GlobalViewIssuesViewSet(BaseViewSet): + if workspace_view.is_locked: + return Response( + {"error": "view is locked"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Only update the view if owner is updating + if workspace_view.owned_by_id != request.user.id: + return Response( + { + "error": "Only the owner of the view can update the view" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueViewSerializer( + workspace_view, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, slug, pk): + workspace_view = IssueView.objects.get( + pk=pk, + workspace__slug=slug, + ) + workspace_member = WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + is_active=True, + ) + if ( + workspace_member.exists() + or workspace_view.owned_by == request.user + ): + workspace_view.delete() + else: + return Response( + {"error": "Only admin or owner can delete the view"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceViewIssuesViewSet(BaseViewSet): permission_classes = [ WorkspaceEntityPermission, ] @@ -143,17 +212,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -162,103 +220,107 @@ class GlobalViewIssuesViewSet(BaseViewSet): .annotate(cycle_id=F("issue_cycle__cycle_id")) ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - if self.fields: - issues = IssueSerializer( - issue_queryset, many=True, fields=self.fields - ).data + # 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=None, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=None, + 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=None, + 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: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + # 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 + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): @@ -269,7 +331,10 @@ class IssueViewViewSet(BaseViewSet): ] def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) + serializer.save( + project_id=self.kwargs.get("project_id"), + owned_by=self.request.user, + ) def get_queryset(self): subquery = UserFavorite.objects.filter( @@ -289,6 +354,7 @@ class IssueViewViewSet(BaseViewSet): project__project_projectmember__is_active=True, project__archived_at__isnull=True, ) + .filter(Q(owned_by=self.request.user) | Q(access=1)) .select_related("project") .select_related("workspace") .annotate(is_favorite=Exists(subquery)) @@ -308,6 +374,60 @@ class IssueViewViewSet(BaseViewSet): ).data return Response(views, status=status.HTTP_200_OK) + def partial_update(self, request, slug, project_id, pk): + with transaction.atomic(): + issue_view = IssueView.objects.select_for_update().get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + + if issue_view.is_locked: + return Response( + {"error": "view is locked"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Only update the view if owner is updating + if issue_view.owned_by_id != request.user.id: + return Response( + { + "error": "Only the owner of the view can update the view" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueViewSerializer( + issue_view, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + def destroy(self, request, slug, project_id, pk): + project_view = IssueView.objects.get( + pk=pk, + project_id=project_id, + workspace__slug=slug, + ) + project_member = ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=20, + is_active=True, + ) + if project_member.exists() or project_view.owned_by == request.user: + project_view.delete() + else: + return Response( + {"error": "Only admin or owner can delete the view"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + class IssueViewFavoriteViewSet(BaseViewSet): model = UserFavorite diff --git a/apiserver/plane/app/views/workspace/base.py b/apiserver/plane/app/views/workspace/base.py index 830ae1dc2..afe998580 100644 --- a/apiserver/plane/app/views/workspace/base.py +++ b/apiserver/plane/app/views/workspace/base.py @@ -44,6 +44,7 @@ from plane.db.models import ( WorkspaceTheme, ) from plane.utils.cache import cache_response, invalidate_cache +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS class WorkSpaceViewSet(BaseViewSet): @@ -118,7 +119,7 @@ class WorkSpaceViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - if serializer.is_valid(): + if serializer.is_valid(raise_exception=True): serializer.save(owner=request.user) # Create Workspace member _ = WorkspaceMember.objects.create( @@ -231,7 +232,10 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - workspace = Workspace.objects.filter(slug=slug).exists() + workspace = ( + Workspace.objects.filter(slug=slug).exists() + or slug in RESTRICTED_WORKSPACE_SLUGS + ) return Response({"status": not workspace}, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace/cycle.py b/apiserver/plane/app/views/workspace/cycle.py index fa2954d67..f642416e3 100644 --- a/apiserver/plane/app/views/workspace/cycle.py +++ b/apiserver/plane/app/views/workspace/cycle.py @@ -2,7 +2,6 @@ from django.db.models import ( Q, Count, - Sum, ) # Third party modules @@ -87,29 +86,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 94a22a1a7..addb8c5ac 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -1,61 +1,66 @@ # Python imports from datetime import date + from dateutil.relativedelta import relativedelta # Django imports -from django.utils import timezone from django.db.models import ( - OuterRef, - Func, - F, - Q, - Count, Case, - Value, - CharField, - When, - Max, + Count, + F, + Func, IntegerField, - UUIDField, + OuterRef, + Q, + Value, + When, ) -from django.db.models.functions import ExtractWeek, Cast from django.db.models.fields import DateField -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce +from django.db.models.functions import Cast, ExtractWeek +from django.utils import timezone # Third party modules from rest_framework import status from rest_framework.response import Response -# Module imports -from plane.app.serializers import ( - WorkSpaceSerializer, - ProjectMemberSerializer, - IssueActivitySerializer, - IssueSerializer, - WorkspaceUserPropertiesSerializer, -) -from plane.app.views.base import BaseAPIView -from plane.db.models import ( - User, - Workspace, - ProjectMember, - IssueActivity, - Issue, - IssueLink, - IssueAttachment, - IssueSubscriber, - Project, - WorkspaceMember, - CycleIssue, - WorkspaceUserProperties, -) from plane.app.permissions import ( WorkspaceEntityPermission, WorkspaceViewerPermission, ) + +# Module imports +from plane.app.serializers import ( + IssueActivitySerializer, + ProjectMemberSerializer, + WorkSpaceSerializer, + WorkspaceUserPropertiesSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + CycleIssue, + Issue, + IssueActivity, + IssueAttachment, + IssueLink, + IssueSubscriber, + Project, + ProjectMember, + User, + Workspace, + WorkspaceMember, + WorkspaceUserProperties, +) +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, +) class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): @@ -99,22 +104,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ] def get(self, request, slug, user_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .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())), - ), - ) .order_by("created_at") ).distinct() - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # 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: + 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: + 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, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + 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, + ), + ) + 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, + 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, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - return Response(issues, status=status.HTTP_200_OK) + 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 + ), + ) class WorkspaceUserPropertiesEndpoint(BaseAPIView): @@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): queryset = queryset.filter(project__in=projects) return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, on_results=lambda issue_activities: IssueActivitySerializer( diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 7b899e63c..906d55700 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -4,6 +4,8 @@ import uuid # Django imports from django.utils import timezone +from django.core.validators import validate_email +from django.core.exceptions import ValidationError # Third party imports from zxcvbn import zxcvbn @@ -16,6 +18,8 @@ from plane.db.models import ( ) from plane.license.utils.instance_value import get_configuration_value from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES +from plane.bgtasks.user_activation_email_task import user_activation_email +from plane.authentication.utils.host import base_host class Adapter: @@ -46,68 +50,71 @@ class Adapter: def authenticate(self): raise NotImplementedError - def complete_login_or_signup(self): - email = self.user_data.get("email") - user = User.objects.filter(email=email).first() - # Check if sign up case or login - is_signup = bool(user) - if not user: - # New user - (ENABLE_SIGNUP,) = get_configuration_value( - [ - { - "key": "ENABLE_SIGNUP", - "default": os.environ.get("ENABLE_SIGNUP", "1"), - }, - ] - ) - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter( - email=email, - ).exists() - ): - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], - error_message="SIGNUP_DISABLED", - payload={"email": email}, - ) - user = User(email=email, username=uuid.uuid4().hex) - - if self.user_data.get("user").get("is_password_autoset"): - user.set_password(uuid.uuid4().hex) - user.is_password_autoset = True - user.is_email_verified = True - else: - # Validate password - results = zxcvbn(self.code) - if results["score"] < 3: - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_PASSWORD" - ], - error_message="INVALID_PASSWORD", - payload={"email": email}, - ) - - user.set_password(self.code) - user.is_password_autoset = False - - avatar = self.user_data.get("user", {}).get("avatar", "") - first_name = self.user_data.get("user", {}).get("first_name", "") - last_name = self.user_data.get("user", {}).get("last_name", "") - user.avatar = avatar if avatar else "" - user.first_name = first_name if first_name else "" - user.last_name = last_name if last_name else "" - user.save() - Profile.objects.create(user=user) - - if not user.is_active: + def sanitize_email(self, email): + # Check if email is present + if not email: raise AuthenticationException( - AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], - error_message="USER_ACCOUNT_DEACTIVATED", + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, ) + # Sanitize email + email = str(email).lower().strip() + + # validate email + try: + validate_email(email) + except ValidationError: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + error_message="INVALID_EMAIL", + payload={"email": email}, + ) + # Return email + return email + + def validate_password(self, email): + """Validate password strength""" + results = zxcvbn(self.code) + if results["score"] < 3: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], + error_message="INVALID_PASSWORD", + payload={"email": email}, + ) + return + + def __check_signup(self, email): + """Check if sign up is enabled or not and raise exception if not enabled""" + + # Get configuration value + (ENABLE_SIGNUP,) = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP", "1"), + }, + ] + ) + + # Check if sign up is disabled and invite is present or not + if ( + ENABLE_SIGNUP == "0" + and not WorkspaceMemberInvite.objects.filter( + email=email, + ).exists() + ): + # Raise exception + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], + error_message="SIGNUP_DISABLED", + payload={"email": email}, + ) + + return True + + def save_user_data(self, user): # Update user details user.last_login_medium = self.provider user.last_active = timezone.now() @@ -115,8 +122,65 @@ class Adapter: user.last_login_ip = self.request.META.get("REMOTE_ADDR") user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() + # If user is not active, send the activation email and set the user as active + if not user.is_active: + user_activation_email.delay( + base_host(request=self.request), user.id + ) + # Set user as active + user.is_active = True user.save() + return user + def complete_login_or_signup(self): + # Get email + email = self.user_data.get("email") + + # Sanitize email + email = self.sanitize_email(email) + + # Check if the user is present + user = User.objects.filter(email=email).first() + # Check if sign up case or login + is_signup = bool(user) + # If user is not present, create a new user + if not user: + # New user + self.__check_signup(email) + + # Initialize user + user = User(email=email, username=uuid.uuid4().hex) + + # Check if password is autoset + if self.user_data.get("user").get("is_password_autoset"): + user.set_password(uuid.uuid4().hex) + user.is_password_autoset = True + user.is_email_verified = True + + # Validate password + else: + # Validate password + self.validate_password(email) + # Set password + user.set_password(self.code) + user.is_password_autoset = False + + # Set user details + avatar = self.user_data.get("user", {}).get("avatar", "") + first_name = self.user_data.get("user", {}).get("first_name", "") + last_name = self.user_data.get("user", {}).get("last_name", "") + user.avatar = avatar if avatar else "" + user.first_name = first_name if first_name else "" + user.last_name = last_name if last_name else "" + user.save() + + # Create profile + Profile.objects.create(user=user) + + # Save user data + user = self.save_user_data(user=user) + + # Call callback if present if self.callback: self.callback( user, @@ -124,7 +188,9 @@ class Adapter: self.request, ) + # Create or update account if token data is present if self.token_data: self.create_update_account(user=user) + # Return user return user diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 7b12db945..90e236a80 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -33,10 +33,13 @@ AUTHENTICATION_ERROR_CODES = { "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN": 5100, "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP": 5102, # Oauth + "OAUTH_NOT_CONFIGURED": 5104, "GOOGLE_NOT_CONFIGURED": 5105, "GITHUB_NOT_CONFIGURED": 5110, + "GITLAB_NOT_CONFIGURED": 5111, "GOOGLE_OAUTH_PROVIDER_ERROR": 5115, "GITHUB_OAUTH_PROVIDER_ERROR": 5120, + "GITLAB_OAUTH_PROVIDER_ERROR": 5121, # Reset Password "INVALID_PASSWORD_TOKEN": 5125, "EXPIRED_PASSWORD_TOKEN": 5130, @@ -58,6 +61,8 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_USER_DEACTIVATED": 5190, # Rate limit "RATE_LIMIT_EXCEEDED": 5900, + # Unknown + "AUTHENTICATION_FAILED": 5999, } diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index a917c002a..ffda0212f 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -39,6 +39,16 @@ class OauthAdapter(Adapter): self.client_secret = client_secret self.code = code + def authentication_error_code(self): + if self.provider == "google": + return "GOOGLE_OAUTH_PROVIDER_ERROR" + elif self.provider == "github": + return "GITHUB_OAUTH_PROVIDER_ERROR" + elif self.provider == "gitlab": + return "GITLAB_OAUTH_PROVIDER_ERROR" + else: + return "OAUTH_NOT_CONFIGURED" + def get_auth_url(self): return self.auth_url @@ -62,11 +72,7 @@ class OauthAdapter(Adapter): response.raise_for_status() return response.json() except requests.RequestException: - code = ( - "GOOGLE_OAUTH_PROVIDER_ERROR" - if self.provider == "google" - else "GITHUB_OAUTH_PROVIDER_ERROR" - ) + code = self.authentication_error_code() raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code), @@ -81,11 +87,7 @@ class OauthAdapter(Adapter): response.raise_for_status() return response.json() except requests.RequestException: - code = ( - "GOOGLE_OAUTH_PROVIDER_ERROR" - if self.provider == "google" - else "GITHUB_OAUTH_PROVIDER_ERROR" - ) + code = self.authentication_error_code() raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code), diff --git a/apiserver/plane/authentication/provider/credentials/magic_code.py b/apiserver/plane/authentication/provider/credentials/magic_code.py index 21309ea9c..418dd2a06 100644 --- a/apiserver/plane/authentication/provider/credentials/magic_code.py +++ b/apiserver/plane/authentication/provider/credentials/magic_code.py @@ -48,7 +48,7 @@ class MagicCodeProvider(CredentialAdapter): raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["SMTP_NOT_CONFIGURED"], error_message="SMTP_NOT_CONFIGURED", - payload={"email": str(self.key)}, + payload={"email": str(key)}, ) if ENABLE_MAGIC_LINK_LOGIN == "0": @@ -57,7 +57,7 @@ class MagicCodeProvider(CredentialAdapter): "MAGIC_LINK_LOGIN_DISABLED" ], error_message="MAGIC_LINK_LOGIN_DISABLED", - payload={"email": str(self.key)}, + payload={"email": str(key)}, ) super().__init__( diff --git a/apiserver/plane/authentication/provider/oauth/gitlab.py b/apiserver/plane/authentication/provider/oauth/gitlab.py new file mode 100644 index 000000000..3795cc37f --- /dev/null +++ b/apiserver/plane/authentication/provider/oauth/gitlab.py @@ -0,0 +1,132 @@ +# Python imports +import os +from datetime import datetime +from urllib.parse import urlencode + +import pytz + +# Module imports +from plane.authentication.adapter.oauth import OauthAdapter +from plane.license.utils.instance_value import get_configuration_value +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class GitLabOAuthProvider(OauthAdapter): + + provider = "gitlab" + scope = "read_user" + + def __init__(self, request, code=None, state=None, callback=None): + + GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET, GITLAB_HOST = ( + get_configuration_value( + [ + { + "key": "GITLAB_CLIENT_ID", + "default": os.environ.get("GITLAB_CLIENT_ID"), + }, + { + "key": "GITLAB_CLIENT_SECRET", + "default": os.environ.get("GITLAB_CLIENT_SECRET"), + }, + { + "key": "GITLAB_HOST", + "default": os.environ.get( + "GITLAB_HOST", "https://gitlab.com" + ), + }, + ] + ) + ) + + self.host = GITLAB_HOST + self.token_url = f"{self.host}/oauth/token" + self.userinfo_url = f"{self.host}/api/v4/user" + + if not (GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET and GITLAB_HOST): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITLAB_NOT_CONFIGURED"], + error_message="GITLAB_NOT_CONFIGURED", + ) + + client_id = GITLAB_CLIENT_ID + client_secret = GITLAB_CLIENT_SECRET + + redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/gitlab/callback/""" + url_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": self.scope, + "state": state, + } + auth_url = f"{self.host}/oauth/authorize?{urlencode(url_params)}" + super().__init__( + request, + self.provider, + client_id, + self.scope, + redirect_uri, + auth_url, + self.token_url, + self.userinfo_url, + client_secret, + code, + callback=callback, + ) + + def set_token_data(self): + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": self.code, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + token_response = self.get_user_token( + data=data, headers={"Accept": "application/json"} + ) + super().set_token_data( + { + "access_token": token_response.get("access_token"), + "refresh_token": token_response.get("refresh_token", None), + "access_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("created_at") + + token_response.get("expires_in"), + tz=pytz.utc, + ) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp( + token_response.get("refresh_token_expired_at"), + tz=pytz.utc, + ) + if token_response.get("refresh_token_expired_at") + else None + ), + "id_token": token_response.get("id_token", ""), + } + ) + + def set_user_data(self): + user_info_response = self.get_user_response() + email = user_info_response.get("email") + super().set_user_data( + { + "email": email, + "user": { + "provider_id": user_info_response.get("id"), + "email": email, + "avatar": user_info_response.get("avatar_url"), + "first_name": user_info_response.get("name"), + "last_name": user_info_response.get("family_name"), + "is_password_autoset": True, + }, + } + ) diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index ee860f41f..a375d94cb 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -8,6 +8,8 @@ from .views import ( ChangePasswordEndpoint, # App EmailCheckEndpoint, + GitLabCallbackEndpoint, + GitLabOauthInitiateEndpoint, GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint, GoogleCallbackEndpoint, @@ -22,6 +24,8 @@ from .views import ( ResetPasswordSpaceEndpoint, # Space EmailCheckSpaceEndpoint, + GitLabCallbackSpaceEndpoint, + GitLabOauthInitiateSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceEndpoint, GoogleCallbackSpaceEndpoint, @@ -151,6 +155,27 @@ urlpatterns = [ GitHubCallbackSpaceEndpoint.as_view(), name="github-callback", ), + ## Gitlab Oauth + path( + "gitlab/", + GitLabOauthInitiateEndpoint.as_view(), + name="gitlab-initiate", + ), + path( + "gitlab/callback/", + GitLabCallbackEndpoint.as_view(), + name="gitlab-callback", + ), + path( + "spaces/gitlab/", + GitLabOauthInitiateSpaceEndpoint.as_view(), + name="gitlab-initiate", + ), + path( + "spaces/gitlab/callback/", + GitLabCallbackSpaceEndpoint.as_view(), + name="gitlab-callback", + ), # Email Check path( "email-check/", diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py index 8910ec637..3b6f231ed 100644 --- a/apiserver/plane/authentication/utils/workspace_project_join.py +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -4,6 +4,7 @@ from plane.db.models import ( WorkspaceMember, WorkspaceMemberInvite, ) +from plane.utils.cache import invalidate_cache_directly def process_workspace_project_invitations(user): @@ -26,6 +27,16 @@ def process_workspace_project_invitations(user): ignore_conflicts=True, ) + [ + invalidate_cache_directly( + path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/", + url_params=False, + user=False, + multiple=True, + ) + for workspace_member_invite in workspace_member_invites + ] + # Check if user has any project invites project_member_invites = ProjectMemberInvite.objects.filter( email=user.email, accepted=True diff --git a/apiserver/plane/authentication/views/__init__.py b/apiserver/plane/authentication/views/__init__.py index 51ea3e60a..af58a9cbd 100644 --- a/apiserver/plane/authentication/views/__init__.py +++ b/apiserver/plane/authentication/views/__init__.py @@ -14,6 +14,10 @@ from .app.github import ( GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint, ) +from .app.gitlab import ( + GitLabCallbackEndpoint, + GitLabOauthInitiateEndpoint, +) from .app.google import ( GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint, @@ -34,6 +38,11 @@ from .space.github import ( GitHubOauthInitiateSpaceEndpoint, ) +from .space.gitlab import ( + GitLabCallbackSpaceEndpoint, + GitLabOauthInitiateSpaceEndpoint, +) + from .space.google import ( GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint, diff --git a/apiserver/plane/authentication/views/app/check.py b/apiserver/plane/authentication/views/app/check.py index 5b3ac7337..6af8859fc 100644 --- a/apiserver/plane/authentication/views/app/check.py +++ b/apiserver/plane/authentication/views/app/check.py @@ -95,17 +95,7 @@ class EmailCheckEndpoint(APIView): # If existing user if existing_user: - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) - + # Return response return Response( { "existing": True, diff --git a/apiserver/plane/authentication/views/app/email.py b/apiserver/plane/authentication/views/app/email.py index f21e431a4..08a3e8b01 100644 --- a/apiserver/plane/authentication/views/app/email.py +++ b/apiserver/plane/authentication/views/app/email.py @@ -107,22 +107,6 @@ class SignInAuthEndpoint(View): ) return HttpResponseRedirect(url) - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), - ) - return HttpResponseRedirect(url) - try: provider = EmailProvider( request=request, @@ -222,22 +206,6 @@ class SignUpAuthEndpoint(View): if existing_user: # Existing User - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request, is_app=True), - "?" + urlencode(params), - ) - return HttpResponseRedirect(url) - exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/app/gitlab.py b/apiserver/plane/authentication/views/app/gitlab.py new file mode 100644 index 000000000..02a44aeb4 --- /dev/null +++ b/apiserver/plane/authentication/views/app/gitlab.py @@ -0,0 +1,131 @@ +import uuid +from urllib.parse import urlencode, urljoin + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.user_auth_workflow import ( + post_user_auth_workflow, +) +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AuthenticationException, + AUTHENTICATION_ERROR_CODES, +) + + +class GitLabOauthInitiateEndpoint(View): + + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_app=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + try: + state = uuid.uuid4().hex + provider = GitLabOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host(request=request, is_app=True), + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + +class GitLabCallbackEndpoint(View): + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITLAB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITLAB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) + + try: + provider = GitLabOAuthProvider( + request=request, + code=code, + callback=post_user_auth_workflow, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_app=True) + # Get the redirection path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + # redirect to referer path + url = urljoin(base_host, path) + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = urljoin( + base_host, + "?" + urlencode(params), + ) + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/app/magic.py b/apiserver/plane/authentication/views/app/magic.py index bb3c72534..8b2d1accd 100644 --- a/apiserver/plane/authentication/views/app/magic.py +++ b/apiserver/plane/authentication/views/app/magic.py @@ -112,22 +112,6 @@ class MagicSignInEndpoint(View): ) return HttpResponseRedirect(url) - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin( - base_host(request=request, is_app=True), - "sign-in?" + urlencode(params), - ) - return HttpResponseRedirect(url) - try: provider = MagicCodeProvider( request=request, diff --git a/apiserver/plane/authentication/views/space/check.py b/apiserver/plane/authentication/views/space/check.py index a86a29c09..560ae0e31 100644 --- a/apiserver/plane/authentication/views/space/check.py +++ b/apiserver/plane/authentication/views/space/check.py @@ -93,17 +93,7 @@ class EmailCheckSpaceEndpoint(APIView): # If existing user if existing_user: - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) - + # Return response return Response( { "existing": True, diff --git a/apiserver/plane/authentication/views/space/email.py b/apiserver/plane/authentication/views/space/email.py index 7a5613a75..4329ed26d 100644 --- a/apiserver/plane/authentication/views/space/email.py +++ b/apiserver/plane/authentication/views/space/email.py @@ -89,19 +89,6 @@ class SignInAuthSpaceEndpoint(View): url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" return HttpResponseRedirect(url) - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" - return HttpResponseRedirect(url) - try: provider = EmailProvider( request=request, key=email, code=password, is_signup=False @@ -178,19 +165,6 @@ class SignUpAuthSpaceEndpoint(View): existing_user = User.objects.filter(email=email).first() if existing_user: - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" - return HttpResponseRedirect(url) - exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"], error_message="USER_ALREADY_EXIST", diff --git a/apiserver/plane/authentication/views/space/gitlab.py b/apiserver/plane/authentication/views/space/gitlab.py new file mode 100644 index 000000000..7ebd9d187 --- /dev/null +++ b/apiserver/plane/authentication/views/space/gitlab.py @@ -0,0 +1,109 @@ +# Python imports +import uuid +from urllib.parse import urlencode + +# Django import +from django.http import HttpResponseRedirect +from django.views import View + +# Module imports +from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider +from plane.authentication.utils.login import user_login +from plane.license.models import Instance +from plane.authentication.utils.host import base_host +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) + + +class GitLabOauthInitiateSpaceEndpoint(View): + + def get(self, request): + # Get host and next path + request.session["host"] = base_host(request=request, is_space=True) + next_path = request.GET.get("next_path") + if next_path: + request.session["next_path"] = str(next_path) + + # Check instance configuration + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "INSTANCE_NOT_CONFIGURED" + ], + error_message="INSTANCE_NOT_CONFIGURED", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + state = uuid.uuid4().hex + provider = GitLabOAuthProvider(request=request, state=state) + request.session["state"] = state + auth_url = provider.get_auth_url() + return HttpResponseRedirect(auth_url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + +class GitLabCallbackSpaceEndpoint(View): + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + base_host = request.session.get("host") + next_path = request.session.get("next_path") + + if state != request.session.get("state", ""): + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITLAB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + if not code: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[ + "GITLAB_OAUTH_PROVIDER_ERROR" + ], + error_message="GITLAB_OAUTH_PROVIDER_ERROR", + ) + params = exc.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) + + try: + provider = GitLabOAuthProvider( + request=request, + code=code, + ) + user = provider.authenticate() + # Login the user and record his device info + user_login(request=request, user=user, is_space=True) + # Process workspace and project invitations + # redirect to referer path + url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + return HttpResponseRedirect(url) + except AuthenticationException as e: + params = e.get_error_dict() + if next_path: + params["next_path"] = str(next_path) + url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + return HttpResponseRedirect(url) diff --git a/apiserver/plane/authentication/views/space/magic.py b/apiserver/plane/authentication/views/space/magic.py index 0e859d44d..838039f96 100644 --- a/apiserver/plane/authentication/views/space/magic.py +++ b/apiserver/plane/authentication/views/space/magic.py @@ -20,7 +20,7 @@ from plane.authentication.utils.login import user_login from plane.bgtasks.magic_link_code_task import magic_link from plane.license.models import Instance from plane.authentication.utils.host import base_host -from plane.db.models import User, Profile +from plane.db.models import User from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, @@ -101,18 +101,6 @@ class MagicSignInSpaceEndpoint(View): return HttpResponseRedirect(url) # Active User - if not existing_user.is_active: - exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "USER_ACCOUNT_DEACTIVATED" - ], - error_message="USER_ACCOUNT_DEACTIVATED", - ) - params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" - return HttpResponseRedirect(url) try: provider = MagicCodeProvider( request=request, key=f"magic_{email}", code=code @@ -121,12 +109,7 @@ class MagicSignInSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - profile = Profile.objects.get(user=user) - if user.is_password_autoset and profile.is_onboarded: - path = "accounts/set-password" - else: - # Get the redirection path - path = str(next_path) if next_path else "" + path = str(next_path) if next_path else "" url = f"{base_host(request=request, is_space=True)}{path}" return HttpResponseRedirect(url) diff --git a/apiserver/plane/bgtasks/dummy_data_task.py b/apiserver/plane/bgtasks/dummy_data_task.py index e76cdac22..74e210de6 100644 --- a/apiserver/plane/bgtasks/dummy_data_task.py +++ b/apiserver/plane/bgtasks/dummy_data_task.py @@ -28,6 +28,7 @@ from plane.db.models import ( CycleIssue, ModuleIssue, Page, + ProjectPage, PageLabel, Inbox, InboxIssue, @@ -37,7 +38,7 @@ from plane.db.models import ( def create_project(workspace, user_id): fake = Faker() name = fake.name() - unique_id = str(uuid.uuid4())[:5] + unique_id = str(uuid.uuid4())[:5] project = Project.objects.create( workspace=workspace, @@ -244,7 +245,6 @@ def create_pages(workspace, project, user_id, pages_count): pages.append( Page( name=fake.name(), - project=project, workspace=workspace, owned_by_id=user_id, access=random.randint(0, 1), @@ -254,8 +254,20 @@ def create_pages(workspace, project, user_id, pages_count): is_locked=False, ) ) - - return Page.objects.bulk_create(pages, ignore_conflicts=True) + # Bulk create pages + pages = Page.objects.bulk_create(pages, ignore_conflicts=True) + # Add Page to project + ProjectPage.objects.bulk_create( + [ + ProjectPage( + page=page, + project=project, + workspace=workspace, + ) + for page in pages + ], + batch_size=1000, + ) def create_page_labels(workspace, project, user_id, pages_count): @@ -263,7 +275,9 @@ def create_page_labels(workspace, project, user_id, pages_count): labels = Label.objects.filter(project=project).values_list("id", flat=True) pages = random.sample( list( - Page.objects.filter(project=project).values_list("id", flat=True) + Page.objects.filter(projects__id=project.id).values_list( + "id", flat=True + ) ), int(pages_count / 2), ) @@ -278,7 +292,6 @@ def create_page_labels(workspace, project, user_id, pages_count): PageLabel( page_id=page, label_id=label, - project=project, workspace=workspace, ) ) @@ -293,8 +306,14 @@ def create_issues(workspace, project, user_id, issue_count): fake = Faker() Faker.seed(0) - states = State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True) - creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True) + states = ( + State.objects.filter(workspace=workspace, project=project) + .exclude(group="Triage") + .values_list("id", flat=True) + ) + creators = ProjectMember.objects.filter( + workspace=workspace, project=project + ).values_list("member_id", flat=True) issues = [] diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index c99836c83..cfb6853a7 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -69,26 +69,34 @@ def create_zip_file(files): def upload_to_s3(zip_file, workspace_id, token_id, slug): - file_name = ( - f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip" - ) + file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip" expires_in = 7 * 24 * 60 * 60 if settings.USE_MINIO: - s3 = boto3.client( + upload_s3 = boto3.client( "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL, aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=Config(signature_version="s3v4"), ) - s3.upload_fileobj( + upload_s3.upload_fileobj( zip_file, settings.AWS_STORAGE_BUCKET_NAME, file_name, ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, ) - presigned_url = s3.generate_presigned_url( + + # Generate presigned url for the uploaded file with different base + presign_s3 = boto3.client( + "s3", + endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + presigned_url = presign_s3.generate_presigned_url( "get_object", Params={ "Bucket": settings.AWS_STORAGE_BUCKET_NAME, @@ -96,19 +104,27 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): }, ExpiresIn=expires_in, ) - # Create the new url with updated domain and protocol - presigned_url = presigned_url.replace( - f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}/", - f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/", - ) else: - s3 = boto3.client( - "s3", - region_name=settings.AWS_REGION, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - config=Config(signature_version="s3v4"), - ) + + # If endpoint url is present, use it + if settings.AWS_S3_ENDPOINT_URL: + s3 = boto3.client( + "s3", + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + else: + s3 = boto3.client( + "s3", + region_name=settings.AWS_REGION, + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + config=Config(signature_version="s3v4"), + ) + + # Upload the file to S3 s3.upload_fileobj( zip_file, settings.AWS_STORAGE_BUCKET_NAME, @@ -116,6 +132,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"}, ) + # Generate presigned url for the uploaded file presigned_url = s3.generate_presigned_url( "get_object", Params={ @@ -127,6 +144,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug): exporter_instance = ExporterHistory.objects.get(token=token_id) + # Update the exporter instance with the presigned url if presigned_url: exporter_instance.url = presigned_url exporter_instance.status = "completed" diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 007b3e48c..67cda14af 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -28,6 +28,7 @@ from plane.db.models import ( Project, State, User, + EstimatePoint, ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception @@ -448,21 +449,37 @@ def track_estimate_points( if current_instance.get("estimate_point") != requested_data.get( "estimate_point" ): + old_estimate = ( + EstimatePoint.objects.filter( + pk=current_instance.get("estimate_point") + ).first() + if current_instance.get("estimate_point") is not None + else None + ) + new_estimate = ( + EstimatePoint.objects.filter( + pk=requested_data.get("estimate_point") + ).first() + if requested_data.get("estimate_point") is not None + else None + ) issue_activities.append( IssueActivity( issue_id=issue_id, actor_id=actor_id, verb="updated", - old_value=( + old_identifier=( current_instance.get("estimate_point") if current_instance.get("estimate_point") is not None - else "" + else None ), - new_value=( + new_identifier=( requested_data.get("estimate_point") if requested_data.get("estimate_point") is not None - else "" + else None ), + old_value=old_estimate.value if old_estimate else None, + new_value=new_estimate.value if new_estimate else None, field="estimate_point", project_id=project_id, workspace_id=workspace_id, diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py index eceb3693e..e3cf81a6e 100644 --- a/apiserver/plane/bgtasks/page_transaction_task.py +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -59,7 +59,6 @@ def page_transaction(new_value, old_value, page_id): entity_identifier=mention["entity_identifier"], entity_name=mention["entity_name"], workspace_id=page.workspace_id, - project_id=page.project_id, created_at=timezone.now(), updated_at=timezone.now(), ) diff --git a/apiserver/plane/bgtasks/project_add_user_email_task.py b/apiserver/plane/bgtasks/project_add_user_email_task.py new file mode 100644 index 000000000..c8308465a --- /dev/null +++ b/apiserver/plane/bgtasks/project_add_user_email_task.py @@ -0,0 +1,87 @@ +# Python imports +import logging + +# Third party imports +from celery import shared_task + +# Third party imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + + +# Module imports +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception +from plane.db.models import ProjectMember +from plane.db.models import User + + +@shared_task +def project_add_user_email(current_site, project_member_id, invitor_id): + try: + # Get the invitor + invitor = User.objects.get(pk=invitor_id) + inviter_first_name = invitor.first_name + # Get the project member + project_member = ProjectMember.objects.get(pk=project_member_id) + # Get the project member details + project_name = project_member.project.name + workspace_name = project_member.workspace.name + member_email = project_member.member.email + project_url = f"{current_site}/{project_member.workspace.slug}/projects/{project_member.project_id}/issues" + # set the context + context = { + "project_name": project_name, + "workspace_name": workspace_name, + "email": member_email, + "inviter_first_name": inviter_first_name, + "project_url": project_url, + } + + # Get the email configuration + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + # Set the subject + subject = "You have been invited to a Plane project" + + # Render the email template + html_content = render_to_string( + "emails/notifications/project_addition.html", context + ) + text_content = strip_tags(html_content) + # Initialize the connection + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + # Send the email + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[member_email], + connection=connection, + ) + # Attach the html content + msg.attach_alternative(html_content, "text/html") + # Send the email + msg.send() + # Log the success + logging.getLogger("plane").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index b60c49da1..84ef237ef 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -5,6 +5,7 @@ import logging from celery import shared_task # Django imports +# Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags diff --git a/apiserver/plane/bgtasks/user_activation_email_task.py b/apiserver/plane/bgtasks/user_activation_email_task.py new file mode 100644 index 000000000..2fdfc4ddb --- /dev/null +++ b/apiserver/plane/bgtasks/user_activation_email_task.py @@ -0,0 +1,70 @@ +# Python imports +import logging + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def user_activation_email(current_site, user_id): + try: + # Send email to user when account is activated + user = User.objects.get(id=user_id) + subject = f"{user.first_name or user.display_name or user.email} has been activated on Plane" + + context = { + "email": str(user.email), + "profile_url": current_site + "/profile", + } + + # Send email to user + html_content = render_to_string( + "emails/user/user_activation.html", context + ) + + text_content = strip_tags(html_content) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[user.email], + connection=connection, + ) + + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/bgtasks/user_deactivation_email_task.py b/apiserver/plane/bgtasks/user_deactivation_email_task.py new file mode 100644 index 000000000..fa8523d50 --- /dev/null +++ b/apiserver/plane/bgtasks/user_deactivation_email_task.py @@ -0,0 +1,72 @@ +# Python imports +import logging + +# Django imports +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User +from plane.license.utils.instance_value import get_email_configuration +from plane.utils.exception_logger import log_exception + + +@shared_task +def user_deactivation_email(current_site, user_id): + try: + # Send email to user when account is deactivated + user = User.objects.get(id=user_id) + subject = f"{user.first_name or user.display_name or user.email} has been deactivated on Plane" + + context = { + "email": str(user.email), + "login_url": current_site + "/login", + } + + # Send email to user + html_content = render_to_string( + "emails/user/user_deactivation.html", context + ) + + text_content = strip_tags(html_content) + # Configure email connection from the database + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_USE_SSL, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + use_ssl=EMAIL_USE_SSL == "1", + ) + + # Send email + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[user.email], + connection=connection, + ) + + # Attach HTML content + msg.attach_alternative(html_content, "text/html") + msg.send() + logging.getLogger("plane").info("Email sent successfully.") + return + except Exception as e: + log_exception(e) + return diff --git a/apiserver/plane/db/migrations/0067_issue_estimate.py b/apiserver/plane/db/migrations/0067_issue_estimate.py new file mode 100644 index 000000000..b341f9864 --- /dev/null +++ b/apiserver/plane/db/migrations/0067_issue_estimate.py @@ -0,0 +1,260 @@ +# # Generated by Django 4.2.7 on 2024-05-24 09:47 +# Python imports +import uuid +from uuid import uuid4 +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +import plane.db.models.deploy_board + + +def issue_estimate_point(apps, schema_editor): + Issue = apps.get_model("db", "Issue") + Project = apps.get_model("db", "Project") + EstimatePoint = apps.get_model("db", "EstimatePoint") + IssueActivity = apps.get_model("db", "IssueActivity") + updated_estimate_point = [] + updated_issue_activity = [] + + # loop through all the projects + for project in Project.objects.filter(estimate__isnull=False): + estimate_points = EstimatePoint.objects.filter( + estimate=project.estimate, project=project + ) + + for issue_activity in IssueActivity.objects.filter( + field="estimate_point", project=project + ): + if issue_activity.new_value: + new_identifier = estimate_points.filter( + key=issue_activity.new_value + ).first().id + issue_activity.new_identifier = new_identifier + new_value = estimate_points.filter( + key=issue_activity.new_value + ).first().value + issue_activity.new_value = new_value + + if issue_activity.old_value: + old_identifier = estimate_points.filter( + key=issue_activity.old_value + ).first().id + issue_activity.old_identifier = old_identifier + old_value = estimate_points.filter( + key=issue_activity.old_value + ).first().value + issue_activity.old_value = old_value + updated_issue_activity.append(issue_activity) + + for issue in Issue.objects.filter( + point__isnull=False, project=project + ): + # get the estimate id for the corresponding estimate point in the issue + estimate = estimate_points.filter(key=issue.point).first() + issue.estimate_point = estimate + updated_estimate_point.append(issue) + + Issue.objects.bulk_update( + updated_estimate_point, ["estimate_point"], batch_size=1000 + ) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value", "new_identifier", "old_identifier"], + batch_size=1000, + ) + + +def last_used_estimate(apps, schema_editor): + Project = apps.get_model("db", "Project") + Estimate = apps.get_model("db", "Estimate") + + # Get all estimate ids used in projects + estimate_ids = Project.objects.filter(estimate__isnull=False).values_list( + "estimate", flat=True + ) + + # Update all matching estimates + Estimate.objects.filter(id__in=estimate_ids).update(last_used=True) + + +def populate_deploy_board(apps, schema_editor): + DeployBoard = apps.get_model("db", "DeployBoard") + ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard") + + DeployBoard.objects.bulk_create( + [ + DeployBoard( + entity_identifier=deploy_board.project_id, + project_id=deploy_board.project_id, + entity_name="project", + anchor=uuid4().hex, + is_comments_enabled=deploy_board.comments, + is_reactions_enabled=deploy_board.reactions, + inbox=deploy_board.inbox, + is_votes_enabled=deploy_board.votes, + view_props=deploy_board.views, + workspace_id=deploy_board.workspace_id, + created_at=deploy_board.created_at, + updated_at=deploy_board.updated_at, + created_by_id=deploy_board.created_by_id, + updated_by_id=deploy_board.updated_by_id, + ) + for deploy_board in ProjectDeployBoard.objects.all() + ], + batch_size=100, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0066_account_id_token_cycle_logo_props_module_logo_props"), + ] + + operations = [ + migrations.CreateModel( + name="DeployBoard", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ( + "entity_name", + models.CharField( + choices=[ + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ], + max_length=30, + ), + ), + ( + "anchor", + models.CharField( + db_index=True, + default=plane.db.models.deploy_board.get_anchor, + max_length=255, + unique=True, + ), + ), + ("is_comments_enabled", models.BooleanField(default=False)), + ("is_reactions_enabled", models.BooleanField(default=False)), + ("is_votes_enabled", models.BooleanField(default=False)), + ("view_props", models.JSONField(default=dict)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "inbox", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="board_inbox", + to="db.inbox", + ), + ), + ( + "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": "Deploy Board", + "verbose_name_plural": "Deploy Boards", + "db_table": "deploy_boards", + "ordering": ("-created_at",), + "unique_together": {("entity_name", "entity_identifier")}, + }, + ), + migrations.AddField( + model_name="estimate", + name="last_used", + field=models.BooleanField(default=False), + ), + # Rename the existing field + migrations.RenameField( + model_name="issue", + old_name="estimate_point", + new_name="point", + ), + # Add a new field with the original name as a foreign key + migrations.AddField( + model_name="issue", + name="estimate_point", + field=models.ForeignKey( + on_delete=django.db.models.deletion.SET_NULL, + related_name="issue_estimates", + to="db.EstimatePoint", + blank=True, + null=True, + ), + ), + migrations.AlterField( + model_name="estimate", + name="type", + field=models.CharField(default="categories", max_length=255), + ), + migrations.AlterField( + model_name="estimatepoint", + name="value", + field=models.CharField(max_length=255), + ), + migrations.RunPython(issue_estimate_point), + migrations.RunPython(last_used_estimate), + migrations.RunPython(populate_deploy_board), + ] diff --git a/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py new file mode 100644 index 000000000..50475c2a8 --- /dev/null +++ b/apiserver/plane/db/migrations/0068_remove_pagelabel_project_remove_pagelog_project_and_more.py @@ -0,0 +1,257 @@ +# Generated by Django 4.2.11 on 2024-06-07 12:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +def migrate_pages(apps, schema_editor): + ProjectPage = apps.get_model("db", "ProjectPage") + Page = apps.get_model("db", "Page") + ProjectPage.objects.bulk_create( + [ + ProjectPage( + workspace_id=page.get("workspace_id"), + project_id=page.get("project_id"), + page_id=page.get("id"), + created_by_id=page.get("created_by_id"), + updated_by_id=page.get("updated_by_id"), + ) + for page in Page.objects.values( + "workspace_id", + "project_id", + "id", + "created_by_id", + "updated_by_id", + ) + ], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0067_issue_estimate"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="is_global", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="ProjectPage", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + to="db.page", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_pages", + 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="project_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Page", + "verbose_name_plural": "Project Pages", + "db_table": "project_pages", + "ordering": ("-created_at",), + "unique_together": {("project", "page")}, + }, + ), + migrations.CreateModel( + name="TeamPage", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.page", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="team_pages", + to="db.team", + ), + ), + ( + "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="team_pages", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Team Page", + "verbose_name_plural": "Team Pages", + "db_table": "team_pages", + "ordering": ("-created_at",), + "unique_together": {("team", "page")}, + }, + ), + migrations.AddField( + model_name="page", + name="projects", + field=models.ManyToManyField( + related_name="pages", through="db.ProjectPage", to="db.project" + ), + ), + migrations.AddField( + model_name="page", + name="teams", + field=models.ManyToManyField( + related_name="pages", through="db.TeamPage", to="db.team" + ), + ), + migrations.RunPython(migrate_pages), + migrations.RemoveField( + model_name="page", + name="project", + ), + migrations.AlterField( + model_name="page", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pages", + to="db.workspace", + ), + ), + migrations.RemoveField( + model_name="pagelabel", + name="project", + ), + migrations.RemoveField( + model_name="pagelog", + name="project", + ), + migrations.AlterField( + model_name="pagelabel", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_label", + to="db.workspace", + ), + ), + migrations.AlterField( + model_name="pagelog", + name="workspace", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_page_log", + to="db.workspace", + ), + ), + ] diff --git a/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py b/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py new file mode 100644 index 000000000..8c806abab --- /dev/null +++ b/apiserver/plane/db/migrations/0069_alter_account_provider_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 4.2.11 on 2024-06-03 17:16 + +from django.db import migrations, models +from django.conf import settings +from django.db.models import F +import django.db.models.deletion + + +def populate_views_owned_by(apps, schema_editor): + IssueView = apps.get_model("db", "IssueView") + + # update all existing views to be owned by the user who created them + IssueView.objects.update(owned_by_id=F("created_by_id")) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0068_remove_pagelabel_project_remove_pagelog_project_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name="account", + name="provider", + field=models.CharField( + choices=[ + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ] + ), + ), + migrations.AlterField( + model_name="socialloginconnection", + name="medium", + field=models.CharField( + choices=[ + ("Google", "google"), + ("Github", "github"), + ("GitLab", "gitlab"), + ("Jira", "jira"), + ], + default=None, + max_length=20, + ), + ), + migrations.AddField( + model_name="issueview", + name="is_locked", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="issueview", + name="owned_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), + migrations.RunPython(populate_views_owned_by), + migrations.AlterField( + model_name="issueview", + name="owned_by", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="views", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index b11ce7aa3..38586791d 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -4,6 +4,7 @@ from .asset import FileAsset from .base import BaseModel from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget +from .deploy_board import DeployBoard from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer @@ -49,22 +50,22 @@ from .notification import ( Notification, UserNotificationPreference, ) -from .page import Page, PageFavorite, PageLabel, PageLog +from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage from .project import ( Project, ProjectBaseModel, - ProjectDeployBoard, ProjectFavorite, ProjectIdentifier, ProjectMember, ProjectMemberInvite, ProjectPublicMember, ) +from .deploy_board import DeployBoard from .session import Session from .social_connection import SocialLoginConnection from .state import State from .user import Account, Profile, User -from .view import GlobalView, IssueView, IssueViewFavorite +from .view import IssueView, IssueViewFavorite from .webhook import Webhook, WebhookLog from .workspace import ( Team, diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 7dd2f2c91..86e5ceef8 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -12,6 +12,7 @@ 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}" diff --git a/apiserver/plane/db/models/deploy_board.py b/apiserver/plane/db/models/deploy_board.py new file mode 100644 index 000000000..41ffbc7c1 --- /dev/null +++ b/apiserver/plane/db/models/deploy_board.py @@ -0,0 +1,53 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +def get_anchor(): + return uuid4().hex + + +class DeployBoard(WorkspaceBaseModel): + TYPE_CHOICES = ( + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ) + + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=TYPE_CHOICES, + ) + anchor = models.CharField( + max_length=255, default=get_anchor, unique=True, db_index=True + ) + is_comments_enabled = models.BooleanField(default=False) + is_reactions_enabled = models.BooleanField(default=False) + inbox = models.ForeignKey( + "db.Inbox", + related_name="board_inbox", + on_delete=models.SET_NULL, + null=True, + ) + is_votes_enabled = models.BooleanField(default=False) + view_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the deploy board""" + return f"{self.entity_identifier} <{self.entity_name}>" + + class Meta: + unique_together = ["entity_name", "entity_identifier"] + verbose_name = "Deploy Board" + verbose_name_plural = "Deploy Boards" + db_table = "deploy_boards" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index 6ff1186c3..0713d774f 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel): description = models.TextField( verbose_name="Estimate Description", blank=True ) - type = models.CharField(max_length=255, default="Categories") + type = models.CharField(max_length=255, default="categories") + last_used = models.BooleanField(default=False) def __str__(self): """Return name of the estimate""" @@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel): default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) description = models.TextField(blank=True) - value = models.CharField(max_length=20) + value = models.CharField(max_length=255) def __str__(self): """Return name of the estimate""" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 527597ddc..e82b974ed 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -91,6 +91,7 @@ class IssueManager(models.Manager): | models.Q(issue_inbox__status=2) | models.Q(issue_inbox__isnull=True) ) + .filter(state__is_triage=False) .exclude(archived_at__isnull=False) .exclude(project__archived_at__isnull=False) .exclude(is_draft=True) @@ -119,11 +120,18 @@ class Issue(ProjectBaseModel): blank=True, related_name="state_issue", ) - estimate_point = models.IntegerField( + point = models.IntegerField( validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True, ) + estimate_point = models.ForeignKey( + "db.EstimatePoint", + on_delete=models.SET_NULL, + related_name="issue_estimates", + null=True, + blank=True, + ) name = models.CharField(max_length=255, verbose_name="Issue Name") description = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index e079dcbe5..9a8b3078d 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -9,13 +9,17 @@ from django.db import models from plane.utils.html_processor import strip_tags from .project import ProjectBaseModel +from .base import BaseModel def get_view_props(): return {"full_width": False} -class Page(ProjectBaseModel): +class Page(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="pages" + ) name = models.CharField(max_length=255, blank=True) description = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) @@ -44,6 +48,13 @@ class Page(ProjectBaseModel): is_locked = models.BooleanField(default=False) view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) + is_global = models.BooleanField(default=False) + projects = models.ManyToManyField( + "db.Project", related_name="pages", through="db.ProjectPage" + ) + teams = models.ManyToManyField( + "db.Team", related_name="pages", through="db.TeamPage" + ) class Meta: verbose_name = "Page" @@ -56,7 +67,7 @@ class Page(ProjectBaseModel): return f"{self.owned_by.email} <{self.name}>" -class PageLog(ProjectBaseModel): +class PageLog(BaseModel): TYPE_CHOICES = ( ("to_do", "To Do"), ("issue", "issue"), @@ -81,6 +92,9 @@ class PageLog(ProjectBaseModel): choices=TYPE_CHOICES, verbose_name="Transaction Type", ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" + ) class Meta: unique_together = ["page", "transaction"] @@ -171,13 +185,18 @@ class PageFavorite(ProjectBaseModel): return f"{self.user.email} <{self.page.name}>" -class PageLabel(ProjectBaseModel): +class PageLabel(BaseModel): label = models.ForeignKey( "db.Label", on_delete=models.CASCADE, related_name="page_labels" ) page = models.ForeignKey( "db.Page", on_delete=models.CASCADE, related_name="page_labels" ) + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_page_label", + ) class Meta: verbose_name = "Page Label" @@ -187,3 +206,44 @@ class PageLabel(ProjectBaseModel): def __str__(self): return f"{self.page.name} {self.label.name}" + + +class ProjectPage(BaseModel): + project = models.ForeignKey( + "db.Project", on_delete=models.CASCADE, related_name="project_pages" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="project_pages" + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="project_pages" + ) + + class Meta: + unique_together = ["project", "page"] + verbose_name = "Project Page" + verbose_name_plural = "Project Pages" + db_table = "project_pages" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.project.name} {self.page.name}" + + +class TeamPage(BaseModel): + team = models.ForeignKey( + "db.Team", on_delete=models.CASCADE, related_name="team_pages" + ) + page = models.ForeignKey( + "db.Page", on_delete=models.CASCADE, related_name="team_pages" + ) + workspace = models.ForeignKey( + "db.Workspace", on_delete=models.CASCADE, related_name="team_pages" + ) + + class Meta: + unique_together = ["team", "page"] + verbose_name = "Team Page" + verbose_name_plural = "Team Pages" + db_table = "team_pages" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 49fca1323..ba8dbf580 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -260,6 +260,8 @@ def get_default_views(): } +# DEPRECATED TODO: +# used to get the old anchors for the project deploy boards class ProjectDeployBoard(ProjectBaseModel): anchor = models.CharField( max_length=255, default=get_anchor, unique=True, db_index=True diff --git a/apiserver/plane/db/models/social_connection.py b/apiserver/plane/db/models/social_connection.py index 96fbbb967..2a21c55fd 100644 --- a/apiserver/plane/db/models/social_connection.py +++ b/apiserver/plane/db/models/social_connection.py @@ -10,7 +10,7 @@ from .base import BaseModel class SocialLoginConnection(BaseModel): medium = models.CharField( max_length=20, - choices=(("Google", "google"), ("Github", "github"), ("Jira", "jira")), + choices=(("Google", "google"), ("Github", "github"), ("GitLab", "gitlab"), ("Jira", "jira")), default=None, ) last_login_at = models.DateTimeField(default=timezone.now, null=True) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index c083b631c..2a88df8b6 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -182,7 +182,7 @@ class Account(TimeAuditModel): ) provider_account_id = models.CharField(max_length=255) provider = models.CharField( - choices=(("google", "Google"), ("github", "Github")), + 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 8916bd406..3822b531e 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -102,6 +102,13 @@ class IssueView(WorkspaceBaseModel): ) sort_order = models.FloatField(default=65535) logo_props = models.JSONField(default=dict) + owned_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="views", + ) + is_locked = models.BooleanField(default=False) + class Meta: verbose_name = "Issue View" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index f9cd681ec..f10d1ce6d 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -5,6 +5,7 @@ from django.db import models # Module imports from .base import BaseModel +from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS ROLE_CHOICES = ( (20, "Owner"), @@ -112,19 +113,7 @@ def get_issue_props(): def slug_validator(value): - if value in [ - "404", - "accounts", - "api", - "create-workspace", - "god-mode", - "installations", - "invitations", - "onboarding", - "profile", - "spaces", - "workspace-invitations", - ]: + if value in RESTRICTED_WORKSPACE_SLUGS: raise ValidationError("Slug is not valid") diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 1ec09fbb5..8d885083a 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -54,6 +54,7 @@ class InstanceEndpoint(BaseAPIView): IS_GOOGLE_ENABLED, IS_GITHUB_ENABLED, GITHUB_APP_NAME, + IS_GITLAB_ENABLED, EMAIL_HOST, ENABLE_MAGIC_LINK_LOGIN, ENABLE_EMAIL_PASSWORD, @@ -76,6 +77,10 @@ class InstanceEndpoint(BaseAPIView): "key": "GITHUB_APP_NAME", "default": os.environ.get("GITHUB_APP_NAME", ""), }, + { + "key": "IS_GITLAB_ENABLED", + "default": os.environ.get("IS_GITLAB_ENABLED", "0"), + }, { "key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", ""), @@ -115,6 +120,7 @@ class InstanceEndpoint(BaseAPIView): # Authentication data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1" data["is_github_enabled"] = IS_GITHUB_ENABLED == "1" + data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1" data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1" data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1" diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py index 5a6eadc2e..7f61024f9 100644 --- a/apiserver/plane/license/management/commands/configure_instance.py +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -2,7 +2,7 @@ import os # Django imports -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError # Module imports from plane.license.models import InstanceConfiguration @@ -15,6 +15,12 @@ class Command(BaseCommand): from plane.license.utils.encryption import encrypt_data from plane.license.utils.instance_value import get_configuration_value + mandatory_keys = ["SECRET_KEY"] + + for item in mandatory_keys: + if not os.environ.get(item): + raise CommandError(f"{item} env variable is required.") + config_keys = [ # Authentication Settings { @@ -59,6 +65,24 @@ class Command(BaseCommand): "category": "GITHUB", "is_encrypted": True, }, + { + "key": "GITLAB_HOST", + "value": os.environ.get("GITLAB_HOST"), + "category": "GITLAB", + "is_encrypted": False, + }, + { + "key": "GITLAB_CLIENT_ID", + "value": os.environ.get("GITLAB_CLIENT_ID"), + "category": "GITLAB", + "is_encrypted": False, + }, + { + "key": "GITLAB_CLIENT_SECRET", + "value": os.environ.get("GITLAB_CLIENT_SECRET"), + "category": "GITLAB", + "is_encrypted": True, + }, { "key": "EMAIL_HOST", "value": os.environ.get("EMAIL_HOST", ""), @@ -145,7 +169,7 @@ class Command(BaseCommand): ) ) - keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED"] + keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"] if not InstanceConfiguration.objects.filter(key__in=keys).exists(): for key in keys: if key == "IS_GOOGLE_ENABLED": @@ -216,6 +240,46 @@ class Command(BaseCommand): f"{key} loaded with value from environment variable." ) ) + if key == "IS_GITLAB_ENABLED": + GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = ( + get_configuration_value( + [ + { + "key": "GITLAB_HOST", + "default": os.environ.get( + "GITLAB_HOST", "https://gitlab.com" + ), + }, + { + "key": "GITLAB_CLIENT_ID", + "default": os.environ.get( + "GITLAB_CLIENT_ID", "" + ), + }, + { + "key": "GITLAB_CLIENT_SECRET", + "default": os.environ.get( + "GITLAB_CLIENT_SECRET", "" + ), + }, + ] + ) + ) + if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET): + value = "1" + else: + value = "0" + InstanceConfiguration.objects.create( + key="IS_GITLAB_ENABLED", + value=value, + category="AUTHENTICATION", + is_encrypted=False, + ) + self.stdout.write( + self.style.SUCCESS( + f"{key} loaded with value from environment variable." + ) + ) else: for key in keys: self.stdout.write( diff --git a/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py new file mode 100644 index 000000000..8d7b9a402 --- /dev/null +++ b/apiserver/plane/license/migrations/0003_alter_changelog_title_alter_changelog_version_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.11 on 2024-06-05 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0002_rename_version_instance_current_version_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="changelog", + name="title", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="changelog", + name="version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="current_version", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="instance", + name="latest_version", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="namespace", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="instance", + name="product", + field=models.CharField(default="plane-ce", max_length=255), + ), + ] diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py index ea88ba9bb..0c0581c8b 100644 --- a/apiserver/plane/license/models/instance.py +++ b/apiserver/plane/license/models/instance.py @@ -21,15 +21,15 @@ class Instance(BaseModel): whitelist_emails = models.TextField(blank=True, null=True) instance_id = models.CharField(max_length=255, unique=True) license_key = models.CharField(max_length=256, null=True, blank=True) - current_version = models.CharField(max_length=10) - latest_version = models.CharField(max_length=10, null=True, blank=True) + current_version = models.CharField(max_length=255) + latest_version = models.CharField(max_length=255, null=True, blank=True) product = models.CharField( - max_length=50, default=ProductTypes.PLANE_CE.value + max_length=255, default=ProductTypes.PLANE_CE.value ) domain = models.TextField(blank=True) # Instance specifics last_checked_at = models.DateTimeField() - namespace = models.CharField(max_length=50, blank=True, null=True) + namespace = models.CharField(max_length=255, blank=True, null=True) # telemetry and support is_telemetry_enabled = models.BooleanField(default=True) is_support_required = models.BooleanField(default=True) @@ -86,9 +86,9 @@ class InstanceConfiguration(BaseModel): class ChangeLog(BaseModel): """Change Log model to store the release changelogs made in the application.""" - title = models.CharField(max_length=100) + title = models.CharField(max_length=255) description = models.TextField(blank=True) - version = models.CharField(max_length=100) + version = models.CharField(max_length=255) tags = models.JSONField(default=list) release_date = models.DateTimeField(null=True) is_release_candidate = models.BooleanField(default=False) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 40128f9ad..937ff0c2a 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -16,6 +16,7 @@ from django.core.management.utils import get_random_secret_key from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration +from corsheaders.defaults import default_headers BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -127,6 +128,8 @@ else: CORS_ALLOW_ALL_ORIGINS = True secure_origins = False +CORS_ALLOW_HEADERS = [*default_headers, "X-API-Key"] + # Application Settings WSGI_APPLICATION = "plane.wsgi.application" ASGI_APPLICATION = "plane.asgi.application" diff --git a/apiserver/plane/space/urls/inbox.py b/apiserver/plane/space/urls/inbox.py index 60de040e2..20ebb3437 100644 --- a/apiserver/plane/space/urls/inbox.py +++ b/apiserver/plane/space/urls/inbox.py @@ -10,7 +10,7 @@ from plane.space.views import ( urlpatterns = [ path( - "workspaces//project-boards//inboxes//inbox-issues/", + "anchor//inboxes//inbox-issues/", InboxIssuePublicViewSet.as_view( { "get": "list", @@ -20,7 +20,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//project-boards//inboxes//inbox-issues//", + "anchor//inboxes//inbox-issues//", InboxIssuePublicViewSet.as_view( { "get": "retrieve", @@ -31,7 +31,7 @@ urlpatterns = [ name="inbox-issue", ), path( - "workspaces//project-boards//issues//votes/", + "anchor//issues//votes/", IssueVotePublicViewSet.as_view( { "get": "list", diff --git a/apiserver/plane/space/urls/issue.py b/apiserver/plane/space/urls/issue.py index 099eace5d..61c19ba01 100644 --- a/apiserver/plane/space/urls/issue.py +++ b/apiserver/plane/space/urls/issue.py @@ -10,12 +10,12 @@ from plane.space.views import ( urlpatterns = [ path( - "workspaces//project-boards//issues//", + "anchor//issues//", IssueRetrievePublicEndpoint.as_view(), name="workspace-project-boards", ), path( - "workspaces//project-boards//issues//comments/", + "anchor//issues//comments/", IssueCommentPublicViewSet.as_view( { "get": "list", @@ -25,7 +25,7 @@ urlpatterns = [ name="issue-comments-project-board", ), path( - "workspaces//project-boards//issues//comments//", + "anchor//issues//comments//", IssueCommentPublicViewSet.as_view( { "get": "retrieve", @@ -36,7 +36,7 @@ urlpatterns = [ name="issue-comments-project-board", ), path( - "workspaces//project-boards//issues//reactions/", + "anchor//issues//reactions/", IssueReactionPublicViewSet.as_view( { "get": "list", @@ -46,7 +46,7 @@ urlpatterns = [ name="issue-reactions-project-board", ), path( - "workspaces//project-boards//issues//reactions//", + "anchor//issues//reactions//", IssueReactionPublicViewSet.as_view( { "delete": "destroy", @@ -55,7 +55,7 @@ urlpatterns = [ name="issue-reactions-project-board", ), path( - "workspaces//project-boards//comments//reactions/", + "anchor//comments//reactions/", CommentReactionPublicViewSet.as_view( { "get": "list", @@ -65,7 +65,7 @@ urlpatterns = [ name="comment-reactions-project-board", ), path( - "workspaces//project-boards//comments//reactions//", + "anchor//comments//reactions//", CommentReactionPublicViewSet.as_view( { "delete": "destroy", diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py index dc97b43a7..3294b01f6 100644 --- a/apiserver/plane/space/urls/project.py +++ b/apiserver/plane/space/urls/project.py @@ -4,17 +4,23 @@ from django.urls import path from plane.space.views import ( ProjectDeployBoardPublicSettingsEndpoint, ProjectIssuesPublicEndpoint, + WorkspaceProjectAnchorEndpoint, ) urlpatterns = [ path( - "workspaces//project-boards//settings/", + "anchor//settings/", ProjectDeployBoardPublicSettingsEndpoint.as_view(), name="project-deploy-board-settings", ), path( - "workspaces//project-boards//issues/", + "anchor//issues/", ProjectIssuesPublicEndpoint.as_view(), name="project-deploy-board", ), + path( + "workspaces//projects//anchor/", + WorkspaceProjectAnchorEndpoint.as_view(), + name="project-deploy-board", + ), ] diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py index 5130e04d5..eced7d1b4 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -1,6 +1,7 @@ from .project import ( ProjectDeployBoardPublicSettingsEndpoint, WorkspaceProjectDeployBoardEndpoint, + WorkspaceProjectAnchorEndpoint, ) from .issue import ( diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 9f681c160..b89c77672 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -18,7 +18,7 @@ from plane.db.models import ( State, IssueLink, IssueAttachment, - ProjectDeployBoard, + DeployBoard, ) from plane.app.serializers import ( IssueSerializer, @@ -39,7 +39,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ] def get_queryset(self): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -58,9 +58,9 @@ class InboxIssuePublicViewSet(BaseViewSet): ) return InboxIssue.objects.none() - def list(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def list(self, request, anchor, inbox_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -72,8 +72,8 @@ class InboxIssuePublicViewSet(BaseViewSet): issues = ( Issue.objects.filter( issue_inbox__inbox_id=inbox_id, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, ) .filter(**filters) .annotate(bridge_id=F("issue_inbox__id")) @@ -117,9 +117,9 @@ class InboxIssuePublicViewSet(BaseViewSet): status=status.HTTP_200_OK, ) - def create(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, inbox_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -151,7 +151,7 @@ class InboxIssuePublicViewSet(BaseViewSet): name="Triage", group="backlog", description="Default state for managing all Inbox Issues", - project_id=project_id, + project_id=project_deploy_board.project_id, color="#ff7700", ) @@ -163,7 +163,7 @@ class InboxIssuePublicViewSet(BaseViewSet): "description_html", "

" ), priority=request.data.get("issue", {}).get("priority", "low"), - project_id=project_id, + project_id=project_deploy_board.project_id, state=state, ) @@ -173,14 +173,14 @@ class InboxIssuePublicViewSet(BaseViewSet): requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue.id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) # create an inbox issue InboxIssue.objects.create( inbox_id=inbox_id, - project_id=project_id, + project_id=project_deploy_board.project_id, issue=issue, source=request.data.get("source", "in-app"), ) @@ -188,9 +188,9 @@ class InboxIssuePublicViewSet(BaseViewSet): serializer = IssueStateInboxSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - def partial_update(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def partial_update(self, request, anchor, inbox_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -200,8 +200,8 @@ class InboxIssuePublicViewSet(BaseViewSet): inbox_issue = InboxIssue.objects.get( pk=pk, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, inbox_id=inbox_id, ) # Get the project member @@ -216,8 +216,8 @@ class InboxIssuePublicViewSet(BaseViewSet): issue = Issue.objects.get( pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, ) # viewers and guests since only viewers and guests issue_data = { @@ -242,7 +242,7 @@ class InboxIssuePublicViewSet(BaseViewSet): requested_data=requested_data, actor_id=str(request.user.id), issue_id=str(issue.id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, @@ -255,9 +255,9 @@ class InboxIssuePublicViewSet(BaseViewSet): issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - def retrieve(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def retrieve(self, request, anchor, inbox_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -267,21 +267,21 @@ class InboxIssuePublicViewSet(BaseViewSet): inbox_issue = InboxIssue.objects.get( pk=pk, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, inbox_id=inbox_id, ) issue = Issue.objects.get( pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, ) serializer = IssueStateInboxSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - def destroy(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, inbox_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) if project_deploy_board.inbox is None: return Response( @@ -291,8 +291,8 @@ class InboxIssuePublicViewSet(BaseViewSet): inbox_issue = InboxIssue.objects.get( pk=pk, - workspace__slug=slug, - project_id=project_id, + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, inbox_id=inbox_id, ) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 8c4d6e150..6ece02cbb 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -1,57 +1,57 @@ # Python imports import json +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + Exists, + F, + Func, + OuterRef, + Q, + Prefetch, + Case, + When, + CharField, + IntegerField, + Value, + Max, +) + # Django imports from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - IntegerField, -) -from django.core.serializers.json import DjangoJSONEncoder - -# Third Party imports -from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated -# Module imports -from .base import BaseViewSet, BaseAPIView -from plane.app.serializers import ( - IssueCommentSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssuePublicSerializer, -) +# Third Party imports +from rest_framework.response import Response +from plane.app.serializers import ( + CommentReactionSerializer, + IssueCommentSerializer, + IssuePublicSerializer, + IssueReactionSerializer, + IssueVoteSerializer, +) from plane.db.models import ( Issue, IssueComment, - Label, IssueLink, IssueAttachment, - State, ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, + DeployBoard, IssueVote, ProjectPublicMember, + State, + Label, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters +# Module imports +from .base import BaseAPIView, BaseViewSet + class IssueCommentPublicViewSet(BaseViewSet): serializer_class = IssueCommentSerializer @@ -76,15 +76,15 @@ class IssueCommentPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + project_deploy_board = DeployBoard.objects.get( + anchor=self.kwargs.get("anchor"), + entity_name="project", ) - if project_deploy_board.comments: + if project_deploy_board.is_comments_enabled: return self.filter_queryset( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + .filter(workspace_id=project_deploy_board.workspace_id) .filter(issue_id=self.kwargs.get("issue_id")) .filter(access="EXTERNAL") .select_related("project") @@ -93,8 +93,8 @@ class IssueCommentPublicViewSet(BaseViewSet): .annotate( is_member=Exists( ProjectMember.objects.filter( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, member_id=self.request.user.id, is_active=True, ) @@ -103,15 +103,15 @@ class IssueCommentPublicViewSet(BaseViewSet): .distinct() ).order_by("created_at") return IssueComment.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueComment.objects.none() - def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.comments: + if not project_deploy_board.is_comments_enabled: return Response( {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, @@ -120,7 +120,7 @@ class IssueCommentPublicViewSet(BaseViewSet): serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, + project_id=project_deploy_board.project_id, issue_id=issue_id, actor=request.user, access="EXTERNAL", @@ -132,37 +132,35 @@ class IssueCommentPublicViewSet(BaseViewSet): ), actor_id=str(request.user.id), issue_id=str(issue_id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def partial_update(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def partial_update(self, request, anchor, issue_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.comments: + if not project_deploy_board.is_comments_enabled: return Response( {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) - comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, actor=request.user - ) + comment = IssueComment.objects.get(pk=pk, actor=request.user) serializer = IssueCommentSerializer( comment, data=request.data, partial=True ) @@ -173,7 +171,7 @@ class IssueCommentPublicViewSet(BaseViewSet): requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(issue_id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, @@ -183,20 +181,18 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, issue_id, pk): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.comments: + if not project_deploy_board.is_comments_enabled: return Response( {"error": "Comments are not enabled for this project"}, status=status.HTTP_400_BAD_REQUEST, ) comment = IssueComment.objects.get( - workspace__slug=slug, pk=pk, - project_id=project_id, actor=request.user, ) issue_activity.delay( @@ -204,7 +200,7 @@ class IssueCommentPublicViewSet(BaseViewSet): requested_data=json.dumps({"comment_id": str(pk)}), actor_id=str(request.user.id), issue_id=str(issue_id), - project_id=str(project_id), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, @@ -221,11 +217,11 @@ class IssueReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) - if project_deploy_board.reactions: + if project_deploy_board.is_reactions_enabled: return ( super() .get_queryset() @@ -236,15 +232,15 @@ class IssueReactionPublicViewSet(BaseViewSet): .distinct() ) return IssueReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueReaction.objects.none() - def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this project board"}, status=status.HTTP_400_BAD_REQUEST, @@ -253,16 +249,18 @@ class IssueReactionPublicViewSet(BaseViewSet): serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, issue_id=issue_id, actor=request.user + project_id=project_deploy_board.project_id, + issue_id=issue_id, + actor=request.user, ) if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) issue_activity.delay( @@ -272,25 +270,25 @@ class IssueReactionPublicViewSet(BaseViewSet): ), 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)), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, issue_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, issue_id, reaction_code): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this project board"}, status=status.HTTP_400_BAD_REQUEST, ) issue_reaction = IssueReaction.objects.get( - workspace__slug=slug, + workspace_id=project_deploy_board.workspace_id, issue_id=issue_id, reaction=reaction_code, actor=request.user, @@ -300,7 +298,7 @@ class IssueReactionPublicViewSet(BaseViewSet): 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)), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( { "reaction": str(reaction_code), @@ -319,30 +317,29 @@ class CommentReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + project_deploy_board = DeployBoard.objects.get( + anchor=self.kwargs.get("anchor"), entity_name="project" ) - if project_deploy_board.reactions: + if project_deploy_board.is_reactions_enabled: return ( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) .filter(comment_id=self.kwargs.get("comment_id")) .order_by("-created_at") .distinct() ) return CommentReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return CommentReaction.objects.none() - def create(self, request, slug, project_id, comment_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def create(self, request, anchor, comment_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this board"}, status=status.HTTP_400_BAD_REQUEST, @@ -351,18 +348,18 @@ class CommentReactionPublicViewSet(BaseViewSet): serializer = CommentReactionSerializer(data=request.data) if serializer.is_valid(): serializer.save( - project_id=project_id, + project_id=project_deploy_board.project_id, comment_id=comment_id, actor=request.user, ) if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): # Add the user for workspace tracking _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) issue_activity.delay( @@ -379,19 +376,19 @@ class CommentReactionPublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def destroy(self, request, slug, project_id, comment_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def destroy(self, request, anchor, comment_id, reaction_code): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - if not project_deploy_board.reactions: + if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this board"}, status=status.HTTP_400_BAD_REQUEST, ) comment_reaction = CommentReaction.objects.get( - project_id=project_id, - workspace__slug=slug, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, comment_id=comment_id, reaction=reaction_code, actor=request.user, @@ -401,7 +398,7 @@ class CommentReactionPublicViewSet(BaseViewSet): requested_data=None, actor_id=str(self.request.user.id), issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( { "reaction": str(reaction_code), @@ -421,36 +418,39 @@ class IssueVotePublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=self.kwargs.get("slug"), - project_id=self.kwargs.get("project_id"), + project_deploy_board = DeployBoard.objects.get( + workspace__slug=self.kwargs.get("anchor"), + entity_name="project", ) - if project_deploy_board.votes: + if project_deploy_board.is_votes_enabled: return ( super() .get_queryset() .filter(issue_id=self.kwargs.get("issue_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace_id=project_deploy_board.workspace_id) + .filter(project_id=project_deploy_board.project_id) ) return IssueVote.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueVote.objects.none() - def create(self, request, slug, project_id, issue_id): + def create(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, - project_id=project_id, + project_id=project_deploy_board.project_id, issue_id=issue_id, ) # Add the user for workspace tracking if not ProjectMember.objects.filter( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, is_active=True, ).exists(): _ = ProjectPublicMember.objects.get_or_create( - project_id=project_id, + project_id=project_deploy_board.project_id, member=request.user, ) issue_vote.vote = request.data.get("vote", 1) @@ -462,26 +462,29 @@ class IssueVotePublicViewSet(BaseViewSet): ), 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)), + project_id=str(project_deploy_board.project_id), current_instance=None, epoch=int(timezone.now().timestamp()), ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) - def destroy(self, request, slug, project_id, issue_id): + def destroy(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) issue_vote = IssueVote.objects.get( - workspace__slug=slug, - project_id=project_id, issue_id=issue_id, actor_id=request.user.id, + project_id=project_deploy_board.project_id, + workspace_id=project_deploy_board.workspace_id, ) issue_activity.delay( type="issue_vote.activity.deleted", 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)), + project_id=str(project_deploy_board.project_id), current_instance=json.dumps( { "vote": str(issue_vote.vote), @@ -499,9 +502,14 @@ class IssueRetrievePublicEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug, project_id, issue_id): + def get(self, request, anchor, issue_id): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" + ) issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=issue_id + workspace_id=project_deploy_board.workspace_id, + project_id=project_deploy_board.project_id, + pk=issue_id, ) serializer = IssuePublicSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) @@ -512,10 +520,11 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug, project_id): - if not ProjectDeployBoard.objects.filter( - workspace__slug=slug, project_id=project_id - ).exists(): + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + if not deploy_board: return Response( {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND, @@ -535,6 +544,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): order_by_param = request.GET.get("order_by", "-created_at") + project_id = deploy_board.entity_identifier + slug = deploy_board.workspace.slug + issue_queryset = ( Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter( @@ -673,11 +685,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): workspace__slug=slug, project_id=project_id ).values("id", "name", "color", "parent") - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - issues = group_results(issues, group_by) - return Response( { "issues": issues, diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index 10a3c3879..6f8977e03 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -11,10 +11,10 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView -from plane.app.serializers import ProjectDeployBoardSerializer +from plane.app.serializers import DeployBoardSerializer from plane.db.models import ( Project, - ProjectDeployBoard, + DeployBoard, ) @@ -23,11 +23,11 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( - workspace__slug=slug, project_id=project_id + def get(self, request, anchor): + project_deploy_board = DeployBoard.objects.get( + anchor=anchor, entity_name="project" ) - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) @@ -36,13 +36,18 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): AllowAny, ] - def get(self, request, slug): + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).values_list projects = ( - Project.objects.filter(workspace__slug=slug) + Project.objects.filter(workspace=deploy_board.workspace) .annotate( is_public=Exists( - ProjectDeployBoard.objects.filter( - workspace__slug=slug, project_id=OuterRef("pk") + DeployBoard.objects.filter( + anchor=anchor, + project_id=OuterRef("pk"), + entity_name="project", ) ) ) @@ -58,3 +63,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): ) return Response(projects, status=status.HTTP_200_OK) + + +class WorkspaceProjectAnchorEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug, project_id): + project_deploy_board = DeployBoard.objects.get( + workspace__slug=slug, project_id=project_id + ) + serializer = DeployBoardSerializer(project_deploy_board) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index cd57690c6..0d2564a04 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -4,18 +4,28 @@ from itertools import groupby # Django import from django.db import models -from django.db.models import Case, CharField, Count, F, Sum, Value, When +from django.db.models import ( + Case, + CharField, + Count, + F, + Sum, + Value, + When, + IntegerField, +) from django.db.models.functions import ( Coalesce, Concat, ExtractMonth, ExtractYear, TruncDate, + Cast, ) from django.utils import timezone # Module imports -from plane.db.models import Issue +from plane.db.models import Issue, Project def annotate_with_monthly_dimension(queryset, field_name, attribute): @@ -87,9 +97,9 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): # Estimate else: - queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by( - x_axis - ) + queryset = queryset.annotate( + estimate=Sum(Cast("estimate_point__value", IntegerField())) + ).order_by(x_axis) queryset = ( queryset.annotate(segment=F(segment)) if segment else queryset ) @@ -110,9 +120,44 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): return sort_data(grouped_data, temp_axis) -def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): +def burndown_plot( + queryset, + slug, + project_id, + plot_type, + cycle_id=None, + module_id=None, +): # Total Issues in Cycle or Module total_issues = queryset.total_issues + # check whether the estimate is a point or not + estimate_type = Project.objects.filter( + workspace__slug=slug, + pk=project_id, + estimate__isnull=False, + estimate__type="points", + ).exists() + if estimate_type and plot_type == "points" and cycle_id: + issue_estimates = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + estimate_point__isnull=False, + ).values_list("estimate_point__value", flat=True) + + issue_estimates = [float(value) for value in issue_estimates] + total_estimate_points = sum(issue_estimates) + + if estimate_type and plot_type == "points" and module_id: + issue_estimates = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_module__module_id=module_id, + estimate_point__isnull=False, + ).values_list("estimate_point__value", flat=True) + + issue_estimates = [float(value) for value in issue_estimates] + total_estimate_points = sum(issue_estimates) if cycle_id: if queryset.end_date and queryset.start_date: @@ -128,18 +173,32 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): chart_data = {str(date): 0 for date in date_range} - completed_issues_distribution = ( - Issue.issue_objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_cycle__cycle_id=cycle_id, + if plot_type == "points": + completed_issues_estimate_point_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + estimate_point__isnull=False, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .values("date", "estimate_point__value") + .order_by("date") + ) + else: + completed_issues_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") ) - .annotate(date=TruncDate("completed_at")) - .values("date") - .annotate(total_completed=Count("id")) - .values("date", "total_completed") - .order_by("date") - ) if module_id: # Get all dates between the two dates @@ -152,31 +211,60 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): chart_data = {str(date): 0 for date in date_range} - completed_issues_distribution = ( - Issue.issue_objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_module__module_id=module_id, + if plot_type == "points": + completed_issues_estimate_point_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_module__module_id=module_id, + estimate_point__isnull=False, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .values("date", "estimate_point__value") + .order_by("date") ) - .annotate(date=TruncDate("completed_at")) - .values("date") - .annotate(total_completed=Count("id")) - .values("date", "total_completed") - .order_by("date") - ) - - for date in date_range: - cumulative_pending_issues = total_issues - total_completed = 0 - total_completed = sum( - item["total_completed"] - for item in completed_issues_distribution - if item["date"] is not None and item["date"] <= date - ) - cumulative_pending_issues -= total_completed - if date > timezone.now().date(): - chart_data[str(date)] = None else: - chart_data[str(date)] = cumulative_pending_issues + completed_issues_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_module__module_id=module_id, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") + ) + + if plot_type == "points": + for date in date_range: + cumulative_pending_issues = total_estimate_points + total_completed = 0 + total_completed = sum( + float(item["estimate_point__value"]) + for item in completed_issues_estimate_point_distribution + if item["date"] is not None and item["date"] <= date + ) + cumulative_pending_issues -= total_completed + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues + else: + for date in date_range: + cumulative_pending_issues = total_issues + total_completed = 0 + total_completed = sum( + item["total_completed"] + for item in completed_issues_distribution + if item["date"] is not None and item["date"] <= date + ) + cumulative_pending_issues -= total_completed + if date > timezone.now().date(): + chart_data[str(date)] = None + else: + chart_data[str(date)] = cumulative_pending_issues return chart_data diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 071051129..bda942899 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -66,7 +66,7 @@ def invalidate_cache_directly( custom_path = path if path is not None else request.get_full_path() auth_header = ( None - if request.user.is_anonymous + if request and request.user.is_anonymous else str(request.user.id) if user else None ) key = generate_cache_key(custom_path, auth_header) diff --git a/apiserver/plane/utils/constants.py b/apiserver/plane/utils/constants.py new file mode 100644 index 000000000..635268628 --- /dev/null +++ b/apiserver/plane/utils/constants.py @@ -0,0 +1,30 @@ +RESTRICTED_WORKSPACE_SLUGS = [ + "404", + "accounts", + "api", + "create-workspace", + "god-mode", + "installations", + "invitations", + "onboarding", + "profile", + "spaces", + "workspace-invitations", + "password", + "flags", + "monitor", + "monitoring", + "ingest", + "plane-pro", + "plane-ultimate", + "enterprise", + "plane-enterprise", + "disco", + "silo", + "chat", + "calendar", + "drive", + "channels", + "upgrade", + "billing", +] diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py index 0938f054b..f802f286b 100644 --- a/apiserver/plane/utils/exception_logger.py +++ b/apiserver/plane/utils/exception_logger.py @@ -1,16 +1,23 @@ # Python imports import logging +import traceback + +# Django imports +from django.conf import settings # Third party imports from sentry_sdk import capture_exception def log_exception(e): - print(e) # Log the error logger = logging.getLogger("plane") logger.error(e) + if settings.DEBUG: + # Print the traceback if in debug mode + print(traceback.format_exc()) + # Capture in sentry if configured capture_exception(e) return diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index edc7adc15..a3ac2420e 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -1,240 +1,204 @@ -def resolve_keys(group_keys, value): - """resolve keys to a key which will be used for - grouping +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value +from django.db.models.functions import Coalesce - Args: - group_keys (string): key which will be used for grouping - value (obj): data value - - Returns: - string: the key which will be used for - """ - keys = group_keys.split(".") - for key in keys: - value = value.get(key, None) - return value +# Module imports +from plane.db.models import ( + Cycle, + Issue, + Label, + Module, + Project, + ProjectMember, + State, + WorkspaceMember, +) -def group_results(results_data, group_by, sub_group_by=False): - """group results data into certain group_by +def issue_queryset_grouper(queryset, group_by, sub_group_by): - Args: - results_data (obj): complete results data - group_by (key): string + FIELD_MAPPER = { + "label_ids": "labels__id", + "assignee_ids": "assignees__id", + "module_ids": "issue_module__module_id", + } - Returns: - obj: grouped results - """ - if sub_group_by: - main_responsive_dict = dict() + annotations_map = { + "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), + "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "module_ids": ( + "issue_module__module_id", + ~Q(issue_module__module_id__isnull=True), + ), + } + default_annotations = { + key: Coalesce( + ArrayAgg( + field, + distinct=True, + filter=condition, + ), + Value([], output_field=ArrayField(UUIDField())), + ) + for key, (field, condition) in annotations_map.items() + if FIELD_MAPPER.get(key) != group_by + or FIELD_MAPPER.get(key) != sub_group_by + } - if sub_group_by == "priority": - main_responsive_dict = { - "urgent": {}, - "high": {}, - "medium": {}, - "low": {}, - "none": {}, - } + return queryset.annotate(**default_annotations) - for value in results_data: - main_group_attribute = resolve_keys(sub_group_by, value) - group_attribute = resolve_keys(group_by, value) - if isinstance(main_group_attribute, list) and not isinstance( - group_attribute, list - ): - if len(main_group_attribute): - for attrib in main_group_attribute: - if str(attrib) not in main_responsive_dict: - main_responsive_dict[str(attrib)] = {} - if ( - str(group_attribute) - in main_responsive_dict[str(attrib)] - ): - main_responsive_dict[str(attrib)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(attrib)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(attrib)][ - str(group_attribute) - ].append(value) - else: - if str(None) not in main_responsive_dict: - main_responsive_dict[str(None)] = {} - if str(group_attribute) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(None)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(None)][ - str(group_attribute) - ].append(value) +def issue_on_results(issues, group_by, sub_group_by): - elif isinstance(group_attribute, list) and not isinstance( - main_group_attribute, list - ): - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} - if len(group_attribute): - for attrib in group_attribute: - if ( - str(attrib) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ].append(value) - else: - if ( - str(None) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(None) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(None) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(None) - ].append(value) + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } - elif isinstance(group_attribute, list) and isinstance( - main_group_attribute, list - ): - if len(main_group_attribute): - for main_attrib in main_group_attribute: - if str(main_attrib) not in main_responsive_dict: - main_responsive_dict[str(main_attrib)] = {} - if len(group_attribute): - for attrib in group_attribute: - if ( - str(attrib) - in main_responsive_dict[str(main_attrib)] - ): - main_responsive_dict[str(main_attrib)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(main_attrib)][ - str(attrib) - ] = [] - main_responsive_dict[str(main_attrib)][ - str(attrib) - ].append(value) - else: - if ( - str(None) - in main_responsive_dict[str(main_attrib)] - ): - main_responsive_dict[str(main_attrib)][ - str(None) - ].append(value) - else: - main_responsive_dict[str(main_attrib)][ - str(None) - ] = [] - main_responsive_dict[str(main_attrib)][ - str(None) - ].append(value) - else: - if str(None) not in main_responsive_dict: - main_responsive_dict[str(None)] = {} - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(None)][ - str(attrib) - ] = [] - main_responsive_dict[str(None)][ - str(attrib) - ].append(value) - else: - if str(None) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(None)].append( - value - ) - else: - main_responsive_dict[str(None)][str(None)] = [] - main_responsive_dict[str(None)][str(None)].append( - value - ) - else: - main_group_attribute = resolve_keys(sub_group_by, value) - group_attribute = resolve_keys(group_by, value) + original_list = ["assignee_ids", "label_ids", "module_ids"] - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} + required_fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + "state__group", + ] - if ( - str(group_attribute) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ].append(value) + if group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[group_by]) + original_list.append(group_by) - return main_responsive_dict + if sub_group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[sub_group_by]) + original_list.append(sub_group_by) - else: - response_dict = {} + required_fields.extend(original_list) + return issues.values(*required_fields) - if group_by == "priority": - response_dict = { - "urgent": [], - "high": [], - "medium": [], - "low": [], - "none": [], - } - for value in results_data: - group_attribute = resolve_keys(group_by, value) - if isinstance(group_attribute, list): - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in response_dict: - response_dict[str(attrib)].append(value) - else: - response_dict[str(attrib)] = [] - response_dict[str(attrib)].append(value) - else: - if str(None) in response_dict: - response_dict[str(None)].append(value) - else: - response_dict[str(None)] = [] - response_dict[str(None)].append(value) - else: - if str(group_attribute) in response_dict: - response_dict[str(group_attribute)].append(value) - else: - response_dict[str(group_attribute)] = [] - response_dict[str(group_attribute)].append(value) +def issue_group_values(field, slug, project_id=None, filters=dict): + if field == "state_id": + queryset = State.objects.filter( + is_triage=False, + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "labels__id": + queryset = Label.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "assignees__id": + if project_id: + return ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ).values_list("member_id", flat=True) + else: + return list( + WorkspaceMember.objects.filter( + workspace__slug=slug, is_active=True + ).values_list("member_id", flat=True) + ) + if field == "issue_module__module_id": + queryset = Module.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "cycle_id": + queryset = Cycle.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "project_id": + queryset = Project.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + return list(queryset) + if field == "priority": + return [ + "low", + "medium", + "high", + "urgent", + "none", + ] + if field == "state__group": + return [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + if field == "target_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("target_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "start_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("start_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) - return response_dict + if field == "created_by": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("created_by", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + return [] diff --git a/apiserver/plane/utils/host.py b/apiserver/plane/utils/host.py new file mode 100644 index 000000000..4046c1e20 --- /dev/null +++ b/apiserver/plane/utils/host.py @@ -0,0 +1,42 @@ +# Python imports +from urllib.parse import urlsplit + +# Django imports +from django.conf import settings + + +def base_host(request, is_admin=False, is_space=False, is_app=False): + """Utility function to return host / origin from the request""" + # Calculate the base origin from request + base_origin = str( + request.META.get("HTTP_ORIGIN") + or f"{urlsplit(request.META.get('HTTP_REFERER')).scheme}://{urlsplit(request.META.get('HTTP_REFERER')).netloc}" + or f"""{"https" if request.is_secure() else "http"}://{request.get_host()}""" + ) + + # Admin redirections + if is_admin: + if settings.ADMIN_BASE_URL: + return settings.ADMIN_BASE_URL + else: + return base_origin + "/god-mode/" + + # Space redirections + if is_space: + if settings.SPACE_BASE_URL: + return settings.SPACE_BASE_URL + else: + return base_origin + "/spaces/" + + # App Redirection + if is_app: + if settings.APP_BASE_URL: + return settings.APP_BASE_URL + else: + return base_origin + + return base_origin + + +def user_ip(request): + return str(request.META.get("REMOTE_ADDR")) diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 531ef93ec..e0d51a56b 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,6 +1,7 @@ import re import uuid from datetime import timedelta + from django.utils import timezone # The date from pattern @@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries): """ for query in queries: date_query = query.split(";") - if len(date_query) >= 2: - match = pattern.match(date_query[0]) - if match: - if len(date_query) == 3: - digit, term = date_query[0].split("_") - string_date_filter( - filter=filter, - duration=int(digit), - subsequent=date_query[1], - term=term, - date_filter=date_term, - offset=date_query[2], - ) - else: - if "after" in date_query: - filter[f"{date_term}__gte"] = date_query[0] + if date_query: + if len(date_query) >= 2: + match = pattern.match(date_query[0]) + if match: + if len(date_query) == 3: + digit, term = date_query[0].split("_") + string_date_filter( + filter=filter, + duration=int(digit), + subsequent=date_query[1], + term=term, + date_filter=date_term, + offset=date_query[2], + ) else: - filter[f"{date_term}__lte"] = date_query[0] + if "after" in date_query: + filter[f"{date_term}__gte"] = date_query[0] + else: + filter[f"{date_term}__lte"] = date_query[0] + else: + filter[f"{date_term}__contains"] = date_query[0] def filter_state(params, filter, method, prefix=""): @@ -158,6 +162,8 @@ def filter_parent(params, filter, method, prefix=""): parents = [ item for item in params.get("parent").split(",") if item != "null" ] + if "None" in parents: + filter[f"{prefix}parent__isnull"] = True parents = filter_valid_uuids(parents) if len(parents) and "" not in parents: filter[f"{prefix}parent__in"] = parents @@ -176,6 +182,8 @@ def filter_labels(params, filter, method, prefix=""): labels = [ item for item in params.get("labels").split(",") if item != "null" ] + if "None" in labels: + filter[f"{prefix}labels__isnull"] = True labels = filter_valid_uuids(labels) if len(labels) and "" not in labels: filter[f"{prefix}labels__in"] = labels @@ -196,6 +204,8 @@ def filter_assignees(params, filter, method, prefix=""): for item in params.get("assignees").split(",") if item != "null" ] + if "None" in assignees: + filter[f"{prefix}assignees__isnull"] = True assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: filter[f"{prefix}assignees__in"] = assignees @@ -238,6 +248,8 @@ def filter_created_by(params, filter, method, prefix=""): for item in params.get("created_by").split(",") if item != "null" ] + if "None" in created_bys: + filter[f"{prefix}created_by__isnull"] = True created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: filter[f"{prefix}created_by__in"] = created_bys @@ -381,6 +393,8 @@ def filter_cycle(params, filter, method, prefix=""): cycles = [ item for item in params.get("cycle").split(",") if item != "null" ] + if "None" in cycles: + filter[f"{prefix}issue_cycle__cycle_id__isnull"] = True cycles = filter_valid_uuids(cycles) if len(cycles) and "" not in cycles: filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles @@ -399,6 +413,8 @@ def filter_module(params, filter, method, prefix=""): modules = [ item for item in params.get("module").split(",") if item != "null" ] + if "None" in modules: + filter[f"{prefix}issue_module__module_id__isnull"] = True modules = filter_valid_uuids(modules) if len(modules) and "" not in modules: filter[f"{prefix}issue_module__module_id__in"] = modules diff --git a/apiserver/plane/utils/order_queryset.py b/apiserver/plane/utils/order_queryset.py new file mode 100644 index 000000000..aafa954dc --- /dev/null +++ b/apiserver/plane/utils/order_queryset.py @@ -0,0 +1,84 @@ +from django.db.models import ( + Case, + CharField, + Min, + Value, + When, +) + +# Custom ordering for priority and state +PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"] +STATE_ORDER = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", +] + + +def order_issue_queryset(issue_queryset, order_by_param="-created_at"): + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(PRIORITY_ORDER) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + order_by_param = ( + "-priority_order" + if order_by_param.startswith("-") + else "priority_order" + ) + # State Ordering + elif order_by_param in [ + "state__group", + "-state__group", + ]: + state_order = ( + STATE_ORDER + if order_by_param in ["state__name", "state__group"] + else STATE_ORDER[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + order_by_param = ( + "-state_order" if order_by_param.startswith("-") else "state_order" + ) + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "assignees__first_name", + "issue_module__module__name", + "-labels__name", + "-assignees__first_name", + "-issue_module__module__name", + ]: + issue_queryset = issue_queryset.annotate( + min_values=Min( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-min_values" if order_by_param.startswith("-") else "min_values" + ) + order_by_param = ( + "-min_values" if order_by_param.startswith("-") else "min_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + order_by_param = order_by_param + return issue_queryset, order_by_param diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 8cc853370..3ea74bf9b 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -1,33 +1,49 @@ -from rest_framework.response import Response -from rest_framework.exceptions import ParseError -from collections.abc import Sequence +# Python imports import math +from collections import defaultdict +from collections.abc import Sequence + +# Django imports +from django.db.models import Count, F, Window +from django.db.models.functions import RowNumber + +# Third party imports +from rest_framework.exceptions import ParseError +from rest_framework.response import Response + +# Module imports class Cursor: + # The cursor value def __init__(self, value, offset=0, is_prev=False, has_results=None): self.value = value self.offset = int(offset) self.is_prev = bool(is_prev) self.has_results = has_results + # Return the cursor value in string format def __str__(self): return f"{self.value}:{self.offset}:{int(self.is_prev)}" + # Return the cursor value def __eq__(self, other): return all( getattr(self, attr) == getattr(other, attr) for attr in ("value", "offset", "is_prev", "has_results") ) + # Return the representation of the cursor def __repr__(self): return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" + # Return if the cursor is true def __bool__(self): return bool(self.has_results) @classmethod def from_string(cls, value): + """Return the cursor value from string format""" try: bits = value.split(":") if len(bits) != 3: @@ -50,15 +66,19 @@ class CursorResult(Sequence): self.max_hits = max_hits def __len__(self): + # Return the length of the results return len(self.results) def __iter__(self): + # Return the iterator of the results return iter(self.results) def __getitem__(self, key): + # Return the results based on the key return self.results[key] def __repr__(self): + # Return the representation of the results return f"<{type(self).__name__}: results={len(self.results)}>" @@ -85,11 +105,14 @@ class OffsetPaginator: max_offset=None, on_results=None, ): + # Key tuple and remove `-` if descending order by self.key = ( order_by if order_by is None or isinstance(order_by, (list, tuple, set)) - else (order_by,) + else (order_by[1::] if order_by.startswith("-") else order_by,) ) + # Set desc to true when `-` exists in the order by + self.desc = True if order_by and order_by.startswith("-") else False self.queryset = queryset self.max_limit = max_limit self.max_offset = max_offset @@ -101,13 +124,23 @@ class OffsetPaginator: if cursor is None: cursor = Cursor(0, 0, 0) + # Get the min from limit and max limit limit = min(limit, self.max_limit) + # queryset queryset = self.queryset if self.key: - queryset = queryset.order_by(*self.key) - + queryset = queryset.order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + "-created_at", + ) + # The current page page = cursor.offset + # The offset offset = cursor.offset * cursor.value stop = offset + (cursor.value or limit) + 1 @@ -116,20 +149,30 @@ class OffsetPaginator: if offset < 0: raise BadPaginationError("Pagination offset cannot be negative") - results = list(queryset[offset:stop]) + results = queryset[offset:stop] + if cursor.value != limit: results = results[-(limit + 1) :] - next_cursor = Cursor(limit, page + 1, False, len(results) > limit) + # Adjust cursors based on the results for pagination + next_cursor = Cursor(limit, page + 1, False, results.count() > limit) + # If the page is greater than 0, then set the previous cursor prev_cursor = Cursor(limit, page - 1, True, page > 0) - results = list(results[:limit]) + # Process the results + results = results[:limit] + + # Process the results if self.on_results: results = self.on_results(results) + # Count the queryset count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed max_hits = math.ceil(count / limit) + # Return the cursor results return CursorResult( results=results, next=next_cursor, @@ -138,6 +181,544 @@ class OffsetPaginator: max_hits=max_hits, ) + def process_results(self, results): + raise NotImplementedError + + +class GroupedOffsetPaginator(OffsetPaginator): + + # Field mappers - list m2m fields here + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + group_by_fields, + count_filter, + *args, + **kwargs, + ): + # Initiate the parent class for all the parameters + super().__init__(queryset, *args, **kwargs) + + # Set the group by field name + self.group_by_field_name = group_by_field_name + # Set the group by fields + self.group_by_fields = group_by_fields + # Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters + self.count_filter = count_filter + + def get_result(self, limit=50, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset + + page = cursor.offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + # Check if the offset is greater than the max offset + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + + # Check if the offset is less than 0 + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + # Compute the results + results = {} + # Create window for all the groups + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[F(self.group_by_field_name)], + order_by=( + ( + F(*self.key).desc( + nulls_last=True + ) # order by desc if desc is set + if self.desc + else F(*self.key).asc( + nulls_last=True + ) # Order by asc if set + ), + F("created_at").desc(), + ), + ) + ) + # Filter the results by row number + results = queryset.filter( + row_number__gt=offset, row_number__lt=stop + ).order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + F("created_at").desc(), + ) + + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor( + limit, + page + 1, + False, + queryset.filter(row_number__gte=stop).exists(), + ) + + # Add previous cursors + prev_cursor = Cursor( + limit, + page - 1, + True, + page > 0, + ) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def __get_total_queryset(self): + # Get total items for each group + return ( + self.queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by() + ) + + def __get_total_dict(self): + # Convert the total into dictionary of keys as group name and value as the total + total_group_dict = {} + for group in self.__get_total_queryset(): + total_group_dict[str(group.get(self.group_by_field_name))] = ( + total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + + (1 if group.get("count") == 0 else group.get("count")) + ) + return total_group_dict + + def __get_field_dict(self): + # Create a field dictionary + total_group_dict = self.__get_total_dict() + return { + str(field): { + "results": [], + "total_results": total_group_dict.get(str(field), 0), + } + for field in self.group_by_fields + } + + def __result_already_added(self, result, group): + # Check if the result is already added then add it + for existing_issue in group: + if existing_issue["id"] == result["id"]: + return True + return False + + def __query_multi_grouper(self, results): + # Grouping for m2m values + total_group_dict = self.__get_total_dict() + + # Preparing a dict to keep track of group IDs associated with each entity ID + result_group_mapping = defaultdict(set) + # Preparing a dict to group result by group ID + grouped_by_field_name = defaultdict(list) + + # Iterate over results to fill the above dictionaries + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + + # Adding group_ids key to each issue and grouping by group_name + for result in results: + result_id = result["id"] + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in group_ids else group_ids + ) + # If a result belongs to multiple groups, add it to each group + for group_id in group_ids: + if not self.__result_already_added( + result, grouped_by_field_name[group_id] + ): + grouped_by_field_name[group_id].append(result) + + # Convert grouped_by_field_name back to a list for each group + processed_results = { + str(group_id): { + "results": issues, + "total_results": total_group_dict.get(str(group_id)), + } + for group_id, issues in grouped_by_field_name.items() + } + + return processed_results + + def __query_grouper(self, results): + # Grouping for values that are not m2m + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + if group_value in processed_results: + processed_results[str(group_value)]["results"].append(result) + return processed_results + + def process_results(self, results): + # Process results + if results: + if self.group_by_field_name in self.FIELD_MAPPER: + processed_results = self.__query_multi_grouper(results=results) + else: + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + + +class SubGroupedOffsetPaginator(OffsetPaginator): + # Field mappers this are the fields that are m2m + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + sub_group_by_field_name, + group_by_fields, + sub_group_by_fields, + count_filter, + *args, + **kwargs, + ): + # Initiate the parent class for all the parameters + super().__init__(queryset, *args, **kwargs) + + # Set the group by field name + self.group_by_field_name = group_by_field_name + self.group_by_fields = group_by_fields + + # Set the sub group by field name + self.sub_group_by_field_name = sub_group_by_field_name + self.sub_group_by_fields = sub_group_by_fields + + # Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters + self.count_filter = count_filter + + def get_result(self, limit=30, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + # get the minimum value + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset + + # the current page + page = cursor.offset + + # the offset + offset = cursor.offset * cursor.value + + # the stop + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + # Compute the results + results = {} + + # Create windows for group and sub group field name + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[ + F(self.group_by_field_name), + F(self.sub_group_by_field_name), + ], + order_by=( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + "-created_at", + ), + ) + ) + + # Filter the results + results = queryset.filter( + row_number__gt=offset, row_number__lt=stop + ).order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + F("created_at").desc(), + ) + + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor( + limit, + page + 1, + False, + queryset.filter(row_number__gte=stop).exists(), + ) + + # Add previous cursors + prev_cursor = Cursor( + limit, + page - 1, + True, + page > 0, + ) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def __get_group_total_queryset(self): + # Get group totals + return ( + self.queryset.order_by(self.group_by_field_name) + .values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .distinct() + ) + + def __get_subgroup_total_queryset(self): + # Get subgroup totals + return ( + self.queryset.values( + self.group_by_field_name, self.sub_group_by_field_name + ) + .annotate( + count=Count("id", filter=self.count_filter, distinct=True) + ) + .order_by() + .values( + self.group_by_field_name, self.sub_group_by_field_name, "count" + ) + ) + + def __get_total_dict(self): + # Use the above to convert to dictionary of 2D objects + total_group_dict = {} + total_sub_group_dict = {} + for group in self.__get_group_total_queryset(): + total_group_dict[str(group.get(self.group_by_field_name))] = ( + total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + + (1 if group.get("count") == 0 else group.get("count")) + ) + + # Sub group total values + for item in self.__get_subgroup_total_queryset(): + group = str(item[self.group_by_field_name]) + subgroup = str(item[self.sub_group_by_field_name]) + count = item["count"] + + # Create a dictionary of group and sub group + if group not in total_sub_group_dict: + total_sub_group_dict[str(group)] = {} + + # Create a dictionary of sub group + if subgroup not in total_sub_group_dict[group]: + total_sub_group_dict[str(group)][str(subgroup)] = {} + + # Create a nested dictionary of group and sub group + total_sub_group_dict[group][subgroup] = count + + return total_group_dict, total_sub_group_dict + + def __get_field_dict(self): + # Create a field dictionary + total_group_dict, total_sub_group_dict = self.__get_total_dict() + + # Create a dictionary of group and sub group + return { + str(group): { + "results": { + str(sub_group): { + "results": [], + "total_results": total_sub_group_dict.get( + str(group) + ).get(str(sub_group), 0), + } + for sub_group in total_sub_group_dict.get(str(group), []) + }, + "total_results": total_group_dict.get(str(group), 0), + } + for group in self.group_by_fields + } + + def __query_multi_grouper(self, results): + # Multi grouper + processed_results = self.__get_field_dict() + # Preparing a dict to keep track of group IDs associated with each label ID + result_group_mapping = defaultdict(set) + result_sub_group_mapping = defaultdict(set) + + # Iterate over results to fill the above dictionaries + if self.group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + # Use the same calculation for the sub group + if self.sub_group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + sub_group_id = result[self.sub_group_by_field_name] + result_sub_group_mapping[str(result_id)].add(str(sub_group_id)) + + # Iterate over results + for result in results: + # Get the group value + group_value = str(result.get(self.group_by_field_name)) + # Get the sub group value + sub_group_value = str(result.get(self.sub_group_by_field_name)) + # Check if the group value is in the processed results + result_id = result["id"] + + if ( + group_value in processed_results + and sub_group_value + in processed_results[str(group_value)]["results"] + ): + if self.group_by_field_name in self.FIELD_MAPPER: + # for multi grouper + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in group_ids else group_ids + ) + if self.sub_group_by_field_name in self.FIELD_MAPPER: + sub_group_ids = list( + result_sub_group_mapping[str(result_id)] + ) + # for multi groups + result[ + self.FIELD_MAPPER.get(self.sub_group_by_field_name) + ] = ([] if "None" in sub_group_ids else sub_group_ids) + # If a result belongs to multiple groups, add it to each group + processed_results[str(group_value)]["results"][ + str(sub_group_value) + ]["results"].append(result) + + return processed_results + + def __query_grouper(self, results): + # Single grouper + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + sub_group_value = str(result.get(self.sub_group_by_field_name)) + processed_results[group_value]["results"][sub_group_value][ + "results" + ].append(result) + + return processed_results + + def process_results(self, results): + if results: + if ( + self.group_by_field_name in self.FIELD_MAPPER + or self.sub_group_by_field_name in self.FIELD_MAPPER + ): + # if the grouping is done through m2m then + processed_results = self.__query_multi_grouper(results=results) + else: + # group it directly + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + class BasePaginator: """BasePaginator class can be inherited by any View to return a paginated view""" @@ -171,6 +752,11 @@ class BasePaginator: cursor_cls=Cursor, extra_stats=None, controller=None, + group_by_field_name=None, + group_by_fields=None, + sub_group_by_field_name=None, + sub_group_by_fields=None, + count_filter=None, **paginator_kwargs, ): """Paginate the request""" @@ -178,15 +764,27 @@ class BasePaginator: # Convert the cursor value to integer and float from string input_cursor = None - if request.GET.get(self.cursor_name): - try: - input_cursor = cursor_cls.from_string( - request.GET.get(self.cursor_name) - ) - except ValueError: - raise ParseError(detail="Invalid cursor parameter.") + try: + input_cursor = cursor_cls.from_string( + request.GET.get(self.cursor_name, f"{per_page}:0:0"), + ) + except ValueError: + raise ParseError(detail="Invalid cursor parameter.") if not paginator: + if group_by_field_name: + paginator_kwargs["group_by_field_name"] = group_by_field_name + paginator_kwargs["group_by_fields"] = group_by_fields + paginator_kwargs["count_filter"] = count_filter + + if sub_group_by_field_name: + paginator_kwargs["sub_group_by_field_name"] = ( + sub_group_by_field_name + ) + paginator_kwargs["sub_group_by_fields"] = ( + sub_group_by_fields + ) + paginator = paginator_cls(**paginator_kwargs) try: @@ -196,12 +794,14 @@ class BasePaginator: except BadPaginationError: raise ParseError(detail="Error in parsing") - # Serialize result according to the on_result function if on_results: results = on_results(cursor_result.results) else: results = cursor_result.results + if group_by_field_name: + results = paginator.process_results(results=results) + # Add Manipulation functions to the response if controller is not None: results = controller(results) @@ -211,6 +811,9 @@ class BasePaginator: # Return the response response = Response( { + "grouped_by": group_by_field_name, + "sub_grouped_by": sub_group_by_field_name, + "total_count": (cursor_result.hits), "next_cursor": str(cursor_result.next), "prev_cursor": str(cursor_result.prev), "next_page_results": cursor_result.next.has_results, diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index a6bd2ab50..ddea260a4 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -3,7 +3,7 @@ # django Django==4.2.11 # rest framework -djangorestframework==3.15.1 +djangorestframework==3.15.2 # postgres psycopg==3.1.18 psycopg-binary==3.1.18 @@ -60,4 +60,5 @@ zxcvbn==4.4.28 # timezone pytz==2024.1 # jwt -PyJWT==2.8.0 \ No newline at end of file +PyJWT==2.8.0 + diff --git a/apiserver/templates/emails/notifications/project_addition.html b/apiserver/templates/emails/notifications/project_addition.html new file mode 100644 index 000000000..ccf0f7a95 --- /dev/null +++ b/apiserver/templates/emails/notifications/project_addition.html @@ -0,0 +1,1591 @@ + + + + + + + + You are have been invited to a Plane project + + + + + + + + + + + + + + + diff --git a/apiserver/templates/emails/user/user_activation.html b/apiserver/templates/emails/user/user_activation.html new file mode 100644 index 000000000..1ec60e955 --- /dev/null +++ b/apiserver/templates/emails/user/user_activation.html @@ -0,0 +1,1570 @@ + + + + + + + + Your Plane account is now active + + + + + + + + + + + + + + + diff --git a/apiserver/templates/emails/user/user_deactivation.html b/apiserver/templates/emails/user/user_deactivation.html new file mode 100644 index 000000000..b6bc7b768 --- /dev/null +++ b/apiserver/templates/emails/user/user_deactivation.html @@ -0,0 +1,1571 @@ + + + + + + + + Your Plane account has been deactivated + + + + + + + + + + + + + + + diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 10f64fd1c..5a7b1acfc 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -147,7 +147,7 @@ services: plane-redis: <<: *app-env - image: redis:7.2.4-alpine + image: valkey/valkey:7.2.5-alpine pull_policy: if_not_present restart: unless-stopped volumes: diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 39c587d2b..aea05f958 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -9,7 +9,7 @@ volumes: services: plane-redis: - image: redis:7.2.4-alpine + image: valkey/valkey:7.2.5-alpine restart: unless-stopped networks: - dev_env diff --git a/docker-compose.yml b/docker-compose.yml index 879ebb7a4..08d40a85a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,7 +116,7 @@ services: plane-redis: container_name: plane-redis - image: redis:7.2.4-alpine + image: valkey/valkey:7.2.5-alpine restart: always volumes: - redisdata:/data diff --git a/package.json b/package.json index 57d694378..e49884638 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.21.0", + "version": "0.22.0", "license": "AGPL-3.0", "private": true, "workspaces": [ "web", "space", "admin", - "packages/editor/*", + "packages/editor", "packages/eslint-config-custom", "packages/tailwind-config-custom", "packages/tsconfig", @@ -34,10 +34,11 @@ "prettier": "latest", "prettier-plugin-tailwindcss": "^0.5.4", "tailwindcss": "^3.3.3", - "turbo": "^1.13.2" + "turbo": "^2.0.6" }, "resolutions": { "@types/react": "18.2.48" }, - "packageManager": "yarn@1.22.19" + "packageManager": "yarn@1.22.22", + "name": "plane" } diff --git a/packages/constants/auth.ts b/packages/constants/auth.ts new file mode 100644 index 000000000..59f08a37f --- /dev/null +++ b/packages/constants/auth.ts @@ -0,0 +1,91 @@ +export enum EAuthPageTypes { + PUBLIC = "PUBLIC", + NON_AUTHENTICATED = "NON_AUTHENTICATED", + SET_PASSWORD = "SET_PASSWORD", + ONBOARDING = "ONBOARDING", + AUTHENTICATED = "AUTHENTICATED", +} + +export enum EAuthModes { + SIGN_IN = "SIGN_IN", + SIGN_UP = "SIGN_UP", +} + +export enum EAuthSteps { + EMAIL = "EMAIL", + PASSWORD = "PASSWORD", + UNIQUE_CODE = "UNIQUE_CODE", +} + +// TODO: remove this +export enum EErrorAlertType { + BANNER_ALERT = "BANNER_ALERT", + INLINE_FIRST_NAME = "INLINE_FIRST_NAME", + INLINE_EMAIL = "INLINE_EMAIL", + INLINE_PASSWORD = "INLINE_PASSWORD", + INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", +} + +export enum EAuthErrorCodes { + // Global + INSTANCE_NOT_CONFIGURED = "5000", + INVALID_EMAIL = "5005", + EMAIL_REQUIRED = "5010", + SIGNUP_DISABLED = "5015", + MAGIC_LINK_LOGIN_DISABLED = "5016", + PASSWORD_LOGIN_DISABLED = "5018", + USER_ACCOUNT_DEACTIVATED = "5019", + // Password strength + INVALID_PASSWORD = "5020", + SMTP_NOT_CONFIGURED = "5025", + // Sign Up + USER_ALREADY_EXIST = "5030", + AUTHENTICATION_FAILED_SIGN_UP = "5035", + REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", + INVALID_EMAIL_SIGN_UP = "5045", + INVALID_EMAIL_MAGIC_SIGN_UP = "5050", + MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", + // Sign In + USER_DOES_NOT_EXIST = "5060", + AUTHENTICATION_FAILED_SIGN_IN = "5065", + REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", + INVALID_EMAIL_SIGN_IN = "5075", + INVALID_EMAIL_MAGIC_SIGN_IN = "5080", + MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", + // Both Sign in and Sign up for magic + INVALID_MAGIC_CODE_SIGN_IN = "5090", + INVALID_MAGIC_CODE_SIGN_UP = "5092", + EXPIRED_MAGIC_CODE_SIGN_IN = "5095", + EXPIRED_MAGIC_CODE_SIGN_UP = "5097", + EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100", + EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102", + // Oauth + OAUTH_NOT_CONFIGURED = "5104", + GOOGLE_NOT_CONFIGURED = "5105", + GITHUB_NOT_CONFIGURED = "5110", + GITLAB_NOT_CONFIGURED = "5111", + GOOGLE_OAUTH_PROVIDER_ERROR = "5115", + GITHUB_OAUTH_PROVIDER_ERROR = "5120", + GITLAB_OAUTH_PROVIDER_ERROR = "5121", + // Reset Password + INVALID_PASSWORD_TOKEN = "5125", + EXPIRED_PASSWORD_TOKEN = "5130", + // Change password + INCORRECT_OLD_PASSWORD = "5135", + MISSING_PASSWORD = "5138", + INVALID_NEW_PASSWORD = "5140", + // set passowrd + PASSWORD_ALREADY_SET = "5145", + // Admin + ADMIN_ALREADY_EXIST = "5150", + REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", + INVALID_ADMIN_EMAIL = "5160", + INVALID_ADMIN_PASSWORD = "5165", + REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", + ADMIN_AUTHENTICATION_FAILED = "5175", + ADMIN_USER_ALREADY_EXIST = "5180", + ADMIN_USER_DOES_NOT_EXIST = "5185", + ADMIN_USER_DEACTIVATED = "5190", + // Rate limit + RATE_LIMIT_EXCEEDED = "5900", +} diff --git a/packages/constants/src/index.ts b/packages/constants/index.ts similarity index 100% rename from packages/constants/src/index.ts rename to packages/constants/index.ts diff --git a/packages/constants/package.json b/packages/constants/package.json index 28d84c32b..7229217dc 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,10 +1,6 @@ { "name": "@plane/constants", - "version": "0.21.0", + "version": "0.22.0", "private": true, - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./*": "./src/*" - } + "main": "./index.ts" } diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts deleted file mode 100644 index c12b63d63..000000000 --- a/packages/constants/src/auth.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { ReactNode } from "react"; -import Link from "next/link"; - -export enum EPageTypes { - PUBLIC = "PUBLIC", - NON_AUTHENTICATED = "NON_AUTHENTICATED", - SET_PASSWORD = "SET_PASSWORD", - ONBOARDING = "ONBOARDING", - AUTHENTICATED = "AUTHENTICATED", -} - -export enum EAuthModes { - SIGN_IN = "SIGN_IN", - SIGN_UP = "SIGN_UP", -} - -export enum EAuthSteps { - EMAIL = "EMAIL", - PASSWORD = "PASSWORD", - UNIQUE_CODE = "UNIQUE_CODE", -} - -// TODO: remove this -export enum EErrorAlertType { - BANNER_ALERT = "BANNER_ALERT", - INLINE_FIRST_NAME = "INLINE_FIRST_NAME", - INLINE_EMAIL = "INLINE_EMAIL", - INLINE_PASSWORD = "INLINE_PASSWORD", - INLINE_EMAIL_CODE = "INLINE_EMAIL_CODE", -} - -export enum EAuthErrorCodes { - // Global - INSTANCE_NOT_CONFIGURED = "5000", - INVALID_EMAIL = "5005", - EMAIL_REQUIRED = "5010", - SIGNUP_DISABLED = "5015", - MAGIC_LINK_LOGIN_DISABLED = "5017", - PASSWORD_LOGIN_DISABLED = "5019", - SMTP_NOT_CONFIGURED = "5025", - // Password strength - INVALID_PASSWORD = "5020", - // Sign Up - USER_ACCOUNT_DEACTIVATED = "5019", - USER_ALREADY_EXIST = "5030", - AUTHENTICATION_FAILED_SIGN_UP = "5035", - REQUIRED_EMAIL_PASSWORD_SIGN_UP = "5040", - INVALID_EMAIL_SIGN_UP = "5045", - INVALID_EMAIL_MAGIC_SIGN_UP = "5050", - MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", - // Sign In - USER_DOES_NOT_EXIST = "5060", - AUTHENTICATION_FAILED_SIGN_IN = "5065", - REQUIRED_EMAIL_PASSWORD_SIGN_IN = "5070", - INVALID_EMAIL_SIGN_IN = "5075", - INVALID_EMAIL_MAGIC_SIGN_IN = "5080", - MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED = "5085", - // Both Sign in and Sign up for magic - INVALID_MAGIC_CODE = "5090", - EXPIRED_MAGIC_CODE = "5095", - EMAIL_CODE_ATTEMPT_EXHAUSTED = "5100", - // Oauth - GOOGLE_NOT_CONFIGURED = "5105", - GITHUB_NOT_CONFIGURED = "5110", - GOOGLE_OAUTH_PROVIDER_ERROR = "5115", - GITHUB_OAUTH_PROVIDER_ERROR = "5120", - // Reset Password - INVALID_PASSWORD_TOKEN = "5125", - EXPIRED_PASSWORD_TOKEN = "5130", - // Change password - INCORRECT_OLD_PASSWORD = "5135", - MISSING_PASSWORD= "5138", - INVALID_NEW_PASSWORD = "5140", - // set passowrd - PASSWORD_ALREADY_SET = "5145", - // Admin - ADMIN_ALREADY_EXIST = "5150", - REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME = "5155", - INVALID_ADMIN_EMAIL = "5160", - INVALID_ADMIN_PASSWORD = "5165", - REQUIRED_ADMIN_EMAIL_PASSWORD = "5170", - ADMIN_AUTHENTICATION_FAILED = "5175", - ADMIN_USER_ALREADY_EXIST = "5180", - ADMIN_USER_DOES_NOT_EXIST = "5185", -} - -export type TAuthErrorInfo = { - type: EErrorAlertType; - code: EAuthErrorCodes; - title: string; - message: ReactNode; -}; - -const errorCodeMessages: { - [key in EAuthErrorCodes]: { title: string; message: (email?: string | undefined) => ReactNode }; -} = { - // global - [EAuthErrorCodes.INSTANCE_NOT_CONFIGURED]: { - title: `Instance not configured`, - message: () => `Instance not configured. Please contact your administrator.`, - }, - [EAuthErrorCodes.SIGNUP_DISABLED]: { - title: `Sign up disabled`, - message: () => `Sign up disabled. Please contact your administrator.`, - }, - [EAuthErrorCodes.INVALID_PASSWORD]: { - title: `Invalid password`, - message: () => `Invalid password. Please try again.`, - }, - [EAuthErrorCodes.SMTP_NOT_CONFIGURED]: { - title: `SMTP not configured`, - message: () => `SMTP not configured. Please contact your administrator.`, - }, - - // email check in both sign up and sign in - [EAuthErrorCodes.INVALID_EMAIL]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - [EAuthenticationErrorCodes.EMAIL_REQUIRED]: { - title: `Email required`, - message: () => `Email required. Please try again.`, - }, - - // sign up - [EAuthenticationErrorCodes.USER_ALREADY_EXIST]: { - title: `User already exists`, - message: (email = undefined) => ( -
- Your account is already registered.  - - Sign In - -  now. -
- ), - }, - [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - [EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED]: { - title: `Email and code required`, - message: () => `Email and code required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - - // sign in - [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { - title: `User account deactivated`, - message: () =>
Your account is deactivated. Contact support@plane.so.
, - }, - [EAuthenticationErrorCodes.USER_DOES_NOT_EXIST]: { - title: `User does not exist`, - message: (email = undefined) => ( -
- No account found.  - - Create one - -  to get started. -
- ), - }, - [EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - [EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED]: { - title: `Email and code required`, - message: () => `Email and code required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN]: { - title: `Invalid email`, - message: () => `Invalid email. Please try again.`, - }, - - // Both Sign in and Sign up - [EAuthenticationErrorCodes.INVALID_MAGIC_CODE]: { - title: `Authentication failed`, - message: () => `Invalid magic code. Please try again.`, - }, - [EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE]: { - title: `Expired magic code`, - message: () => `Expired magic code. Please try again.`, - }, - [EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED]: { - title: `Expired magic code`, - message: () => `Expired magic code. Please try again.`, - }, - - // Oauth - [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { - title: `Google not configured`, - message: () => `Google not configured. Please contact your administrator.`, - }, - [EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED]: { - title: `GitHub not configured`, - message: () => `GitHub not configured. Please contact your administrator.`, - }, - [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { - title: `Google OAuth provider error`, - message: () => `Google OAuth provider error. Please try again.`, - }, - [EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR]: { - title: `GitHub OAuth provider error`, - message: () => `GitHub OAuth provider error. Please try again.`, - }, - - // Reset Password - [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { - title: `Invalid password token`, - message: () => `Invalid password token. Please try again.`, - }, - [EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN]: { - title: `Expired password token`, - message: () => `Expired password token. Please try again.`, - }, - - // Change password - - [EAuthenticationErrorCodes.MISSING_PASSWORD]: { - title: `Password required`, - message: () => `Password required. Please try again.`, - }, - [EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD]: { - title: `Incorrect old password`, - message: () => `Incorrect old password. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_NEW_PASSWORD]: { - title: `Invalid new password`, - message: () => `Invalid new password. Please try again.`, - }, - - // set password - [EAuthenticationErrorCodes.PASSWORD_ALREADY_SET]: { - title: `Password already set`, - message: () => `Password already set. Please try again.`, - }, - - // admin - [EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST]: { - title: `Admin already exists`, - message: () => `Admin already exists. Please try again.`, - }, - [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME]: { - title: `Email, password and first name required`, - message: () => `Email, password and first name required. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL]: { - title: `Invalid admin email`, - message: () => `Invalid admin email. Please try again.`, - }, - [EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD]: { - title: `Invalid admin password`, - message: () => `Invalid admin password. Please try again.`, - }, - [EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD]: { - title: `Email and password required`, - message: () => `Email and password required. Please try again.`, - }, - [EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED]: { - title: `Authentication failed`, - message: () => `Authentication failed. Please try again.`, - }, - [EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST]: { - title: `Admin user already exists`, - message: () => ( -
- Admin user already exists.  - - Sign In - -  now. -
- ), - }, - [EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST]: { - title: `Admin user does not exist`, - message: () => ( -
- Admin user does not exist.  - - Sign In - -  now. -
- ), - }, -}; - -export const authErrorHandler = ( - errorCode: EAuthenticationErrorCodes, - email?: string | undefined -): TAuthErrorInfo | undefined => { - const bannerAlertErrorCodes = [ - EAuthenticationErrorCodes.INSTANCE_NOT_CONFIGURED, - EAuthenticationErrorCodes.INVALID_EMAIL, - EAuthenticationErrorCodes.EMAIL_REQUIRED, - EAuthenticationErrorCodes.SIGNUP_DISABLED, - EAuthenticationErrorCodes.INVALID_PASSWORD, - EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, - EAuthenticationErrorCodes.USER_ALREADY_EXIST, - EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_UP, - EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP, - EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_UP, - EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_UP, - EAuthenticationErrorCodes.MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED, - EAuthenticationErrorCodes.USER_DOES_NOT_EXIST, - EAuthenticationErrorCodes.AUTHENTICATION_FAILED_SIGN_IN, - EAuthenticationErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_IN, - EAuthenticationErrorCodes.INVALID_EMAIL_SIGN_IN, - EAuthenticationErrorCodes.INVALID_EMAIL_MAGIC_SIGN_IN, - EAuthenticationErrorCodes.MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED, - EAuthenticationErrorCodes.INVALID_MAGIC_CODE, - EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE, - EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED, - EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, - EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, - EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, - EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, - EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, - EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, - EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, - EAuthenticationErrorCodes.INVALID_NEW_PASSWORD, - EAuthenticationErrorCodes.PASSWORD_ALREADY_SET, - EAuthenticationErrorCodes.ADMIN_ALREADY_EXIST, - EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME, - EAuthenticationErrorCodes.INVALID_ADMIN_EMAIL, - EAuthenticationErrorCodes.INVALID_ADMIN_PASSWORD, - EAuthenticationErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD, - EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, - EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, - EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, - ]; - - if (bannerAlertErrorCodes.includes(errorCode)) - return { - type: EErrorAlertType.BANNER_ALERT, - code: errorCode, - title: errorCodeMessages[errorCode]?.title || "Error", - message: errorCodeMessages[errorCode]?.message(email) || "Something went wrong. Please try again.", - }; - - return undefined; -}; diff --git a/packages/editor/.eslintrc.js b/packages/editor/.eslintrc.js new file mode 100644 index 000000000..f1fc56356 --- /dev/null +++ b/packages/editor/.eslintrc.js @@ -0,0 +1,38 @@ +module.exports = { + root: true, + extends: ["custom"], + rules: { + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroups: [ + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "lucide-react", + group: "external", + position: "after", + }, + { + pattern: "@plane/**", + group: "external", + position: "after", + }, + { + pattern: "@/**", + group: "internal", + }, + ], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + }, +}; diff --git a/packages/editor/core/.prettierignore b/packages/editor/.prettierignore similarity index 100% rename from packages/editor/core/.prettierignore rename to packages/editor/.prettierignore diff --git a/packages/editor/core/.prettierrc b/packages/editor/.prettierrc similarity index 100% rename from packages/editor/core/.prettierrc rename to packages/editor/.prettierrc diff --git a/packages/editor/core/Readme.md b/packages/editor/Readme.md similarity index 85% rename from packages/editor/core/Readme.md rename to packages/editor/Readme.md index aafda7008..75aad2c95 100644 --- a/packages/editor/core/Readme.md +++ b/packages/editor/Readme.md @@ -1,8 +1,8 @@ -# @plane/editor-core +# @plane/editor ## Description -The `@plane/editor-core` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors. +The `@plane/editor` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors. ## Utilities @@ -64,40 +64,6 @@ const customEditorClassNames = getEditorClassNames({ - **Value Cleaning**: The Editor’s value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors. - **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package. -```json - "web#develop": { - "cache": false, - "persistent": true, - "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build" - ] - }, - "space#develop": { - "cache": false, - "persistent": true, - "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build" - ] - }, - "web#build": { - "cache": true, - "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build" - ] - }, - "space#build": { - "cache": true, - "dependsOn": [ - "@plane/lite-text-editor#build", - "@plane/rich-text-editor#build" - ] - }, - -``` - ## Base extensions included - BulletList diff --git a/packages/editor/core/.eslintrc.js b/packages/editor/core/.eslintrc.js deleted file mode 100644 index c8df60750..000000000 --- a/packages/editor/core/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["custom"], -}; diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts deleted file mode 100644 index 493f02d2f..000000000 --- a/packages/editor/core/src/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -// styles -// import "./styles/tailwind.css"; -import "src/styles/editor.css"; -import "src/styles/table.css"; -import "src/styles/github-dark.css"; - -export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell-selection"; - -// utils -export * from "src/lib/utils"; -export * from "src/ui/extensions/table/table"; -export { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; - -// components -export { EditorContainer } from "src/ui/components/editor-container"; -export { EditorContentWrapper } from "src/ui/components/editor-content"; - -// hooks -export { useEditor } from "src/hooks/use-editor"; -export { useReadOnlyEditor } from "src/hooks/use-read-only-editor"; - -// helper items -export * from "src/ui/menus/menu-items"; -export * from "src/lib/editor-commands"; - -// types -export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor"; -export type { DeleteImage } from "src/types/delete-image"; -export type { UploadImage } from "src/types/upload-image"; -export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api"; -export type { RestoreImage } from "src/types/restore-image"; -export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; -export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion"; -export type { LucideIconType } from "src/types/lucide-icon"; diff --git a/packages/editor/core/src/types/delete-image.ts b/packages/editor/core/src/types/delete-image.ts deleted file mode 100644 index 40bfffe2f..000000000 --- a/packages/editor/core/src/types/delete-image.ts +++ /dev/null @@ -1 +0,0 @@ -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts deleted file mode 100644 index b15ae943d..000000000 --- a/packages/editor/core/src/types/editor-ref-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IMarking } from "src/helpers/scroll-to-node"; -import { EditorMenuItemNames } from "src/ui/menus/menu-items"; - -export type EditorReadOnlyRefApi = { - getMarkDown: () => string; - getHTML: () => string; - clearEditor: () => void; - setEditorValue: (content: string) => void; - scrollSummary: (marking: IMarking) => void; -}; - -export interface EditorRefApi extends EditorReadOnlyRefApi { - setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: (itemName: EditorMenuItemNames) => void; - isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; - onStateChange: (callback: () => void) => () => void; - setFocusAtPosition: (position: number) => void; - isEditorReadyToDiscard: () => boolean; -} diff --git a/packages/editor/core/src/types/lucide-icon.ts b/packages/editor/core/src/types/lucide-icon.ts deleted file mode 100644 index 2211c18e8..000000000 --- a/packages/editor/core/src/types/lucide-icon.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Smile } from "lucide-react"; - -export type LucideIconType = typeof Smile; diff --git a/packages/editor/core/src/types/restore-image.ts b/packages/editor/core/src/types/restore-image.ts deleted file mode 100644 index 9b33177b7..000000000 --- a/packages/editor/core/src/types/restore-image.ts +++ /dev/null @@ -1 +0,0 @@ -export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; diff --git a/packages/editor/core/src/types/upload-image.ts b/packages/editor/core/src/types/upload-image.ts deleted file mode 100644 index 3cf1408d2..000000000 --- a/packages/editor/core/src/types/upload-image.ts +++ /dev/null @@ -1 +0,0 @@ -export type UploadImage = (file: File) => Promise; diff --git a/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx deleted file mode 100644 index 838a6a1c9..000000000 --- a/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import ImageExt from "@tiptap/extension-image"; -import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; -import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; - -export const ImageExtensionWithoutProps = () => - ImageExt.extend({ - addKeyboardShortcuts() { - return { - ArrowDown: insertLineBelowImageAction, - ArrowUp: insertLineAboveImageAction, - }; - }, - - // storage to keep track of image states Map - addStorage() { - return { - images: new Map(), - uploadInProgress: false, - }; - }, - - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - }; - }, - }); diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts deleted file mode 100644 index 205ec96b9..000000000 --- a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { KeyboardShortcutCommand } from "@tiptap/core"; - -export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => { - try { - const { selection, doc } = editor.state; - const { $from, $to } = selection; - - let imageNode: ProseMirrorNode | null = null; - let imagePos: number | null = null; - - // Check if the selection itself is an image node - doc.nodesBetween($from.pos, $to.pos, (node, pos) => { - if (node.type.name === "image") { - imageNode = node; - imagePos = pos; - return false; // Stop iterating once an image node is found - } - return true; - }); - - if (imageNode === null || imagePos === null) return false; - - // Since we want to insert above the image, we use the imagePos directly - const insertPos = imagePos; - - const docSize = editor.state.doc.content.size; - - if (insertPos < 0 || insertPos > docSize) return false; - - // Check for an existing node immediately before the image - if (insertPos === 0) { - // If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there - editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run(); - editor.chain().setTextSelection(insertPos).run(); - } else { - const prevNode = doc.nodeAt(insertPos); - - if (prevNode && prevNode.type.name === "paragraph") { - // If the previous node is a paragraph, move the cursor there - editor.chain().setTextSelection(insertPos).run(); - } else { - return false; - } - } - - return true; - } catch (error) { - console.error("An error occurred while inserting a line above the image:", error); - return false; - } -}; diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts deleted file mode 100644 index fe06ea0d9..000000000 --- a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { KeyboardShortcutCommand } from "@tiptap/core"; - -export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => { - try { - const { selection, doc } = editor.state; - const { $from, $to } = selection; - - let imageNode: ProseMirrorNode | null = null; - let imagePos: number | null = null; - - // Check if the selection itself is an image node - doc.nodesBetween($from.pos, $to.pos, (node, pos) => { - if (node.type.name === "image") { - imageNode = node; - imagePos = pos; - return false; // Stop iterating once an image node is found - } - return true; - }); - - if (imageNode === null || imagePos === null) return false; - - const guaranteedImageNode: ProseMirrorNode = imageNode; - const nextNodePos = imagePos + guaranteedImageNode.nodeSize; - - // Check for an existing node immediately after the image - const nextNode = doc.nodeAt(nextNodePos); - - if (nextNode && nextNode.type.name === "paragraph") { - // If the next node is a paragraph, move the cursor there - const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; - editor.chain().setTextSelection(endOfParagraphPos).run(); - } else if (!nextNode) { - // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there - editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); - editor - .chain() - .setTextSelection(nextNodePos + 1) - .run(); - } else { - // If the next node is not a paragraph, do not proceed - return false; - } - - return true; - } catch (error) { - console.error("An error occurred while inserting a line below the image:", error); - return false; - } -}; diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx deleted file mode 100644 index 8bab79666..000000000 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Mention, MentionOptions } from "@tiptap/extension-mention"; -import { mergeAttributes } from "@tiptap/core"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -import { MentionNodeView } from "src/ui/mentions/mention-node-view"; -import { IMentionHighlight } from "src/types/mention-suggestion"; - -export interface CustomMentionOptions extends MentionOptions { - mentionHighlights: () => Promise; - readonly?: boolean; -} - -export const CustomMention = Mention.extend({ - addStorage(this) { - return { - mentionsOpen: false, - }; - }, - addAttributes() { - return { - id: { - default: null, - }, - label: { - default: null, - }, - target: { - default: null, - }, - self: { - default: false, - }, - redirect_uri: { - default: "/", - }, - entity_identifier: { - default: null, - }, - entity_name: { - default: null, - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer(MentionNodeView); - }, - - parseHTML() { - return [ - { - tag: "mention-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["mention-component", mergeAttributes(HTMLAttributes)]; - }, -}); diff --git a/packages/editor/core/src/ui/mentions/mention-without-props.tsx b/packages/editor/core/src/ui/mentions/mention-without-props.tsx deleted file mode 100644 index a0d22ef4f..000000000 --- a/packages/editor/core/src/ui/mentions/mention-without-props.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { CustomMention } from "./custom"; -import { ReactRenderer } from "@tiptap/react"; -import { Editor } from "@tiptap/core"; -import tippy from "tippy.js"; - -import { MentionList } from "./mention-list"; - -export const MentionsWithoutProps = () => - CustomMention.configure({ - HTMLAttributes: { - class: "mention", - }, - // mentionHighlights: mentionHighlights, - suggestion: { - // @ts-expect-error - Tiptap types are incorrect - render: () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; - - return { - onStart: (props: { editor: Editor; clientRect: DOMRect }) => { - if (!props.clientRect) { - return; - } - component = new ReactRenderer(MentionList, { - props: { ...props }, - editor: props.editor, - }); - props.editor.storage.mentionsOpen = true; - // @ts-expect-error - Tippy types are incorrect - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"), - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { - component?.updateProps(props); - - if (!props.clientRect) { - return; - } - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; - - if (navigationKeys.includes(props.event.key)) { - // @ts-expect-error - Tippy types are incorrect - component?.ref?.onKeyDown(props); - event?.stopPropagation(); - return true; - } - return false; - }, - onExit: (props: { editor: Editor; event: KeyboardEvent }) => { - props.editor.storage.mentionsOpen = false; - popup?.[0].destroy(); - component?.destroy(); - }, - }; - }, - }, - }); diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts deleted file mode 100644 index 8eaf0982f..000000000 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; - -export const getSuggestionItems = - (suggestions: IMentionSuggestion[]) => - ({ query }: { query: string }) => { - const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { - const transactionId = uuidv4(); - return { - ...suggestion, - id: transactionId, - }; - }); - return mappedSuggestions - .filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())) - .slice(0, 5); - }; diff --git a/packages/editor/core/tsconfig.json b/packages/editor/core/tsconfig.json deleted file mode 100644 index c15534037..000000000 --- a/packages/editor/core/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "tsconfig/react-library.json", - "include": [ - "src/**/*", - "index.d.ts" - ], - "exclude": [ - "dist", - "build", - "node_modules" - ], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/packages/editor/document-editor/.eslintrc.js b/packages/editor/document-editor/.eslintrc.js deleted file mode 100644 index c8df60750..000000000 --- a/packages/editor/document-editor/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["custom"], -}; diff --git a/packages/editor/document-editor/.prettierignore b/packages/editor/document-editor/.prettierignore deleted file mode 100644 index 43e8a7b8f..000000000 --- a/packages/editor/document-editor/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.next -.vercel -.tubro -out/ -dis/ -build/ \ No newline at end of file diff --git a/packages/editor/document-editor/.prettierrc b/packages/editor/document-editor/.prettierrc deleted file mode 100644 index 87d988f1b..000000000 --- a/packages/editor/document-editor/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "es5" -} diff --git a/packages/editor/document-editor/Readme.md b/packages/editor/document-editor/Readme.md deleted file mode 100644 index f019d6827..000000000 --- a/packages/editor/document-editor/Readme.md +++ /dev/null @@ -1 +0,0 @@ -# Document Editor diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json deleted file mode 100644 index b565bfedf..000000000 --- a/packages/editor/document-editor/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "@plane/document-editor", - "version": "0.21.0", - "description": "Package that powers Plane's Pages Editor", - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "files": [ - "dist/**/*" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "module": "./dist/index.mjs" - } - }, - "scripts": { - "build": "tsup --minify", - "dev": "tsup --watch", - "check-types": "tsc --noEmit", - "format": "prettier --write \"**/*.{ts,tsx,md}\"" - }, - "peerDependencies": { - "next": "12.3.2", - "next-themes": "^0.2.1", - "react": "^18.2.0", - "react-dom": "18.2.0" - }, - "dependencies": { - "@floating-ui/react": "^0.26.4", - "@plane/editor-core": "*", - "@plane/editor-extensions": "*", - "@plane/ui": "*", - "@tippyjs/react": "^4.2.6", - "@tiptap/core": "^2.1.13", - "@tiptap/extension-collaboration": "^2.3.2", - "@tiptap/pm": "^2.1.13", - "@tiptap/suggestion": "^2.1.13", - "lucide-react": "^0.378.0", - "react-popper": "^2.3.0", - "tippy.js": "^6.3.7", - "uuid": "^9.0.1", - "y-indexeddb": "^9.0.12", - "y-prosemirror": "^1.2.5", - "y-protocols": "^1.0.6", - "yjs": "^13.6.15" - }, - "devDependencies": { - "@types/node": "18.15.3", - "@types/react": "^18.2.42", - "@types/react-dom": "^18.2.17", - "eslint-config-custom": "*", - "postcss": "^8.4.38", - "tailwind-config-custom": "*", - "tsconfig": "*", - "tsup": "^7.2.0", - "typescript": "4.9.5" - }, - "keywords": [ - "editor", - "rich-text", - "markdown", - "nextjs", - "react" - ] -} diff --git a/packages/editor/document-editor/postcss.config.js b/packages/editor/document-editor/postcss.config.js deleted file mode 100644 index 419fe25d1..000000000 --- a/packages/editor/document-editor/postcss.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// If you want to use other PostCSS plugins, see the following: -// https://tailwindcss.com/docs/using-with-preprocessors - -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, - }; \ No newline at end of file diff --git a/packages/editor/document-editor/src/hooks/use-document-editor.ts b/packages/editor/document-editor/src/hooks/use-document-editor.ts deleted file mode 100644 index c2070a9f3..000000000 --- a/packages/editor/document-editor/src/hooks/use-document-editor.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo } from "react"; -import { EditorProps } from "@tiptap/pm/view"; -import { IndexeddbPersistence } from "y-indexeddb"; -import * as Y from "yjs"; -// editor-core -import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core"; -// custom provider -import { CollaborationProvider } from "src/providers/collaboration-provider"; -// extensions -import { DocumentEditorExtensions } from "src/ui/extensions"; - -type DocumentEditorProps = { - id: string; - fileHandler: TFileHandler; - value: Uint8Array; - editorClassName: string; - onChange: (updates: Uint8Array) => void; - editorProps?: EditorProps; - forwardedRef?: React.MutableRefObject; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; - handleEditorReady?: (value: boolean) => void; - placeholder?: string | ((isFocused: boolean, value: string) => string); - setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void; - tabIndex?: number; -}; - -export const useDocumentEditor = ({ - id, - editorProps = {}, - value, - editorClassName, - fileHandler, - onChange, - forwardedRef, - tabIndex, - handleEditorReady, - mentionHandler, - placeholder, - setHideDragHandleFunction, -}: DocumentEditorProps) => { - const provider = useMemo( - () => - new CollaborationProvider({ - name: id, - onChange, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id] - ); - - // update document on value change - useEffect(() => { - if (value.byteLength > 0) Y.applyUpdate(provider.document, value); - }, [value, provider.document]); - - // indexedDB provider - useLayoutEffect(() => { - const localProvider = new IndexeddbPersistence(id, provider.document); - return () => { - localProvider?.destroy(); - }; - }, [provider, id]); - - const editor = useEditor({ - id, - editorProps, - editorClassName, - fileHandler, - handleEditorReady, - forwardedRef, - mentionHandler, - extensions: DocumentEditorExtensions({ - uploadFile: fileHandler.upload, - setHideDragHandle: setHideDragHandleFunction, - provider, - }), - placeholder, - tabIndex, - }); - - return editor; -}; diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts deleted file mode 100644 index 9e8407ce3..000000000 --- a/packages/editor/document-editor/src/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { DocumentEditor, DocumentEditorWithRef } from "src/ui"; -export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly"; - -// hooks -export { useEditorMarkings } from "src/hooks/use-editor-markings"; -// utils -export { proseMirrorJSONToBinaryString, applyUpdates, mergeUpdates } from "src/utils/yjs"; - -export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; - -export type { IMarking } from "src/types/editor-types"; diff --git a/packages/editor/document-editor/src/providers/collaboration-provider.ts b/packages/editor/document-editor/src/providers/collaboration-provider.ts deleted file mode 100644 index b61ceebd5..000000000 --- a/packages/editor/document-editor/src/providers/collaboration-provider.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as Y from "yjs"; - -export interface CompleteCollaboratorProviderConfiguration { - /** - * The identifier/name of your document - */ - name: string; - /** - * The actual Y.js document - */ - document: Y.Doc; - /** - * onChange callback - */ - onChange: (updates: Uint8Array) => void; -} - -export type CollaborationProviderConfiguration = Required> & - Partial; - -export class CollaborationProvider { - public configuration: CompleteCollaboratorProviderConfiguration = { - name: "", - // @ts-expect-error cannot be undefined - document: undefined, - onChange: () => {}, - }; - - constructor(configuration: CollaborationProviderConfiguration) { - this.setConfiguration(configuration); - - this.configuration.document = configuration.document ?? new Y.Doc(); - this.document.on("update", this.documentUpdateHandler.bind(this)); - this.document.on("destroy", this.documentDestroyHandler.bind(this)); - } - - public setConfiguration(configuration: Partial = {}): void { - this.configuration = { - ...this.configuration, - ...configuration, - }; - } - - get document() { - return this.configuration.document; - } - - documentUpdateHandler(update: Uint8Array, origin: any) { - // return if the update is from the provider itself - if (origin === this) return; - - // call onChange with the update - this.configuration.onChange?.(update); - } - - documentDestroyHandler() { - this.document.off("update", this.documentUpdateHandler); - this.document.off("destroy", this.documentDestroyHandler); - } -} diff --git a/packages/editor/document-editor/src/types/editor-types.ts b/packages/editor/document-editor/src/types/editor-types.ts deleted file mode 100644 index 476642103..000000000 --- a/packages/editor/document-editor/src/types/editor-types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IMarking { - type: "heading"; - level: number; - text: string; - sequence: number; -} diff --git a/packages/editor/document-editor/src/types/mark.ts b/packages/editor/document-editor/src/types/mark.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/editor/document-editor/src/types/menu-actions.d.ts b/packages/editor/document-editor/src/types/menu-actions.d.ts deleted file mode 100644 index 87e848be7..000000000 --- a/packages/editor/document-editor/src/types/menu-actions.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface IDuplicationConfig { - action: () => Promise; -} -export interface IPageLockConfig { - is_locked: boolean; - action: () => Promise; - locked_by?: string; -} -export interface IPageArchiveConfig { - is_archived: boolean; - archived_at?: Date; - action: () => Promise; -} diff --git a/packages/editor/document-editor/src/ui/components/index.ts b/packages/editor/document-editor/src/ui/components/index.ts deleted file mode 100644 index 4d2d76baa..000000000 --- a/packages/editor/document-editor/src/ui/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./page-renderer"; diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx deleted file mode 100644 index 10c9fa596..000000000 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-widget"; - -import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; -import { UploadImage } from "@plane/editor-core"; -import { CollaborationProvider } from "src/providers/collaboration-provider"; -import Collaboration from "@tiptap/extension-collaboration"; - -type TArguments = { - uploadFile: UploadImage; - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; - provider: CollaborationProvider; -}; - -export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [ - SlashCommand(uploadFile), - DragAndDrop(setHideDragHandle), - IssueWidgetPlaceholder(), - Collaboration.configure({ - document: provider.document, - }), -]; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx deleted file mode 100644 index 35a09bcc2..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Editor, Range } from "@tiptap/react"; -import { IssueEmbedSuggestions } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension"; -import { getIssueSuggestionItems } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items"; -import { IssueListRenderer } from "src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer"; -import { v4 as uuidv4 } from "uuid"; - -export type CommandProps = { - editor: Editor; - range: Range; -}; - -export interface IIssueListSuggestion { - title: string; - priority: "high" | "low" | "medium" | "urgent"; - identifier: string; - state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog"; - command: ({ editor, range }: CommandProps) => void; -} - -export const IssueSuggestions = (suggestions: any[]) => { - const mappedSuggestions: IIssueListSuggestion[] = suggestions.map((suggestion): IIssueListSuggestion => { - const transactionId = uuidv4(); - return { - title: suggestion.name, - priority: suggestion.priority.toString(), - identifier: `${suggestion.project_detail.identifier}-${suggestion.sequence_id}`, - state: suggestion.state_detail && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo", - command: ({ editor, range }) => { - editor - .chain() - .focus() - .insertContentAt(range, { - type: "issue-embed-component", - attrs: { - entity_identifier: suggestion.id, - id: transactionId, - title: suggestion.name, - project_identifier: suggestion.project_detail.identifier, - sequence_id: suggestion.sequence_id, - entity_name: "issue", - }, - }) - .run(); - }, - }; - }); - - return IssueEmbedSuggestions.configure({ - suggestion: { - items: getIssueSuggestionItems(mappedSuggestions), - render: IssueListRenderer, - }, - }); -}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx deleted file mode 100644 index 96a5c1325..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-extension.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Extension, Range } from "@tiptap/core"; -import { PluginKey } from "@tiptap/pm/state"; -import { Editor } from "@tiptap/react"; -import Suggestion from "@tiptap/suggestion"; - -export const IssueEmbedSuggestions = Extension.create({ - name: "issue-embed-suggestions", - - addOptions() { - return { - suggestion: { - char: "#issue_", - allowSpaces: true, - command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { - props.command({ editor, range }); - }, - }, - }; - }, - addProseMirrorPlugins() { - return [ - Suggestion({ - pluginKey: new PluginKey("issue-embed-suggestions"), - editor: this.editor, - ...this.options.suggestion, - }), - ]; - }, -}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items.tsx deleted file mode 100644 index df468f2ee..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-items.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { IIssueListSuggestion } from "src/ui/extensions/widgets/issue-embed-suggestion-list"; - -export const getIssueSuggestionItems = - (issueSuggestions: Array) => - ({ query }: { query: string }) => { - const search = query.toLowerCase(); - const filteredSuggestions = issueSuggestions.filter( - (item) => - item.title.toLowerCase().includes(search) || - item.identifier.toLowerCase().includes(search) || - item.priority.toLowerCase().includes(search) - ); - - return filteredSuggestions; - }; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx deleted file mode 100644 index e586bfd80..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-suggestion-list/issue-suggestion-renderer.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { cn } from "@plane/editor-core"; -import { Editor } from "@tiptap/core"; -import tippy from "tippy.js"; -import { ReactRenderer } from "@tiptap/react"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { PriorityIcon } from "@plane/ui"; - -const updateScrollView = (container: HTMLElement, item: HTMLElement) => { - const containerHeight = container.offsetHeight; - const itemHeight = item ? item.offsetHeight : 0; - - const top = item.offsetTop; - const bottom = top + itemHeight; - - if (top < container.scrollTop) { - // container.scrollTop = top - containerHeight; - item.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } else if (bottom > containerHeight + container.scrollTop) { - // container.scrollTop = bottom - containerHeight; - item.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } -}; -interface IssueSuggestionProps { - title: string; - priority: "high" | "low" | "medium" | "urgent" | "none"; - state: "Cancelled" | "In Progress" | "Todo" | "Done" | "Backlog"; - identifier: string; -} - -const IssueSuggestionList = ({ - items, - command, - editor, -}: { - items: IssueSuggestionProps[]; - command: any; - editor: Editor; - range: any; -}) => { - const [selectedIndex, setSelectedIndex] = useState(0); - const [currentSection, setCurrentSection] = useState("Backlog"); - const sections = ["Backlog", "In Progress", "Todo", "Done", "Cancelled"]; - const [displayedItems, setDisplayedItems] = useState<{ - [key: string]: IssueSuggestionProps[]; - }>({}); - const [displayedTotalLength, setDisplayedTotalLength] = useState(0); - const commandListContainer = useRef(null); - - useEffect(() => { - const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {}; - let totalLength = 0; - sections.forEach((section) => { - newDisplayedItems[section] = items.filter((item) => item.state === section).slice(0, 5); - - totalLength += newDisplayedItems[section].length; - }); - setDisplayedTotalLength(totalLength); - setDisplayedItems(newDisplayedItems); - }, [items]); - - const selectItem = useCallback( - (section: string, index: number) => { - const item = displayedItems[section][index]; - if (item) { - command(item); - } - }, - [command, displayedItems, currentSection] - ); - - useEffect(() => { - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; - const onKeyDown = (e: KeyboardEvent) => { - if (navigationKeys.includes(e.key)) { - // if (editor.isFocused) { - // editor.chain().blur(); - // commandListContainer.current?.focus(); - // } - if (e.key === "ArrowUp") { - setSelectedIndex( - (selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length - ); - return true; - } - if (e.key === "ArrowDown") { - const nextIndex = (selectedIndex + 1) % displayedItems[currentSection].length; - setSelectedIndex(nextIndex); - if (nextIndex === 4) { - const nextItems = items - .filter((item) => item.state === currentSection) - .slice(displayedItems[currentSection].length, displayedItems[currentSection].length + 5); - setDisplayedItems((prevItems) => ({ - ...prevItems, - [currentSection]: [...prevItems[currentSection], ...nextItems], - })); - } - return true; - } - if (e.key === "Enter") { - selectItem(currentSection, selectedIndex); - return true; - } - if (e.key === "Tab") { - const currentSectionIndex = sections.indexOf(currentSection); - const nextSectionIndex = (currentSectionIndex + 1) % sections.length; - setCurrentSection(sections[nextSectionIndex]); - setSelectedIndex(0); - return true; - } - return false; - } else if (e.key === "Escape") { - if (!editor.isFocused) { - editor.chain().focus(); - } - } - }; - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [displayedItems, selectedIndex, setSelectedIndex, selectItem, currentSection]); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - if (container) { - const sectionContainer = container?.querySelector(`#${currentSection}-container`) as HTMLDivElement; - if (sectionContainer) { - updateScrollView(container, sectionContainer); - } - const sectionScrollContainer = container?.querySelector(`#${currentSection}`) as HTMLElement; - const item = sectionScrollContainer?.children[selectedIndex] as HTMLElement; - if (item && sectionScrollContainer) { - updateScrollView(sectionScrollContainer, item); - } - } - }, [selectedIndex, currentSection]); - - return displayedTotalLength > 0 ? ( -
- {sections.map((section) => { - const sectionItems = displayedItems[section]; - return ( - sectionItems && - sectionItems.length > 0 && ( -
-
- {section} -
-
- {sectionItems.map((item: IssueSuggestionProps, index: number) => ( - - ))} -
-
- ) - ); - })} -
- ) : null; -}; -export const IssueListRenderer = () => { - let component: ReactRenderer | null = null; - let popup: any | null = null; - - return { - onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - const container = document.querySelector(".frame-renderer") as HTMLElement; - component = new ReactRenderer(IssueSuggestionList, { - props, - // @ts-ignore - editor: props.editor, - }); - // @ts-ignore - popup = tippy(".frame-renderer", { - flipbehavior: ["bottom", "top"], - appendTo: () => document.querySelector(".frame-renderer") as HTMLElement, - flip: true, - flipOnUpdate: true, - getReferenceClientRect: props.clientRect, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - - container.addEventListener("scroll", () => { - popup?.[0].destroy(); - }); - }, - onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component?.updateProps(props); - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - return true; - } - - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter", "Tab"]; - if (navigationKeys.includes(props.event.key)) { - // @ts-ignore - component?.ref?.onKeyDown(props); - return true; - } - return false; - }, - onExit: (e) => { - const container = document.querySelector(".frame-renderer") as HTMLElement; - if (container) { - container.removeEventListener("scroll", () => {}); - } - popup?.[0].destroy(); - setTimeout(() => { - component?.destroy(); - }, 300); - }, - }; -}; diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx deleted file mode 100644 index 264a70152..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { IssueWidget } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-node"; - -export const IssueWidgetPlaceholder = () => IssueWidget.configure({}); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx deleted file mode 100644 index d3b6fd04f..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-card.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck -import { Button } from "@plane/ui"; -import { NodeViewWrapper } from "@tiptap/react"; -import { Crown } from "lucide-react"; - -export const IssueWidgetCard = (props) => ( - -
-
- {props.node.attrs.project_identifier}-{props.node.attrs.sequence_id} -
-
-
-
-
- -
-
- Embed and access issues in pages seamlessly, upgrade to plane pro now. -
-
- - - -
-
-
-
-); diff --git a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx b/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx deleted file mode 100644 index 6c744927a..000000000 --- a/packages/editor/document-editor/src/ui/extensions/widgets/issue-embed-widget/issue-widget-node.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { mergeAttributes, Node } from "@tiptap/core"; -import { IssueWidgetCard } from "src/ui/extensions/widgets/issue-embed-widget/issue-widget-card"; -import { ReactNodeViewRenderer } from "@tiptap/react"; - -export const IssueWidget = Node.create({ - name: "issue-embed-component", - group: "block", - atom: true, - - addAttributes() { - return { - id: { - default: null, - }, - class: { - default: "w-[600px]", - }, - title: { - default: null, - }, - entity_name: { - default: null, - }, - entity_identifier: { - default: null, - }, - project_identifier: { - default: null, - }, - sequence_id: { - default: null, - }, - }; - }, - - addNodeView() { - return ReactNodeViewRenderer((props: Object) => ); - }, - - parseHTML() { - return [ - { - tag: "issue-embed-component", - getAttrs: (node: string | HTMLElement) => { - if (typeof node === "string") { - return null; - } - return { - id: node.getAttribute("id") || "", - title: node.getAttribute("title") || "", - entity_name: node.getAttribute("entity_name") || "", - entity_identifier: node.getAttribute("entity_identifier") || "", - project_identifier: node.getAttribute("project_identifier") || "", - sequence_id: node.getAttribute("sequence_id") || "", - }; - }, - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; - }, -}); diff --git a/packages/editor/document-editor/src/ui/tooltip.tsx b/packages/editor/document-editor/src/ui/tooltip.tsx deleted file mode 100644 index 127efc7cb..000000000 --- a/packages/editor/document-editor/src/ui/tooltip.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react"; - -// next-themes -import { useTheme } from "next-themes"; -// tooltip2 -import { Tooltip2 } from "@blueprintjs/popover2"; - -type Props = { - tooltipHeading?: string; - tooltipContent: string | React.ReactNode; - position?: - | "top" - | "right" - | "bottom" - | "left" - | "auto" - | "auto-end" - | "auto-start" - | "bottom-left" - | "bottom-right" - | "left-bottom" - | "left-top" - | "right-bottom" - | "right-top" - | "top-left" - | "top-right"; - children: JSX.Element; - disabled?: boolean; - className?: string; - openDelay?: number; - closeDelay?: number; -}; - -export const Tooltip: React.FC = ({ - tooltipHeading, - tooltipContent, - position = "top", - children, - disabled = false, - className = "", - openDelay = 200, - closeDelay, -}) => { - const { theme } = useTheme(); - - return ( - - {tooltipHeading && ( -
- {tooltipHeading} -
- )} - {tooltipContent} -
- } - position={position} - renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) => - React.cloneElement(children, { - ref: eleReference, - ...tooltipProps, - ...children.props, - }) - } - /> - ); -}; diff --git a/packages/editor/document-editor/src/utils/date-utils.ts b/packages/editor/document-editor/src/utils/date-utils.ts deleted file mode 100644 index 63c20a974..000000000 --- a/packages/editor/document-editor/src/utils/date-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -function isNumber(value: any) { - return typeof value === "number"; -} - -/** - * This method returns a date from string of type yyyy-mm-dd - * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets - * @param date - * @returns date or undefined - */ -export const getDate = (date: string | Date | undefined | null): Date | undefined => { - try { - if (!date || date === "") return; - - if (typeof date !== "string" && !(date instanceof String)) return date; - const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); - const year = parseInt(yearString); - const month = parseInt(monthString); - const day = parseInt(dayString); - if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return; - - return new Date(year, month - 1, day); - } catch (e) { - return undefined; - } -}; diff --git a/packages/editor/document-editor/src/utils/editor-summary-utils.ts b/packages/editor/document-editor/src/utils/editor-summary-utils.ts deleted file mode 100644 index b5160fddd..000000000 --- a/packages/editor/document-editor/src/utils/editor-summary-utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Editor } from "@tiptap/react"; -import { IMarking } from "src/types/editor-types"; - -function findNthH1(editor: Editor, n: number, level: number): number { - let count = 0; - let pos = 0; - editor.state.doc.descendants((node, position) => { - if (node.type.name === "heading" && node.attrs.level === level) { - count++; - if (count === n) { - pos = position; - return false; - } - } - }); - return pos; -} - -function scrollToNode(editor: Editor, pos: number): void { - const headingNode = editor.state.doc.nodeAt(pos); - if (headingNode) { - const headingDOM = editor.view.nodeDOM(pos); - if (headingDOM instanceof HTMLElement) { - headingDOM.scrollIntoView({ behavior: "smooth" }); - } - } -} - -export function scrollSummary(editor: Editor, marking: IMarking) { - if (editor) { - const pos = findNthH1(editor, marking.sequence, marking.level); - scrollToNode(editor, pos); - } -} diff --git a/packages/editor/document-editor/tailwind.config.js b/packages/editor/document-editor/tailwind.config.js deleted file mode 100644 index f32063158..000000000 --- a/packages/editor/document-editor/tailwind.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); - -module.exports = { - // prefix ui lib classes to avoid conflicting with the app - ...sharedConfig, -}; diff --git a/packages/editor/document-editor/tsconfig.json b/packages/editor/document-editor/tsconfig.json deleted file mode 100644 index c15534037..000000000 --- a/packages/editor/document-editor/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "tsconfig/react-library.json", - "include": [ - "src/**/*", - "index.d.ts" - ], - "exclude": [ - "dist", - "build", - "node_modules" - ], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/packages/editor/document-editor/tsup.config.ts b/packages/editor/document-editor/tsup.config.ts deleted file mode 100644 index 5e89e04af..000000000 --- a/packages/editor/document-editor/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig, Options } from "tsup"; - -export default defineConfig((options: Options) => ({ - entry: ["src/index.ts"], - format: ["cjs", "esm"], - dts: true, - clean: false, - external: ["react"], - injectStyle: true, - ...options, -})); diff --git a/packages/editor/extensions/.eslintrc.js b/packages/editor/extensions/.eslintrc.js deleted file mode 100644 index c8df60750..000000000 --- a/packages/editor/extensions/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["custom"], -}; diff --git a/packages/editor/extensions/.prettierignore b/packages/editor/extensions/.prettierignore deleted file mode 100644 index 43e8a7b8f..000000000 --- a/packages/editor/extensions/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.next -.vercel -.tubro -out/ -dis/ -build/ \ No newline at end of file diff --git a/packages/editor/extensions/.prettierrc b/packages/editor/extensions/.prettierrc deleted file mode 100644 index 87d988f1b..000000000 --- a/packages/editor/extensions/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "es5" -} diff --git a/packages/editor/extensions/Readme.md b/packages/editor/extensions/Readme.md deleted file mode 100644 index 39aca1226..000000000 --- a/packages/editor/extensions/Readme.md +++ /dev/null @@ -1,97 +0,0 @@ -# @plane/editor-extensions - -## Description - -The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc. - -## Key Features - -- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code. - - `LiteTextEditor` & `LiteTextEditorWithRef` - -- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref) - `LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef` - -## LiteTextEditor - -| Prop | Type | Description | -| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | -| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | -| `value` | `html string` | The initial content of the editor. | -| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press | -| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | -| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | -| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | -| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | - -### Usage - -1. Here is an example of how to use the `RichTextEditor` component - -```tsx - { - onChange(comment_html); - }} -/> -``` - -2. Example of how to use the `LiteTextEditorWithRef` component - -```tsx -const editorRef = useRef(null); - -// can use it to set the editor's value -editorRef.current?.setEditorValue(`${watch("description_html")}`); - -// can use it to clear the editor -editorRef?.current?.clearEditor(); - -return ( - { - onChange(comment_html); - }} - /> -); -``` - -## LiteReadOnlyEditor - -| Prop | Type | Description | -| ------------------------------- | ------------- | --------------------------------------------------------------------- | -| `value` | `html string` | The initial content of the editor. | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | - -### Usage - -Here is an example of how to use the `RichReadOnlyEditor` component - -```tsx - -``` diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json deleted file mode 100644 index 7a5d20933..000000000 --- a/packages/editor/extensions/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@plane/editor-extensions", - "version": "0.21.0", - "description": "Package that powers Plane's Editor with extensions", - "private": true, - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "files": [ - "dist/**/*" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "module": "./dist/index.mjs" - } - }, - "scripts": { - "build": "tsup --minify", - "dev": "tsup --watch", - "check-types": "tsc --noEmit" - }, - "peerDependencies": { - "next": "12.3.2", - "next-themes": "^0.2.1", - "react": "^18.2.0", - "react-dom": "18.2.0" - }, - "dependencies": { - "@plane/editor-core": "*", - "@plane/ui": "*", - "@tiptap/core": "^2.1.13", - "@tiptap/pm": "^2.1.13", - "@tiptap/react": "^2.1.13", - "@tiptap/suggestion": "^2.1.13", - "lucide-react": "^0.378.0", - "tippy.js": "^6.3.7" - }, - "devDependencies": { - "@types/node": "18.15.3", - "@types/react": "^18.2.42", - "@types/react-dom": "^18.2.17", - "eslint-config-custom": "*", - "postcss": "^8.4.38", - "tailwind-config-custom": "*", - "tsconfig": "*", - "tsup": "^7.2.0", - "typescript": "4.9.5" - }, - "keywords": [ - "editor", - "rich-text", - "markdown", - "nextjs", - "react" - ] -} diff --git a/packages/editor/extensions/postcss.config.js b/packages/editor/extensions/postcss.config.js deleted file mode 100644 index 07aa434b2..000000000 --- a/packages/editor/extensions/postcss.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// If you want to use other PostCSS plugins, see the following: -// https://tailwindcss.com/docs/using-with-preprocessors - -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/editor/extensions/src/extensions/index.ts b/packages/editor/extensions/src/extensions/index.ts deleted file mode 100644 index 9ceeb6493..000000000 --- a/packages/editor/extensions/src/extensions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./drag-drop"; -export * from "./slash-commands"; diff --git a/packages/editor/extensions/src/index.ts b/packages/editor/extensions/src/index.ts deleted file mode 100644 index 440de1351..000000000 --- a/packages/editor/extensions/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "src/styles/drag-drop.css"; - -export { DragAndDrop, SlashCommand } from "src/extensions"; diff --git a/packages/editor/extensions/tailwind.config.js b/packages/editor/extensions/tailwind.config.js deleted file mode 100644 index f32063158..000000000 --- a/packages/editor/extensions/tailwind.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); - -module.exports = { - // prefix ui lib classes to avoid conflicting with the app - ...sharedConfig, -}; diff --git a/packages/editor/extensions/tsconfig.json b/packages/editor/extensions/tsconfig.json deleted file mode 100644 index c15534037..000000000 --- a/packages/editor/extensions/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "tsconfig/react-library.json", - "include": [ - "src/**/*", - "index.d.ts" - ], - "exclude": [ - "dist", - "build", - "node_modules" - ], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/packages/editor/extensions/tsup.config.ts b/packages/editor/extensions/tsup.config.ts deleted file mode 100644 index 5e89e04af..000000000 --- a/packages/editor/extensions/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig, Options } from "tsup"; - -export default defineConfig((options: Options) => ({ - entry: ["src/index.ts"], - format: ["cjs", "esm"], - dts: true, - clean: false, - external: ["react"], - injectStyle: true, - ...options, -})); diff --git a/packages/editor/lite-text-editor/.eslintrc.js b/packages/editor/lite-text-editor/.eslintrc.js deleted file mode 100644 index c8df60750..000000000 --- a/packages/editor/lite-text-editor/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["custom"], -}; diff --git a/packages/editor/lite-text-editor/.prettierignore b/packages/editor/lite-text-editor/.prettierignore deleted file mode 100644 index 43e8a7b8f..000000000 --- a/packages/editor/lite-text-editor/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.next -.vercel -.tubro -out/ -dis/ -build/ \ No newline at end of file diff --git a/packages/editor/lite-text-editor/.prettierrc b/packages/editor/lite-text-editor/.prettierrc deleted file mode 100644 index 87d988f1b..000000000 --- a/packages/editor/lite-text-editor/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "es5" -} diff --git a/packages/editor/lite-text-editor/Readme.md b/packages/editor/lite-text-editor/Readme.md deleted file mode 100644 index 1f10f5ff4..000000000 --- a/packages/editor/lite-text-editor/Readme.md +++ /dev/null @@ -1,97 +0,0 @@ -# @plane/lite-text-editor - -## Description - -The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc. - -## Key Features - -- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code. - - `LiteTextEditor` & `LiteTextEditorWithRef` - -- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref) - `LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef` - -## LiteTextEditor - -| Prop | Type | Description | -| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | -| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | -| `value` | `html string` | The initial content of the editor. | -| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press | -| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | -| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | -| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | -| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | - -### Usage - -1. Here is an example of how to use the `RichTextEditor` component - -```tsx - { - onChange(comment_html); - }} -/> -``` - -2. Example of how to use the `LiteTextEditorWithRef` component - -```tsx -const editorRef = useRef(null); - -// can use it to set the editor's value -editorRef.current?.setEditorValue(`${watch("description_html")}`); - -// can use it to clear the editor -editorRef?.current?.clearEditor(); - -return ( - { - onChange(comment_html); - }} - /> -); -``` - -## LiteReadOnlyEditor - -| Prop | Type | Description | -| ------------------------------- | ------------- | --------------------------------------------------------------------- | -| `value` | `html string` | The initial content of the editor. | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | - -### Usage - -Here is an example of how to use the `RichReadOnlyEditor` component - -```tsx - -``` diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json deleted file mode 100644 index 8e03e82ac..000000000 --- a/packages/editor/lite-text-editor/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@plane/lite-text-editor", - "version": "0.21.0", - "description": "Package that powers Plane's Comment Editor", - "private": true, - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "files": [ - "dist/**/*" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "module": "./dist/index.mjs" - } - }, - "scripts": { - "build": "tsup --minify", - "dev": "tsup --watch", - "check-types": "tsc --noEmit", - "format": "prettier --write \"**/*.{ts,tsx,md}\"" - }, - "peerDependencies": { - "next": "12.3.2", - "next-themes": "^0.2.1", - "react": "^18.2.0", - "react-dom": "18.2.0" - }, - "dependencies": { - "@plane/editor-core": "*", - "@plane/ui": "*" - }, - "devDependencies": { - "@types/node": "18.15.3", - "@types/react": "^18.2.42", - "@types/react-dom": "^18.2.17", - "eslint-config-custom": "*", - "postcss": "^8.4.38", - "tailwind-config-custom": "*", - "tsconfig": "*", - "tsup": "^7.2.0", - "typescript": "4.9.5" - }, - "keywords": [ - "editor", - "rich-text", - "markdown", - "nextjs", - "react" - ] -} diff --git a/packages/editor/lite-text-editor/postcss.config.js b/packages/editor/lite-text-editor/postcss.config.js deleted file mode 100644 index 07aa434b2..000000000 --- a/packages/editor/lite-text-editor/postcss.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// If you want to use other PostCSS plugins, see the following: -// https://tailwindcss.com/docs/using-with-preprocessors - -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/editor/lite-text-editor/src/index.ts b/packages/editor/lite-text-editor/src/index.ts deleted file mode 100644 index 3ca8c71db..000000000 --- a/packages/editor/lite-text-editor/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { LiteTextEditor, LiteTextEditorWithRef } from "src/ui"; -export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "src/ui/read-only"; -export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core"; - -export type { ILiteTextEditor } from "src/ui"; -export type { ILiteTextReadOnlyEditor } from "src/ui/read-only"; -export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; diff --git a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx deleted file mode 100644 index c4b24d166..000000000 --- a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { EnterKeyExtension } from "src/ui/extensions/enter-key-extension"; - -export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [EnterKeyExtension(onEnterKeyPress)]; diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx deleted file mode 100644 index 77d3ca0ec..000000000 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import * as React from "react"; -// editor-core -import { - IMentionSuggestion, - EditorContainer, - EditorContentWrapper, - getEditorClassNames, - useEditor, - IMentionHighlight, - EditorRefApi, - TFileHandler, -} from "@plane/editor-core"; -// extensions -import { LiteTextEditorExtensions } from "src/ui/extensions"; - -export interface ILiteTextEditor { - initialValue: string; - value?: string | null; - fileHandler: TFileHandler; - containerClassName?: string; - editorClassName?: string; - onChange?: (json: object, html: string) => void; - forwardedRef?: React.MutableRefObject; - onEnterKeyPress?: (e?: any) => void; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; - tabIndex?: number; - placeholder?: string | ((isFocused: boolean, value: string) => string); - id?: string; -} - -const LiteTextEditor = (props: ILiteTextEditor) => { - const { - onChange, - initialValue, - fileHandler, - value, - containerClassName, - editorClassName = "", - forwardedRef, - onEnterKeyPress, - tabIndex, - mentionHandler, - placeholder = "Add comment...", - id = "", - } = props; - - const editor = useEditor({ - onChange, - initialValue, - value, - id, - editorClassName, - fileHandler, - forwardedRef, - extensions: LiteTextEditorExtensions(onEnterKeyPress), - mentionHandler, - placeholder, - tabIndex, - }); - - const editorContainerClassName = getEditorClassNames({ - noBorder: true, - borderOnFocus: false, - containerClassName, - }); - - if (!editor) return null; - - return ( - -
- -
-
- ); -}; - -const LiteTextEditorWithRef = React.forwardRef((props, ref) => ( - } /> -)); - -LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef"; - -export { LiteTextEditor, LiteTextEditorWithRef }; diff --git a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx deleted file mode 100644 index 5ceb6956e..000000000 --- a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react"; -import { - EditorContainer, - EditorContentWrapper, - EditorReadOnlyRefApi, - getEditorClassNames, - IMentionHighlight, - useReadOnlyEditor, -} from "@plane/editor-core"; - -export interface ILiteTextReadOnlyEditor { - initialValue: string; - borderOnFocus?: boolean; - containerClassName?: string; - editorClassName?: string; - forwardedRef?: React.MutableRefObject; - mentionHandler: { - highlights: () => Promise; - }; - tabIndex?: number; -} - -const LiteTextReadOnlyEditor = ({ - containerClassName, - editorClassName = "", - initialValue, - forwardedRef, - mentionHandler, - tabIndex, -}: ILiteTextReadOnlyEditor) => { - const editor = useReadOnlyEditor({ - initialValue, - editorClassName, - forwardedRef, - mentionHandler, - }); - - const editorContainerClassName = getEditorClassNames({ - containerClassName, - }); - - if (!editor) return null; - - return ( - -
- -
-
- ); -}; - -const LiteTextReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - } /> -)); - -LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; - -export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef }; diff --git a/packages/editor/lite-text-editor/tailwind.config.js b/packages/editor/lite-text-editor/tailwind.config.js deleted file mode 100644 index f32063158..000000000 --- a/packages/editor/lite-text-editor/tailwind.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); - -module.exports = { - // prefix ui lib classes to avoid conflicting with the app - ...sharedConfig, -}; diff --git a/packages/editor/lite-text-editor/tsconfig.json b/packages/editor/lite-text-editor/tsconfig.json deleted file mode 100644 index c15534037..000000000 --- a/packages/editor/lite-text-editor/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "tsconfig/react-library.json", - "include": [ - "src/**/*", - "index.d.ts" - ], - "exclude": [ - "dist", - "build", - "node_modules" - ], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/packages/editor/lite-text-editor/tsup.config.ts b/packages/editor/lite-text-editor/tsup.config.ts deleted file mode 100644 index 5e89e04af..000000000 --- a/packages/editor/lite-text-editor/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig, Options } from "tsup"; - -export default defineConfig((options: Options) => ({ - entry: ["src/index.ts"], - format: ["cjs", "esm"], - dts: true, - clean: false, - external: ["react"], - injectStyle: true, - ...options, -})); diff --git a/packages/editor/core/package.json b/packages/editor/package.json similarity index 87% rename from packages/editor/core/package.json rename to packages/editor/package.json index d91758019..1c30558e3 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { - "name": "@plane/editor-core", - "version": "0.21.0", + "name": "@plane/editor", + "version": "0.22.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -28,9 +28,11 @@ "react-dom": "18.2.0" }, "dependencies": { + "@floating-ui/react": "^0.26.4", "@plane/ui": "*", "@tiptap/core": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13", + "@tiptap/extension-collaboration": "^2.3.2", "@tiptap/extension-image": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-mention": "^2.1.13", @@ -54,7 +56,11 @@ "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.9" + "tiptap-markdown": "^0.8.9", + "y-indexeddb": "^9.0.12", + "y-prosemirror": "^1.2.5", + "y-protocols": "^1.0.6", + "yjs": "^13.6.15" }, "devDependencies": { "@types/node": "18.15.3", diff --git a/packages/editor/core/postcss.config.js b/packages/editor/postcss.config.js similarity index 100% rename from packages/editor/core/postcss.config.js rename to packages/editor/postcss.config.js diff --git a/packages/editor/rich-text-editor/.eslintrc.js b/packages/editor/rich-text-editor/.eslintrc.js deleted file mode 100644 index c8df60750..000000000 --- a/packages/editor/rich-text-editor/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ["custom"], -}; diff --git a/packages/editor/rich-text-editor/.prettierignore b/packages/editor/rich-text-editor/.prettierignore deleted file mode 100644 index 43e8a7b8f..000000000 --- a/packages/editor/rich-text-editor/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -.next -.vercel -.tubro -out/ -dis/ -build/ \ No newline at end of file diff --git a/packages/editor/rich-text-editor/.prettierrc b/packages/editor/rich-text-editor/.prettierrc deleted file mode 100644 index 87d988f1b..000000000 --- a/packages/editor/rich-text-editor/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "printWidth": 120, - "tabWidth": 2, - "trailingComma": "es5" -} diff --git a/packages/editor/rich-text-editor/Readme.md b/packages/editor/rich-text-editor/Readme.md deleted file mode 100644 index 2e5662e30..000000000 --- a/packages/editor/rich-text-editor/Readme.md +++ /dev/null @@ -1,98 +0,0 @@ -# @plane/rich-text-editor - -## Description - -The `@plane/rich-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Slash Commands and many more. - -## Key Features - -- **Exported Components**: There are two components exported from the Rich text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code. - - `RichTextEditor` & `RichTextEditorWithRef` - -- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Rich editor types (with and without Ref) - `RichReadOnlyEditor` &`RichReadOnlyEditorWithRef` - -## RichTextEditor - -| Prop | Type | Description | -| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `uploadFile` | `(file: File) => Promise` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. | -| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. | -| `value` | `html string` | The initial content of the editor. | -| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. | -| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. | -| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. | -| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | - -### Usage - -1. Here is an example of how to use the `RichTextEditor` component - -```tsx - { - setShowAlert(true); - setIsSubmitting("submitting"); - onChange(description_html); - // custom stuff you want to do - }} -/> -``` - -2. Example of how to use the `RichTextEditorWithRef` component - -```tsx -const editorRef = useRef(null); - -// can use it to set the editor's value -editorRef.current?.setEditorValue(`${watch("description_html")}`); - -// can use it to clear the editor -editorRef?.current?.clearEditor(); - -return ( - { - onChange(description_html); - // custom stuff you want to do - }} - /> -); -``` - -## RichReadOnlyEditor - -| Prop | Type | Description | -| ------------------------------- | ------------- | --------------------------------------------------------------------- | -| `value` | `html string` | The initial content of the editor. | -| `noBorder` | `boolean` | If set to true, the editor will not have a border. | -| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. | -| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. | -| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. | - -### Usage - -Here is an example of how to use the `RichReadOnlyEditor` component - -```tsx - -``` diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json deleted file mode 100644 index c0b3d28cc..000000000 --- a/packages/editor/rich-text-editor/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@plane/rich-text-editor", - "version": "0.21.0", - "description": "Rich Text Editor that powers Plane", - "private": true, - "main": "./dist/index.mjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "files": [ - "dist/**/*" - ], - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "module": "./dist/index.mjs" - } - }, - "scripts": { - "build": "tsup --minify", - "dev": "tsup --watch", - "check-types": "tsc --noEmit", - "format": "prettier --write \"**/*.{ts,tsx,md}\"" - }, - "peerDependencies": { - "next": "12.3.2", - "next-themes": "^0.2.1", - "react": "^18.2.0", - "react-dom": "18.2.0" - }, - "dependencies": { - "@plane/editor-core": "*", - "@plane/editor-extensions": "*", - "@tiptap/core": "^2.1.13", - "lucide-react": "^0.378.0" - }, - "devDependencies": { - "@types/node": "18.15.3", - "@types/react": "^18.2.42", - "@types/react-dom": "^18.2.17", - "eslint-config-custom": "*", - "postcss": "^8.4.38", - "react": "^18.2.0", - "tailwind-config-custom": "*", - "tsconfig": "*", - "tsup": "^7.2.0", - "typescript": "4.9.5" - }, - "keywords": [ - "editor", - "rich-text", - "markdown", - "nextjs", - "react" - ] -} diff --git a/packages/editor/rich-text-editor/postcss.config.js b/packages/editor/rich-text-editor/postcss.config.js deleted file mode 100644 index 07aa434b2..000000000 --- a/packages/editor/rich-text-editor/postcss.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// If you want to use other PostCSS plugins, see the following: -// https://tailwindcss.com/docs/using-with-preprocessors - -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts deleted file mode 100644 index ad6f043a1..000000000 --- a/packages/editor/rich-text-editor/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { RichTextEditor, RichTextEditorWithRef } from "src/ui"; -export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "src/ui/read-only"; - -export type { IRichTextEditor } from "src/ui"; - -export type { IRichTextReadOnlyEditor } from "src/ui/read-only"; -export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core"; -export type { EditorRefApi, EditorReadOnlyRefApi } from "@plane/editor-core"; diff --git a/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx deleted file mode 100644 index 70037f046..000000000 --- a/packages/editor/rich-text-editor/src/ui/extensions/enter-key-extension.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Extension } from "@tiptap/core"; - -export const EnterKeyExtension = (onEnterKeyPress?: () => void) => - Extension.create({ - name: "enterKey", - - addKeyboardShortcuts(this) { - return { - Enter: () => { - if (onEnterKeyPress) { - onEnterKeyPress(); - } - return true; - }, - "Shift-Enter": ({ editor }) => - editor.commands.first(({ commands }) => [ - () => commands.newlineInCode(), - () => commands.splitListItem("listItem"), - () => commands.createParagraphNear(), - () => commands.liftEmptyBlock(), - () => commands.splitBlock(), - ]), - }; - }, - }); diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx deleted file mode 100644 index 4face2cb7..000000000 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { UploadImage } from "@plane/editor-core"; -import { DragAndDrop, SlashCommand } from "@plane/editor-extensions"; -import { EnterKeyExtension } from "./enter-key-extension"; - -type TArguments = { - uploadFile: UploadImage; - dragDropEnabled?: boolean; - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; - onEnterKeyPress?: () => void; -}; - -export const RichTextEditorExtensions = ({ - uploadFile, - dragDropEnabled, - setHideDragHandle, - onEnterKeyPress, -}: TArguments) => [ - SlashCommand(uploadFile), - dragDropEnabled === true && DragAndDrop(setHideDragHandle), - // TODO; add the extension conditionally for forms that don't require it - // EnterKeyExtension(onEnterKeyPress), -]; diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx deleted file mode 100644 index 2b8348a62..000000000 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; -import * as React from "react"; -// editor-core -import { - EditorContainer, - EditorContentWrapper, - getEditorClassNames, - IMentionHighlight, - IMentionSuggestion, - useEditor, - EditorRefApi, - TFileHandler, -} from "@plane/editor-core"; -// extensions -import { RichTextEditorExtensions } from "src/ui/extensions"; -// components -import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; - -export type IRichTextEditor = { - initialValue: string; - value?: string | null; - dragDropEnabled?: boolean; - fileHandler: TFileHandler; - id?: string; - containerClassName?: string; - editorClassName?: string; - onChange?: (json: object, html: string) => void; - forwardedRef?: React.MutableRefObject; - debouncedUpdatesEnabled?: boolean; - mentionHandler: { - highlights: () => Promise; - suggestions: () => Promise; - }; - placeholder?: string | ((isFocused: boolean, value: string) => string); - tabIndex?: number; - onEnterKeyPress?: (e?: any) => void; -}; - -const RichTextEditor = (props: IRichTextEditor) => { - const { - onChange, - dragDropEnabled, - initialValue, - value, - fileHandler, - containerClassName, - editorClassName = "", - forwardedRef, - // rerenderOnPropsChange, - id = "", - placeholder, - tabIndex, - mentionHandler, - onEnterKeyPress, - } = props; - - const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); - - // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin - // loads such that we can invoke it from react when the cursor leaves the container - const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { - setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); - }; - - const editor = useEditor({ - id, - editorClassName, - fileHandler, - onChange, - initialValue, - value, - forwardedRef, - // rerenderOnPropsChange, - extensions: RichTextEditorExtensions({ - uploadFile: fileHandler.upload, - dragDropEnabled, - setHideDragHandle: setHideDragHandleFunction, - onEnterKeyPress, - }), - tabIndex, - mentionHandler, - placeholder, - }); - - const editorContainerClassName = getEditorClassNames({ - noBorder: true, - borderOnFocus: false, - containerClassName, - }); - - if (!editor) return null; - - return ( - - {editor && } -
- -
-
- ); -}; - -const RichTextEditorWithRef = React.forwardRef((props, ref) => ( - } /> -)); - -RichTextEditorWithRef.displayName = "RichTextEditorWithRef"; - -export { RichTextEditor, RichTextEditorWithRef }; diff --git a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx deleted file mode 100644 index 3220c477e..000000000 --- a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; -import { - EditorReadOnlyRefApi, - EditorContainer, - EditorContentWrapper, - getEditorClassNames, - IMentionHighlight, - useReadOnlyEditor, -} from "@plane/editor-core"; -import * as React from "react"; - -export interface IRichTextReadOnlyEditor { - initialValue: string; - containerClassName?: string; - editorClassName?: string; - tabIndex?: number; - forwardedRef?: React.MutableRefObject; - mentionHandler: { - highlights: () => Promise; - }; -} - -const RichTextReadOnlyEditor = (props: IRichTextReadOnlyEditor) => { - const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props; - - const editor = useReadOnlyEditor({ - initialValue, - editorClassName, - forwardedRef, - mentionHandler, - }); - - const editorContainerClassName = getEditorClassNames({ - containerClassName, - }); - - if (!editor) return null; - - return ( - -
- -
-
- ); -}; - -const RichTextReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - } /> -)); - -RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; - -export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef }; diff --git a/packages/editor/rich-text-editor/tailwind.config.js b/packages/editor/rich-text-editor/tailwind.config.js deleted file mode 100644 index f32063158..000000000 --- a/packages/editor/rich-text-editor/tailwind.config.js +++ /dev/null @@ -1,6 +0,0 @@ -const sharedConfig = require("tailwind-config-custom/tailwind.config.js"); - -module.exports = { - // prefix ui lib classes to avoid conflicting with the app - ...sharedConfig, -}; diff --git a/packages/editor/rich-text-editor/tsconfig.json b/packages/editor/rich-text-editor/tsconfig.json deleted file mode 100644 index c15534037..000000000 --- a/packages/editor/rich-text-editor/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "tsconfig/react-library.json", - "include": [ - "src/**/*", - "index.d.ts" - ], - "exclude": [ - "dist", - "build", - "node_modules" - ], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/packages/editor/rich-text-editor/tsup.config.ts b/packages/editor/rich-text-editor/tsup.config.ts deleted file mode 100644 index 5e89e04af..000000000 --- a/packages/editor/rich-text-editor/tsup.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig, Options } from "tsup"; - -export default defineConfig((options: Options) => ({ - entry: ["src/index.ts"], - format: ["cjs", "esm"], - dts: true, - clean: false, - external: ["react"], - injectStyle: true, - ...options, -})); diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx new file mode 100644 index 000000000..57583e60a --- /dev/null +++ b/packages/editor/src/ce/extensions/document-extensions.tsx @@ -0,0 +1,18 @@ +import { SlashCommand } from "@/extensions"; +// hooks +import { TFileHandler } from "@/hooks/use-editor"; +// plane editor types +import { TIssueEmbedConfig } from "@/plane-editor/types"; + +type Props = { + fileHandler: TFileHandler; + issueEmbedConfig: TIssueEmbedConfig | undefined; +}; + +export const DocumentEditorAdditionalExtensions = (props: Props) => { + const { fileHandler } = props; + + const extensions = [SlashCommand(fileHandler.upload)]; + + return extensions; +}; diff --git a/packages/editor/src/ce/extensions/index.ts b/packages/editor/src/ce/extensions/index.ts new file mode 100644 index 000000000..4a975b8c5 --- /dev/null +++ b/packages/editor/src/ce/extensions/index.ts @@ -0,0 +1 @@ +export * from "./document-extensions"; diff --git a/packages/editor/src/ce/providers/collaboration-provider.ts b/packages/editor/src/ce/providers/collaboration-provider.ts new file mode 100644 index 000000000..edfb031da --- /dev/null +++ b/packages/editor/src/ce/providers/collaboration-provider.ts @@ -0,0 +1,111 @@ +import { IndexeddbPersistence } from "y-indexeddb"; +import * as Y from "yjs"; + +export interface CompleteCollaboratorProviderConfiguration { + /** + * The identifier/name of your document + */ + name: string; + /** + * The actual Y.js document + */ + document: Y.Doc; + /** + * onChange callback + */ + onChange: (updates: Uint8Array, source?: string) => void; + /** + * Whether connection to the database has been established and all available content has been loaded or not. + */ + hasIndexedDBSynced: boolean; +} + +export type CollaborationProviderConfiguration = Required> & + Partial; + +export class CollaborationProvider { + public configuration: CompleteCollaboratorProviderConfiguration = { + name: "", + document: new Y.Doc(), + onChange: () => {}, + hasIndexedDBSynced: false, + }; + + unsyncedChanges = 0; + + private initialSync = false; + + constructor(configuration: CollaborationProviderConfiguration) { + this.setConfiguration(configuration); + + this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document); + this.indexeddbProvider.on("synced", () => { + this.configuration.hasIndexedDBSynced = true; + }); + this.document.on("update", this.documentUpdateHandler.bind(this)); + this.document.on("destroy", this.documentDestroyHandler.bind(this)); + } + + private indexeddbProvider: IndexeddbPersistence; + + public setConfiguration(configuration: Partial = {}): void { + this.configuration = { + ...this.configuration, + ...configuration, + }; + } + + get document() { + return this.configuration.document; + } + + public hasUnsyncedChanges(): boolean { + return this.unsyncedChanges > 0; + } + + private resetUnsyncedChanges() { + this.unsyncedChanges = 0; + } + + private incrementUnsyncedChanges() { + this.unsyncedChanges += 1; + } + + public setSynced() { + this.resetUnsyncedChanges(); + } + + public async hasIndexedDBSynced() { + await this.indexeddbProvider.whenSynced; + return this.configuration.hasIndexedDBSynced; + } + + async documentUpdateHandler(_update: Uint8Array, origin: any) { + await this.indexeddbProvider.whenSynced; + + // return if the update is from the provider itself + if (origin === this) return; + + // call onChange with the update + const stateVector = Y.encodeStateAsUpdate(this.document); + + if (!this.initialSync) { + this.configuration.onChange?.(stateVector, "initialSync"); + this.initialSync = true; + return; + } + + this.configuration.onChange?.(stateVector); + this.incrementUnsyncedChanges(); + } + + getUpdateFromIndexedDB(): Uint8Array { + const update = Y.encodeStateAsUpdate(this.document); + return update; + } + + documentDestroyHandler() { + this.document.off("update", this.documentUpdateHandler); + this.document.off("destroy", this.documentDestroyHandler); + } +} diff --git a/packages/editor/src/ce/providers/index.ts b/packages/editor/src/ce/providers/index.ts new file mode 100644 index 000000000..9ff7a505f --- /dev/null +++ b/packages/editor/src/ce/providers/index.ts @@ -0,0 +1 @@ +export * from "./collaboration-provider"; diff --git a/packages/editor/src/ce/types/index.ts b/packages/editor/src/ce/types/index.ts new file mode 100644 index 000000000..f30596cb0 --- /dev/null +++ b/packages/editor/src/ce/types/index.ts @@ -0,0 +1 @@ +export * from "./issue-embed"; diff --git a/packages/editor/src/ce/types/issue-embed.ts b/packages/editor/src/ce/types/issue-embed.ts new file mode 100644 index 000000000..d68d182a0 --- /dev/null +++ b/packages/editor/src/ce/types/issue-embed.ts @@ -0,0 +1,17 @@ +export type TEmbedConfig = { + issue?: TIssueEmbedConfig; +}; + +export type TReadOnlyEmbedConfig = TEmbedConfig; + +export type TIssueEmbedConfig = { + widgetCallback: ({ + issueId, + projectId, + workspaceSlug, + }: { + issueId: string; + projectId: string | undefined; + workspaceSlug: string | undefined; + }) => React.ReactNode; +}; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/src/core/components/editors/document/editor.tsx similarity index 77% rename from packages/editor/document-editor/src/ui/index.tsx rename to packages/editor/src/core/components/editors/document/editor.tsx index 1cafe6de7..d39f1b99f 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/src/core/components/editors/document/editor.tsx @@ -1,47 +1,48 @@ import React, { useState } from "react"; -// editor-core -import { - getEditorClassNames, - EditorRefApi, - IMentionHighlight, - IMentionSuggestion, - TFileHandler, -} from "@plane/editor-core"; // components -import { PageRenderer } from "src/ui/components/page-renderer"; +import { PageRenderer } from "@/components/editors"; +// helpers +import { getEditorClassNames } from "@/helpers/common"; // hooks -import { useDocumentEditor } from "src/hooks/use-document-editor"; +import { useDocumentEditor } from "@/hooks/use-document-editor"; +import { TFileHandler } from "@/hooks/use-editor"; +// plane editor types +import { TEmbedConfig } from "@/plane-editor/types"; +// types +import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types"; interface IDocumentEditor { - id: string; - value: Uint8Array; - fileHandler: TFileHandler; - handleEditorReady?: (value: boolean) => void; containerClassName?: string; editorClassName?: string; - onChange: (updates: Uint8Array) => void; + embedHandler: TEmbedConfig; + fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + handleEditorReady?: (value: boolean) => void; + id: string; mentionHandler: { highlights: () => Promise; suggestions: () => Promise; }; - tabIndex?: number; + onChange: (updates: Uint8Array) => void; placeholder?: string | ((isFocused: boolean, value: string) => string); + tabIndex?: number; + value: Uint8Array; } const DocumentEditor = (props: IDocumentEditor) => { const { - onChange, - id, - value, - fileHandler, containerClassName, editorClassName = "", - mentionHandler, - handleEditorReady, + embedHandler, + fileHandler, forwardedRef, - tabIndex, + handleEditorReady, + id, + mentionHandler, + onChange, placeholder, + tabIndex, + value, } = props; // states const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {}); @@ -52,9 +53,10 @@ const DocumentEditor = (props: IDocumentEditor) => { }; // use document editor - const editor = useDocumentEditor({ + const { editor, isIndexedDbSynced } = useDocumentEditor({ id, editorClassName, + embedHandler, fileHandler, value, onChange, @@ -72,7 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => { containerClassName, }); - if (!editor) return null; + if (!editor || !isIndexedDbSynced) return null; return ( ((p DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; -export { DocumentEditor, DocumentEditorWithRef }; +export { DocumentEditorWithRef }; diff --git a/packages/editor/src/core/components/editors/document/helpers.ts b/packages/editor/src/core/components/editors/document/helpers.ts new file mode 100644 index 000000000..b2ef91fb7 --- /dev/null +++ b/packages/editor/src/core/components/editors/document/helpers.ts @@ -0,0 +1,19 @@ +import { Extensions, generateJSON, getSchema } from "@tiptap/core"; +import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions"; + +/** + * @description return an object with contentJSON and editorSchema + * @description contentJSON- ProseMirror JSON from HTML content + * @description editorSchema- editor schema from extensions + * @param {string} html + * @returns {object} {contentJSON, editorSchema} + */ +export const generateJSONfromHTMLForDocumentEditor = (html: string) => { + const extensions = [...CoreEditorExtensionsWithoutProps(), ...DocumentEditorExtensionsWithoutProps()]; + const contentJSON = generateJSON(html ?? "

", extensions as Extensions); + const editorSchema = getSchema(extensions as Extensions); + return { + contentJSON, + editorSchema, + }; +}; diff --git a/packages/editor/src/core/components/editors/document/index.ts b/packages/editor/src/core/components/editors/document/index.ts new file mode 100644 index 000000000..574c613be --- /dev/null +++ b/packages/editor/src/core/components/editors/document/index.ts @@ -0,0 +1,4 @@ +export * from "./editor"; +export * from "./page-renderer"; +export * from "./read-only-editor"; +export * from "./helpers"; diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/src/core/components/editors/document/page-renderer.tsx similarity index 95% rename from packages/editor/document-editor/src/ui/components/page-renderer.tsx rename to packages/editor/src/core/components/editors/document/page-renderer.tsx index a6b5eb5e8..5e1d1ef8a 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/src/core/components/editors/document/page-renderer.tsx @@ -1,9 +1,4 @@ import { useCallback, useRef, useState } from "react"; -import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"; -import { Node } from "@tiptap/pm/model"; -import { EditorView } from "@tiptap/pm/view"; -import { Editor, ReactRenderer } from "@tiptap/react"; -import { LinkView, LinkViewProps } from "./links/link-view"; import { autoUpdate, computePosition, @@ -14,7 +9,13 @@ import { useFloating, useInteractions, } from "@floating-ui/react"; -import BlockMenu from "../menu//block-menu"; +import { Node } from "@tiptap/pm/model"; +import { EditorView } from "@tiptap/pm/view"; +import { Editor, ReactRenderer } from "@tiptap/react"; +// components +import { EditorContainer, EditorContentWrapper } from "@/components/editors"; +import { LinkView, LinkViewProps } from "@/components/links"; +import { BlockMenu } from "@/components/menus"; type IPageRenderer = { editor: Editor; diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx similarity index 67% rename from packages/editor/document-editor/src/ui/readonly/index.tsx rename to packages/editor/src/core/components/editors/document/read-only-editor.tsx index 0e75c2db4..9b91d9782 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -1,13 +1,22 @@ import { forwardRef, MutableRefObject } from "react"; -import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core"; // components -import { PageRenderer } from "src/ui/components/page-renderer"; -import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget"; +import { PageRenderer } from "@/components/editors"; +// extensions +import { IssueWidget } from "@/extensions"; +// helpers +import { getEditorClassNames } from "@/helpers/common"; +// hooks +import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; +// plane web types +import { TEmbedConfig } from "@/plane-editor/types"; +// types +import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; interface IDocumentReadOnlyEditor { initialValue: string; containerClassName: string; editorClassName?: string; + embedHandler: TEmbedConfig; tabIndex?: number; handleEditorReady?: (value: boolean) => void; mentionHandler: { @@ -20,6 +29,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const { containerClassName, editorClassName = "", + embedHandler, initialValue, forwardedRef, tabIndex, @@ -32,7 +42,12 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { mentionHandler, forwardedRef, handleEditorReady, - extensions: [IssueWidgetPlaceholder()], + extensions: [ + embedHandler?.issue && + IssueWidget({ + widgetCallback: embedHandler?.issue.widgetCallback, + }), + ], }); if (!editor) { @@ -52,4 +67,4 @@ const DocumentReadOnlyEditorWithRef = forwardRef React.ReactNode; + extensions: Extension[]; + hideDragHandleOnMouseLeave: () => void; +}; + +export const EditorWrapper: React.FC = (props) => { + const { + children, + containerClassName, + editorClassName = "", + extensions, + hideDragHandleOnMouseLeave, + id = "", + initialValue, + fileHandler, + forwardedRef, + mentionHandler, + onChange, + placeholder, + tabIndex, + value, + } = props; + + const editor = useEditor({ + editorClassName, + extensions, + fileHandler, + forwardedRef, + id, + initialValue, + mentionHandler, + onChange, + placeholder, + tabIndex, + value, + }); + + const editorContainerClassName = getEditorClassNames({ + noBorder: true, + borderOnFocus: false, + containerClassName, + }); + + if (!editor) return null; + + return ( + + {children?.(editor)} +
+ +
+
+ ); +}; diff --git a/packages/editor/src/core/components/editors/index.ts b/packages/editor/src/core/components/editors/index.ts new file mode 100644 index 000000000..03ada4f72 --- /dev/null +++ b/packages/editor/src/core/components/editors/index.ts @@ -0,0 +1,7 @@ +export * from "./document"; +export * from "./lite-text"; +export * from "./rich-text"; +export * from "./editor-container"; +export * from "./editor-content"; +export * from "./editor-wrapper"; +export * from "./read-only-editor-wrapper"; diff --git a/packages/editor/src/core/components/editors/lite-text/editor.tsx b/packages/editor/src/core/components/editors/lite-text/editor.tsx new file mode 100644 index 000000000..0ef708022 --- /dev/null +++ b/packages/editor/src/core/components/editors/lite-text/editor.tsx @@ -0,0 +1,23 @@ +import { forwardRef } from "react"; +// components +import { EditorWrapper } from "@/components/editors/editor-wrapper"; +// extensions +import { EnterKeyExtension } from "@/extensions"; +// types +import { EditorRefApi, ILiteTextEditor } from "@/types"; + +const LiteTextEditor = (props: ILiteTextEditor) => { + const { onEnterKeyPress } = props; + + const extensions = [EnterKeyExtension(onEnterKeyPress)]; + + return {}} />; +}; + +const LiteTextEditorWithRef = forwardRef((props, ref) => ( + } /> +)); + +LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef"; + +export { LiteTextEditorWithRef }; diff --git a/packages/editor/src/core/components/editors/lite-text/index.ts b/packages/editor/src/core/components/editors/lite-text/index.ts new file mode 100644 index 000000000..b2ba8682a --- /dev/null +++ b/packages/editor/src/core/components/editors/lite-text/index.ts @@ -0,0 +1,2 @@ +export * from "./editor"; +export * from "./read-only-editor"; diff --git a/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx b/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx new file mode 100644 index 000000000..b721c84c5 --- /dev/null +++ b/packages/editor/src/core/components/editors/lite-text/read-only-editor.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from "react"; +// components +import { ReadOnlyEditorWrapper } from "@/components/editors"; +// types +import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types"; + +const LiteTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( + } /> +)); + +LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; + +export { LiteTextReadOnlyEditorWithRef }; 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 new file mode 100644 index 000000000..25da4d9af --- /dev/null +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -0,0 +1,33 @@ +// components +import { EditorContainer, EditorContentWrapper } from "@/components/editors"; +// helpers +import { getEditorClassNames } from "@/helpers/common"; +// hooks +import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; +// types +import { IReadOnlyEditorProps } from "@/types"; + +export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { + const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props; + + const editor = useReadOnlyEditor({ + initialValue, + editorClassName, + forwardedRef, + mentionHandler, + }); + + const editorContainerClassName = getEditorClassNames({ + containerClassName, + }); + + if (!editor) return null; + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx new file mode 100644 index 000000000..069681dab --- /dev/null +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -0,0 +1,46 @@ +import { forwardRef, useCallback, useState } from "react"; +// components +import { EditorWrapper } from "@/components/editors"; +import { EditorBubbleMenu } from "@/components/menus"; +// extensions +import { DragAndDrop, SlashCommand } from "@/extensions"; +// types +import { EditorRefApi, IRichTextEditor } from "@/types"; + +const RichTextEditor = (props: IRichTextEditor) => { + const { dragDropEnabled, fileHandler } = props; + // states + const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {}); + + // this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin + // loads such that we can invoke it from react when the cursor leaves the container + const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => { + setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop); + }; + + const getExtensions = useCallback(() => { + const extensions = [ + SlashCommand(fileHandler.upload), + // TODO; add the extension conditionally for forms that don't require it + // EnterKeyExtension(onEnterKeyPress), + ]; + + if (dragDropEnabled) extensions.push(DragAndDrop(setHideDragHandleFunction)); + + return extensions; + }, [dragDropEnabled, fileHandler.upload]); + + return ( + + {(editor) => <>{editor && }} + + ); +}; + +const RichTextEditorWithRef = forwardRef((props, ref) => ( + } /> +)); + +RichTextEditorWithRef.displayName = "RichTextEditorWithRef"; + +export { RichTextEditorWithRef }; diff --git a/packages/editor/src/core/components/editors/rich-text/index.ts b/packages/editor/src/core/components/editors/rich-text/index.ts new file mode 100644 index 000000000..b2ba8682a --- /dev/null +++ b/packages/editor/src/core/components/editors/rich-text/index.ts @@ -0,0 +1,2 @@ +export * from "./editor"; +export * from "./read-only-editor"; diff --git a/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx b/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx new file mode 100644 index 000000000..8bd7a837a --- /dev/null +++ b/packages/editor/src/core/components/editors/rich-text/read-only-editor.tsx @@ -0,0 +1,12 @@ +import { forwardRef } from "react"; +// types +import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types"; +import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper"; + +const RichTextReadOnlyEditorWithRef = forwardRef((props, ref) => ( + } /> +)); + +RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; + +export { RichTextReadOnlyEditorWithRef }; diff --git a/packages/editor/src/core/components/links/index.ts b/packages/editor/src/core/components/links/index.ts new file mode 100644 index 000000000..8e123098e --- /dev/null +++ b/packages/editor/src/core/components/links/index.ts @@ -0,0 +1,4 @@ +export * from "./link-edit-view"; +export * from "./link-input-view"; +export * from "./link-preview"; +export * from "./link-view"; diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/src/core/components/links/link-edit-view.tsx similarity index 82% rename from packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx rename to packages/editor/src/core/components/links/link-edit-view.tsx index 0cee059df..665e7500a 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx +++ b/packages/editor/src/core/components/links/link-edit-view.tsx @@ -1,8 +1,10 @@ -import { isValidHttpUrl } from "@plane/editor-core"; +import { useEffect, useRef, useState } from "react"; import { Node } from "@tiptap/pm/model"; import { Link2Off } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { LinkViewProps } from "./link-view"; +// components +import { LinkViewProps } from "@/components/links"; +// helpers +import { isValidHttpUrl } from "@/helpers/common"; const InputView = ({ label, @@ -49,21 +51,6 @@ export const LinkEditView = ({ return text; }; - const isValidUrl = (urlString: string) => { - var urlPattern = new RegExp( - "^(https?:\\/\\/)?" + // validate protocol - "([\\w-]+\\.)+[\\w-]{2,}" + // validate domain name - "|((\\d{1,3}\\.){3}\\d{1,3})" + // validate IP (v4) address - "(\\:\\d+)?(\\/[-\\w.%]+)*" + // validate port and path - "(\\?[;&\\w.%=-]*)?" + // validate query string - "(\\#[-\\w]*)?$", // validate fragment locator - "i" - ); - const regexTest = urlPattern.test(urlString); - const urlTest = isValidHttpUrl(urlString); // Ensure you have defined isValidHttpUrl - return regexTest && urlTest; - }; - const handleUpdateLink = (url: string) => { setLocalUrl(url); }; @@ -72,7 +59,7 @@ export const LinkEditView = ({ () => () => { if (linkRemoved.current) return; - const url = isValidUrl(localUrl) ? localUrl : viewProps.url; + const url = isValidHttpUrl(localUrl) ? localUrl : viewProps.url; if (to >= editor.state.doc.content.size) return; diff --git a/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx b/packages/editor/src/core/components/links/link-input-view.tsx similarity index 55% rename from packages/editor/document-editor/src/ui/components/links/link-input-view.tsx rename to packages/editor/src/core/components/links/link-input-view.tsx index fa73adbe1..a66d80e6d 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-input-view.tsx +++ b/packages/editor/src/core/components/links/link-input-view.tsx @@ -1,9 +1,7 @@ -import { LinkViewProps } from "./link-view"; +// components +import { LinkViewProps } from "@/components/links"; -export const LinkInputView = ({ - viewProps, - switchView, -}: { +export const LinkInputView = ({}: { viewProps: LinkViewProps; switchView: (view: "LinkPreview" | "LinkEditView" | "LinkInputView") => void; }) =>

LinkInputView

; diff --git a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx b/packages/editor/src/core/components/links/link-preview.tsx similarity index 95% rename from packages/editor/document-editor/src/ui/components/links/link-preview.tsx rename to packages/editor/src/core/components/links/link-preview.tsx index 0bb719d93..1237c7c98 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx +++ b/packages/editor/src/core/components/links/link-preview.tsx @@ -1,5 +1,6 @@ import { Copy, GlobeIcon, Link2Off, PencilIcon } from "lucide-react"; -import { LinkViewProps } from "./link-view"; +// components +import { LinkViewProps } from "@/components/links"; export const LinkPreview = ({ viewProps, diff --git a/packages/editor/document-editor/src/ui/components/links/link-view.tsx b/packages/editor/src/core/components/links/link-view.tsx similarity index 88% rename from packages/editor/document-editor/src/ui/components/links/link-view.tsx rename to packages/editor/src/core/components/links/link-view.tsx index 9befc514c..988250387 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-view.tsx +++ b/packages/editor/src/core/components/links/link-view.tsx @@ -1,8 +1,7 @@ -import { Editor } from "@tiptap/react"; import { CSSProperties, useEffect, useState } from "react"; -import { LinkEditView } from "./link-edit-view"; -import { LinkInputView } from "./link-input-view"; -import { LinkPreview } from "./link-preview"; +import { Editor } from "@tiptap/react"; +// components +import { LinkEditView, LinkInputView, LinkPreview } from "@/components/links"; export interface LinkViewProps { view?: "LinkPreview" | "LinkEditView" | "LinkInputView"; diff --git a/packages/editor/document-editor/src/ui/menu/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx similarity index 98% rename from packages/editor/document-editor/src/ui/menu/block-menu.tsx rename to packages/editor/src/core/components/menus/block-menu.tsx index 8a303809c..dfe309715 100644 --- a/packages/editor/document-editor/src/ui/menu/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -1,13 +1,13 @@ import { useCallback, useEffect, useRef } from "react"; +import { Editor } from "@tiptap/react"; import tippy, { Instance } from "tippy.js"; import { Copy, LucideIcon, Trash2 } from "lucide-react"; -import { Editor } from "@tiptap/react"; interface BlockMenuProps { editor: Editor; } -export default function BlockMenu(props: BlockMenuProps) { +export const BlockMenu = (props: BlockMenuProps) => { const { editor } = props; const menuRef = useRef(null); const popup = useRef(null); @@ -171,4 +171,4 @@ export default function BlockMenu(props: BlockMenuProps) { })}
); -} +}; diff --git a/packages/editor/src/core/components/menus/bubble-menu/index.ts b/packages/editor/src/core/components/menus/bubble-menu/index.ts new file mode 100644 index 000000000..71a98bada --- /dev/null +++ b/packages/editor/src/core/components/menus/bubble-menu/index.ts @@ -0,0 +1,3 @@ +export * from "./link-selector"; +export * from "./node-selector"; +export * from "./root"; diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx similarity index 92% rename from packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx rename to packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx index e45cfb317..20335e8ab 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/link-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/link-selector.tsx @@ -1,15 +1,17 @@ +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; import { Editor } from "@tiptap/core"; import { Check, Trash } from "lucide-react"; -import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react"; -import { cn, isValidHttpUrl, setLinkEditor, unsetLinkEditor } from "@plane/editor-core"; +// helpers +import { cn, isValidHttpUrl } from "@/helpers/common"; +import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands"; -interface LinkSelectorProps { +type Props = { editor: Editor; isOpen: boolean; setIsOpen: Dispatch>; -} +}; -export const LinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const BubbleMenuLinkSelector: FC = ({ editor, isOpen, setIsOpen }) => { const inputRef = useRef(null); const onLinkSubmit = useCallback(() => { diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx similarity index 92% rename from packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx rename to packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx index 5c1c8479f..466d07c0d 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/node-selector.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/node-selector.tsx @@ -1,6 +1,9 @@ +import { Dispatch, FC, SetStateAction } from "react"; +import { Editor } from "@tiptap/react"; +import { Check, ChevronDown } from "lucide-react"; +// components import { BulletListItem, - cn, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, @@ -12,20 +15,18 @@ import { HeadingFourItem, HeadingFiveItem, HeadingSixItem, -} from "@plane/editor-core"; -import { Editor } from "@tiptap/react"; -import { Check, ChevronDown } from "lucide-react"; -import { Dispatch, FC, SetStateAction } from "react"; + BubbleMenuItem, +} from "@/components/menus"; +// helpers +import { cn } from "@/helpers/common"; -import { BubbleMenuItem } from "."; - -interface NodeSelectorProps { +type Props = { editor: Editor; isOpen: boolean; setIsOpen: Dispatch>; -} +}; -export const NodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { +export const BubbleMenuNodeSelector: FC = ({ editor, isOpen, setIsOpen }) => { const items: BubbleMenuItem[] = [ TextItem(editor), HeadingOneItem(editor), diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/src/core/components/menus/bubble-menu/root.tsx similarity index 91% rename from packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx rename to packages/editor/src/core/components/menus/bubble-menu/root.tsx index c11f0593d..ec72f1540 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/src/core/components/menus/bubble-menu/root.tsx @@ -1,25 +1,27 @@ -import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; import { FC, useEffect, useState } from "react"; - -import { NodeSelector } from "src/ui/menus/bubble-menu/node-selector"; -import { LinkSelector } from "src/ui/menus/bubble-menu/link-selector"; +import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; +import { LucideIcon } from "lucide-react"; +// components import { BoldItem, - cn, + BubbleMenuLinkSelector, + BubbleMenuNodeSelector, CodeItem, - isCellSelection, ItalicItem, - LucideIconType, StrikeThroughItem, UnderLineItem, -} from "@plane/editor-core"; +} from "@/components/menus"; +// extensions +import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; +// helpers +import { cn } from "@/helpers/common"; export interface BubbleMenuItem { key: string; name: string; isActive: () => boolean; command: () => void; - icon: LucideIconType; + icon: LucideIcon; } type EditorBubbleMenuProps = Omit; @@ -105,7 +107,7 @@ export const EditorBubbleMenu: FC = (props: any) => { {isSelecting ? null : ( <> {!props.editor.isActive("table") && ( - { @@ -115,7 +117,7 @@ export const EditorBubbleMenu: FC = (props: any) => { /> )} {!props.editor.isActive("code") && ( - { diff --git a/packages/editor/src/core/components/menus/index.ts b/packages/editor/src/core/components/menus/index.ts new file mode 100644 index 000000000..0c6964ea6 --- /dev/null +++ b/packages/editor/src/core/components/menus/index.ts @@ -0,0 +1,3 @@ +export * from "./bubble-menu"; +export * from "./block-menu"; +export * from "./menu-items"; diff --git a/packages/editor/core/src/ui/menus/menu-items/index.ts b/packages/editor/src/core/components/menus/menu-items.ts similarity index 97% rename from packages/editor/core/src/ui/menus/menu-items/index.ts rename to packages/editor/src/core/components/menus/menu-items.ts index ab2ad8ed4..d5c2316b9 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.ts +++ b/packages/editor/src/core/components/menus/menu-items.ts @@ -17,8 +17,11 @@ import { Heading5, Heading6, CaseSensitive, + LucideIcon, } from "lucide-react"; +import { Selection } from "@tiptap/pm/state"; import { Editor } from "@tiptap/react"; +// helpers import { insertImageCommand, insertTableCommand, @@ -38,17 +41,16 @@ import { toggleStrike, toggleTaskList, toggleUnderline, -} from "src/lib/editor-commands"; -import { LucideIconType } from "src/types/lucide-icon"; -import { UploadImage } from "src/types/upload-image"; -import { Selection } from "@tiptap/pm/state"; +} from "@/helpers/editor-commands"; +// types +import { UploadImage } from "@/types"; export interface EditorMenuItem { key: string; name: string; isActive: () => boolean; command: () => void; - icon: LucideIconType; + icon: LucideIcon; } export const TextItem = (editor: Editor) => diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/src/core/extensions/code-inline/index.tsx similarity index 95% rename from packages/editor/core/src/ui/extensions/code-inline/index.tsx rename to packages/editor/src/core/extensions/code-inline/index.tsx index bc629160a..70270355d 100644 --- a/packages/editor/core/src/ui/extensions/code-inline/index.tsx +++ b/packages/editor/src/core/extensions/code-inline/index.tsx @@ -24,7 +24,7 @@ declare module "@tiptap/core" { } export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/; -export const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g; +const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g; export const CustomCodeInlineExtension = Mark.create({ name: "code", @@ -33,7 +33,7 @@ export const CustomCodeInlineExtension = Mark.create({ return { HTMLAttributes: { class: - "rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm", + "rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200", spellcheck: "false", }, }; diff --git a/packages/editor/core/src/ui/extensions/code/code-block-lowlight.ts b/packages/editor/src/core/extensions/code/code-block-lowlight.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/code/code-block-lowlight.ts rename to packages/editor/src/core/extensions/code/code-block-lowlight.ts diff --git a/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx b/packages/editor/src/core/extensions/code/code-block-node-view.tsx similarity index 96% rename from packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx rename to packages/editor/src/core/extensions/code/code-block-node-view.tsx index 21fc36b39..474813bb6 100644 --- a/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx +++ b/packages/editor/src/core/extensions/code/code-block-node-view.tsx @@ -1,11 +1,15 @@ +"use client"; + import { useState } from "react"; -import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; -import { common, createLowlight } from "lowlight"; -import ts from "highlight.js/lib/languages/typescript"; -import { CopyIcon, CheckIcon } from "lucide-react"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { cn } from "src/lib/utils"; +import { NodeViewWrapper, NodeViewContent } from "@tiptap/react"; +import ts from "highlight.js/lib/languages/typescript"; +import { common, createLowlight } from "lowlight"; +import { CopyIcon, CheckIcon } from "lucide-react"; +// ui import { Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common"; // we just have ts support for now const lowlight = createLowlight(common); diff --git a/packages/editor/core/src/ui/extensions/code/code-block.ts b/packages/editor/src/core/extensions/code/code-block.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/code/code-block.ts rename to packages/editor/src/core/extensions/code/code-block.ts diff --git a/packages/editor/core/src/ui/extensions/code/index.tsx b/packages/editor/src/core/extensions/code/index.tsx similarity index 99% rename from packages/editor/core/src/ui/extensions/code/index.tsx rename to packages/editor/src/core/extensions/code/index.tsx index 206930a87..15d66a6ba 100644 --- a/packages/editor/core/src/ui/extensions/code/index.tsx +++ b/packages/editor/src/core/extensions/code/index.tsx @@ -1,14 +1,14 @@ -import { common, createLowlight } from "lowlight"; +import { Selection } from "@tiptap/pm/state"; +import { ReactNodeViewRenderer } from "@tiptap/react"; import ts from "highlight.js/lib/languages/typescript"; +import { common, createLowlight } from "lowlight"; +// components +import { CodeBlockLowlight } from "./code-block-lowlight"; +import { CodeBlockComponent } from "./code-block-node-view"; const lowlight = createLowlight(common); lowlight.register("ts", ts); -import { Selection } from "@tiptap/pm/state"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -import { CodeBlockComponent } from "./code-block-node-view"; -import { CodeBlockLowlight } from "./code-block-lowlight"; - export const CustomCodeBlockExtension = CodeBlockLowlight.extend({ addNodeView() { return ReactNodeViewRenderer(CodeBlockComponent); diff --git a/packages/editor/core/src/ui/extensions/code/lowlight-plugin.ts b/packages/editor/src/core/extensions/code/lowlight-plugin.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/code/lowlight-plugin.ts rename to packages/editor/src/core/extensions/code/lowlight-plugin.ts diff --git a/packages/editor/src/core/extensions/code/utils/replace-code-block-with-text.ts b/packages/editor/src/core/extensions/code/utils/replace-code-block-with-text.ts new file mode 100644 index 000000000..daf2c5f05 --- /dev/null +++ b/packages/editor/src/core/extensions/code/utils/replace-code-block-with-text.ts @@ -0,0 +1,124 @@ +import { Editor, findParentNode } from "@tiptap/core"; + +type ReplaceCodeBlockParams = { + editor: Editor; + from: number; + to: number; + textContent: string; + cursorPosInsideCodeblock: number; +}; + +export function replaceCodeWithText(editor: Editor): void { + try { + const { from, to } = editor.state.selection; + const cursorPosInsideCodeblock = from; + let replaced = false; + + editor.state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type === editor.state.schema.nodes.codeBlock) { + const startPos = pos; + const endPos = pos + node.nodeSize; + const textContent = node.textContent; + + if (textContent.length === 0) { + editor.chain().focus().toggleCodeBlock().run(); + } else { + transformCodeBlockToParagraphs({ + editor, + from: startPos, + to: endPos, + textContent, + cursorPosInsideCodeblock, + }); + } + + replaced = true; + return false; + } + }); + + if (!replaced) { + console.log("No code block to replace."); + } + } catch (error) { + console.error("An error occurred while replacing code block content:", error); + } +} + +function transformCodeBlockToParagraphs({ + editor, + from, + to, + textContent, + cursorPosInsideCodeblock, +}: ReplaceCodeBlockParams): void { + const { schema } = editor.state; + const { paragraph } = schema.nodes; + const docSize = editor.state.doc.content.size; + + if (from < 0 || to > docSize || from > to) { + console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize); + return; + } + + // Split the textContent by new lines to handle each line as a separate paragraph for Windows (\r\n) and Unix (\n) + const lines = textContent.split(/\r?\n/); + const tr = editor.state.tr; + let insertPos = from; + + // Remove the code block first + tr.delete(from, to); + + // For each line, create a paragraph node and insert it + lines.forEach((line) => { + // if the line is empty, create a paragraph node with no content + const paragraphNode = line.length === 0 ? paragraph.create({}) : paragraph.create({}, schema.text(line)); + tr.insert(insertPos, paragraphNode); + insertPos += paragraphNode.nodeSize; + }); + + // Now persist the focus to the converted paragraph + const parentNodeOffset = findParentNode((node) => node.type === schema.nodes.codeBlock)(editor.state.selection)?.pos; + + if (parentNodeOffset === undefined) throw new Error("Invalid code block offset"); + + const lineNumber = getLineNumber(textContent, cursorPosInsideCodeblock, parentNodeOffset); + const cursorPosOutsideCodeblock = cursorPosInsideCodeblock + (lineNumber - 1); + + editor.view.dispatch(tr); + editor.chain().focus(cursorPosOutsideCodeblock).run(); +} + +/** + * Calculates the line number where the cursor is located inside the code block. + * Assumes the indexing of the content inside the code block is like ProseMirror's indexing. + * + * @param {string} textContent - The content of the code block. + * @param {number} cursorPosition - The absolute cursor position in the document. + * @param {number} codeBlockNodePos - The starting position of the code block node in the document. + * @returns {number} The 1-based line number where the cursor is located. + */ +function getLineNumber(textContent: string, cursorPosition: number, codeBlockNodePos: number): number { + // Split the text content into lines, handling both Unix and Windows newlines + const lines = textContent.split(/\r?\n/); + const cursorPosInsideCodeblockRelative = cursorPosition - codeBlockNodePos; + + let startPosition = 0; + let lineNumber = 0; + + for (let i = 0; i < lines.length; i++) { + // Calculate the end position of the current line + const endPosition = startPosition + lines[i].length + 1; // +1 for the newline character + + // Check if the cursor position is within the current line + if (cursorPosInsideCodeblockRelative >= startPosition && cursorPosInsideCodeblockRelative <= endPosition) { + lineNumber = i + 1; // Line numbers are 1-based + break; + } + + // Update the start position for the next line + startPosition = endPosition; + } + + return lineNumber; +} diff --git a/packages/editor/core/src/ui/extensions/core-without-props.tsx b/packages/editor/src/core/extensions/core-without-props.tsx similarity index 53% rename from packages/editor/core/src/ui/extensions/core-without-props.tsx rename to packages/editor/src/core/extensions/core-without-props.tsx index 3bb00010b..101511ce0 100644 --- a/packages/editor/core/src/ui/extensions/core-without-props.tsx +++ b/packages/editor/src/core/extensions/core-without-props.tsx @@ -2,29 +2,27 @@ import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; -import Placeholder from "@tiptap/extension-placeholder"; -import { Markdown } from "tiptap-markdown"; - -import { Table } from "src/ui/extensions/table/table"; -import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; -import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; -import { TableRow } from "src/ui/extensions/table/table-row/table-row"; - -import { isValidHttpUrl } from "src/lib/utils"; - -import { CustomCodeBlockExtension } from "src/ui/extensions/code"; -import { CustomKeymap } from "src/ui/extensions/keymap"; -import { CustomQuoteExtension } from "src/ui/extensions/quote"; - -import { CustomLinkExtension } from "src/ui/extensions/custom-link"; -import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; -import { CustomTypographyExtension } from "src/ui/extensions/typography"; -import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; -import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin"; -import { MentionsWithoutProps } from "src/ui/mentions/mention-without-props"; -import { ImageExtensionWithoutProps } from "src/ui/extensions/image/image-extension-without-props"; - import StarterKit from "@tiptap/starter-kit"; +import { Markdown } from "tiptap-markdown"; +// extensions +import { + CustomCodeBlockExtension, + CustomCodeInlineExtension, + CustomCodeMarkPlugin, + CustomHorizontalRule, + CustomKeymap, + CustomLinkExtension, + CustomMentionWithoutProps, + CustomQuoteExtension, + CustomTypographyExtension, + ImageExtensionWithoutProps, + Table, + TableCell, + TableHeader, + TableRow, +} from "@/extensions"; +// helpers +import { isValidHttpUrl } from "@/helpers/common"; export const CoreEditorExtensionsWithoutProps = () => [ StarterKit.configure({ @@ -47,10 +45,7 @@ export const CoreEditorExtensionsWithoutProps = () => [ codeBlock: false, horizontalRule: false, blockquote: false, - dropcursor: { - color: "rgba(var(--color-text-100))", - width: 1, - }, + dropcursor: false, }), CustomQuoteExtension, CustomHorizontalRule.configure({ @@ -59,7 +54,6 @@ export const CoreEditorExtensionsWithoutProps = () => [ }, }), CustomKeymap, - // ListKeymap, CustomLinkExtension.configure({ openOnClick: true, autolink: true, @@ -105,17 +99,5 @@ export const CoreEditorExtensionsWithoutProps = () => [ TableHeader, TableCell, TableRow, - MentionsWithoutProps(), - Placeholder.configure({ - placeholder: ({ editor, node }) => { - if (node.type.name === "heading") return `Heading ${node.attrs.level}`; - - const shouldHidePlaceholder = - editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); - if (shouldHidePlaceholder) return ""; - - return "Press '/' for commands..."; - }, - includeChildren: true, - }), + CustomMentionWithoutProps(), ]; diff --git a/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts b/packages/editor/src/core/extensions/custom-code-inline.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts rename to packages/editor/src/core/extensions/custom-code-inline.ts diff --git a/packages/editor/core/src/ui/extensions/custom-link/index.ts b/packages/editor/src/core/extensions/custom-link/extension.tsx similarity index 97% rename from packages/editor/core/src/ui/extensions/custom-link/index.ts rename to packages/editor/src/core/extensions/custom-link/extension.tsx index 88e7abfe5..e74916a8f 100644 --- a/packages/editor/core/src/ui/extensions/custom-link/index.ts +++ b/packages/editor/src/core/extensions/custom-link/extension.tsx @@ -10,9 +10,6 @@ export interface LinkProtocolOptions { optionalSlashes?: boolean; } -export const pasteRegex = - /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi; - export interface LinkOptions { /** * If enabled, it adds links as you type. diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts b/packages/editor/src/core/extensions/custom-link/helpers/autolink.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/custom-link/helpers/autolink.ts rename to packages/editor/src/core/extensions/custom-link/helpers/autolink.ts diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts rename to packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts b/packages/editor/src/core/extensions/custom-link/helpers/pasteHandler.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts rename to packages/editor/src/core/extensions/custom-link/helpers/pasteHandler.ts diff --git a/packages/editor/src/core/extensions/custom-link/index.ts b/packages/editor/src/core/extensions/custom-link/index.ts new file mode 100644 index 000000000..2ce32da8b --- /dev/null +++ b/packages/editor/src/core/extensions/custom-link/index.ts @@ -0,0 +1 @@ +export * from "./extension"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts b/packages/editor/src/core/extensions/custom-list-keymap/index.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/custom-list-keymap/index.ts rename to packages/editor/src/core/extensions/custom-list-keymap/index.ts diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts b/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts similarity index 94% rename from packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts rename to packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts index 330ebbc12..7d4cad17e 100644 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts +++ b/packages/editor/src/core/extensions/custom-list-keymap/list-helpers.ts @@ -1,6 +1,6 @@ -import { EditorState } from "@tiptap/pm/state"; import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core"; import { Node, NodeType } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => { const { $from } = state.selection; @@ -72,7 +72,7 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => { // Traverse up the document structure from the adjusted position for (let d = resolvedPos.depth; d > 0; d--) { const node = resolvedPos.node(d); - if (node.type.name === "bulletList" || node.type.name === "orderedList") { + if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") { // Increment depth for each list ancestor found depth++; } @@ -146,6 +146,8 @@ export const handleBackspace = (editor: Editor, name: string, parentListTypes: s if (!isAtStartOfNode(editor.state)) { return false; } + + // is the paragraph node inside of the current list item (maybe with a hard break) const isParaSibling = isCurrentParagraphASibling(editor.state); const isCurrentListItemSublist = prevListIsHigher(name, editor.state); const listItemPos = findListItemPos(name, editor.state); @@ -306,7 +308,10 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => { const currentParagraphNode = $from.parent; // Get the current node where the selection is. // Ensure we're in a paragraph and the parent is a list item. - if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") { + if ( + currentParagraphNode.type.name === "paragraph" && + (listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem") + ) { let paragraphNodesCount = 0; listItemNode.forEach((child) => { if (child.type.name === "paragraph") { @@ -327,16 +332,19 @@ export function isCursorInSubList(editor: Editor) { // Check if the current node is a list item const listItem = editor.schema.nodes.listItem; + const taskItem = editor.schema.nodes.taskItem; // Traverse up the document tree from the current position for (let depth = $from.depth; depth > 0; depth--) { const node = $from.node(depth); - if (node.type === listItem) { + if (node.type === listItem || node.type === taskItem) { // If the parent of the list item is also a list, it's a sub-list const parent = $from.node(depth - 1); if ( parent && - (parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList) + (parent.type === editor.schema.nodes.bulletList || + parent.type === editor.schema.nodes.orderedList || + parent.type === editor.schema.nodes.taskList) ) { return true; } diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts similarity index 93% rename from packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts rename to packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts index db1264f57..4730cce05 100644 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts +++ b/packages/editor/src/core/extensions/custom-list-keymap/list-keymap.ts @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; - -import { handleBackspace, handleDelete } from "src/ui/extensions/custom-list-keymap/list-helpers"; +// extensions +import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers"; export type ListKeymapOptions = { listTypes: Array<{ @@ -69,7 +69,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => return handled; } catch (e) { - console.log("error in handling Backspac:", e); + console.log("Error in handling Delete:", e); return false; } }, @@ -104,7 +104,7 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) => return handled; } catch (e) { - console.log("error in handling Backspac:", e); + console.log("Error in handling Backspace:", e); return false; } }, diff --git a/packages/editor/src/core/extensions/document-without-props.tsx b/packages/editor/src/core/extensions/document-without-props.tsx new file mode 100644 index 000000000..2202510ec --- /dev/null +++ b/packages/editor/src/core/extensions/document-without-props.tsx @@ -0,0 +1,3 @@ +import { IssueWidgetWithoutProps } from "@/extensions/issue-embed"; + +export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()]; diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/src/core/extensions/drag-drop.tsx similarity index 93% rename from packages/editor/extensions/src/extensions/drag-drop.tsx rename to packages/editor/src/core/extensions/drag-drop.tsx index 32867a5f1..06a74427f 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/src/core/extensions/drag-drop.tsx @@ -1,7 +1,6 @@ import { Extension } from "@tiptap/core"; - -import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Fragment, Slice, Node } from "@tiptap/pm/model"; +import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; // @ts-expect-error __serializeForClipboard's is not exported import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; @@ -70,19 +69,25 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) { "p:not(:first-child)", ".code-block", "blockquote", - "h1, h2, h3", - ".table-wrapper", + "img", + "h1, h2, h3, h4, h5, h6", "[data-type=horizontalRule]", + ".table-wrapper", ].join(", "); for (const elem of elements) { + if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) { + return elem; + } + // if the element is a

tag that is the first child of a td or th if ( (elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) && elem?.textContent?.trim() !== "" ) { - return elem; // Return only if p tag is not empty + return elem; // Return only if p tag is not empty in td or th } + // apply general selector if (elem.matches(generalSelectors)) { return elem; @@ -115,7 +120,7 @@ function calcNodePos(pos: number, view: EditorView, node: Element) { const $pos = view.state.doc.resolve(safePos); if ($pos.depth > 1) { - if (node.matches("ul:not([data-type=taskList]) li, ol li")) { + if (node.matches("ul li, ol li")) { // only for nested lists const newPos = $pos.before($pos.depth); return Math.max(0, Math.min(newPos, maxPos)); @@ -317,12 +322,24 @@ function DragHandle(options: DragHandleOptions) { rect.top += (lineHeight - 20) / 2; rect.top += paddingTop; - // Li markers - if (node.matches("ul:not([data-type=taskList]) li, ol li")) { - rect.left -= 18; + if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) { + if (node.matches("ul:not([data-type=taskList]) li, ol li")) { + rect.left -= 5; + } + } else { + // Li markers + if (node.matches("ul:not([data-type=taskList]) li, ol li")) { + rect.left -= 18; + } } + if (node.matches(".table-wrapper")) { rect.top += 8; + rect.left -= 8; + } + + if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) { + rect.left += 8; } rect.width = options.dragHandleWidth; @@ -357,6 +374,7 @@ function DragHandle(options: DragHandleOptions) { if (view.state.selection instanceof NodeSelection) { droppedNode = view.state.selection.node; } + if (!droppedNode) return; const resolvedPos = view.state.doc.resolve(dropPos.pos); diff --git a/packages/editor/core/src/ui/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx similarity index 92% rename from packages/editor/core/src/ui/extensions/drop.tsx rename to packages/editor/src/core/extensions/drop.tsx index 4bf4e2625..d56f802d9 100644 --- a/packages/editor/core/src/ui/extensions/drop.tsx +++ b/packages/editor/src/core/extensions/drop.tsx @@ -1,7 +1,9 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; -import { UploadImage } from "src/types/upload-image"; -import { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; +// plugins +import { startImageUpload } from "@/plugins/image"; +// types +import { UploadImage } from "@/types"; export const DropHandlerExtension = (uploadFile: UploadImage) => Extension.create({ diff --git a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/src/core/extensions/enter-key-extension.tsx similarity index 81% rename from packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx rename to packages/editor/src/core/extensions/enter-key-extension.tsx index 7d93bf36f..a01b58e59 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx +++ b/packages/editor/src/core/extensions/enter-key-extension.tsx @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; -export const EnterKeyExtension = (onEnterKeyPress?: () => void) => +export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => void) => Extension.create({ name: "enterKey", @@ -8,9 +8,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => return { Enter: () => { if (!this.editor.storage.mentionsOpen) { - if (onEnterKeyPress) { - onEnterKeyPress(); - } + onEnterKeyPress?.(this.editor.getHTML()); return true; } return false; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/src/core/extensions/extensions.tsx similarity index 71% rename from packages/editor/core/src/ui/extensions/index.tsx rename to packages/editor/src/core/extensions/extensions.tsx index 2507aca36..d1dfb4370 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -1,36 +1,33 @@ +import Placeholder from "@tiptap/extension-placeholder"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; import TiptapUnderline from "@tiptap/extension-underline"; -import Placeholder from "@tiptap/extension-placeholder"; import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; - -import { Table } from "src/ui/extensions/table/table"; -import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; -import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; -import { TableRow } from "src/ui/extensions/table/table-row/table-row"; - -import { ImageExtension } from "src/ui/extensions/image"; - -import { isValidHttpUrl } from "src/lib/utils"; -import { Mentions } from "src/ui/mentions"; - -import { CustomCodeBlockExtension } from "src/ui/extensions/code"; -import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; -import { CustomKeymap } from "src/ui/extensions/keymap"; -import { CustomQuoteExtension } from "src/ui/extensions/quote"; - -import { DeleteImage } from "src/types/delete-image"; -import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; -import { RestoreImage } from "src/types/restore-image"; -import { CustomLinkExtension } from "src/ui/extensions/custom-link"; -import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; -import { CustomTypographyExtension } from "src/ui/extensions/typography"; -import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; -import { CustomCodeMarkPlugin } from "src/ui/extensions/custom-code-inline/inline-code-plugin"; -import { UploadImage } from "src/types/upload-image"; -import { DropHandlerExtension } from "src/ui/extensions/drop"; +// extensions +import { + CustomCodeBlockExtension, + CustomCodeInlineExtension, + CustomCodeMarkPlugin, + CustomHorizontalRule, + CustomKeymap, + CustomLinkExtension, + CustomMention, + CustomQuoteExtension, + CustomTypographyExtension, + DropHandlerExtension, + ImageExtension, + ListKeymap, + Table, + TableCell, + TableHeader, + TableRow, +} from "@/extensions"; +// helpers +import { isValidHttpUrl } from "@/helpers/common"; +// types +import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; type TArguments = { mentionConfig: { @@ -73,6 +70,7 @@ export const CoreEditorExtensions = ({ codeBlock: false, horizontalRule: false, blockquote: false, + history: false, dropcursor: { color: "rgba(var(--color-text-100))", width: 1, @@ -127,12 +125,13 @@ export const CoreEditorExtensions = ({ Markdown.configure({ html: true, transformPastedText: true, + breaks: true, }), Table, TableHeader, TableCell, TableRow, - Mentions({ + CustomMention({ mentionSuggestions: mentionConfig.mentionSuggestions, mentionHighlights: mentionConfig.mentionHighlights, readonly: false, diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts b/packages/editor/src/core/extensions/horizontal-rule.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts rename to packages/editor/src/core/extensions/horizontal-rule.ts diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/src/core/extensions/image/extension.tsx similarity index 66% rename from packages/editor/core/src/ui/extensions/image/index.tsx rename to packages/editor/src/core/extensions/image/extension.tsx index 7ea12fb11..98961b7f0 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,20 +1,23 @@ -import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image"; import ImageExt from "@tiptap/extension-image"; -import { TrackImageDeletionPlugin } from "src/ui/plugins/image/delete-image"; -import { DeleteImage } from "src/types/delete-image"; -import { RestoreImage } from "src/types/restore-image"; -import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; -import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; -import { TrackImageRestorationPlugin } from "src/ui/plugins/image/restore-image"; -import { IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants"; -import { ImageExtensionStorage } from "src/ui/plugins/image/types/image-node"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; +// plugins +import { + IMAGE_NODE_TYPE, + ImageExtensionStorage, + TrackImageDeletionPlugin, + TrackImageRestorationPlugin, + UploadImagesPlugin, +} from "@/plugins/image"; +// types +import { DeleteImage, RestoreImage } from "@/types"; export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => ImageExt.extend({ addKeyboardShortcuts() { return { - ArrowDown: insertLineBelowImageAction, - ArrowUp: insertLineAboveImageAction, + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"), }; }, addProseMirrorPlugins() { 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 new file mode 100644 index 000000000..0d505000c --- /dev/null +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -0,0 +1,16 @@ +import ImageExt from "@tiptap/extension-image"; + +export const ImageExtensionWithoutProps = () => + ImageExt.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }); diff --git a/packages/editor/core/src/ui/extensions/image/image-resize.tsx b/packages/editor/src/core/extensions/image/image-resize.tsx similarity index 100% rename from packages/editor/core/src/ui/extensions/image/image-resize.tsx rename to packages/editor/src/core/extensions/image/image-resize.tsx index 7f61cc9cb..6be8214d7 100644 --- a/packages/editor/core/src/ui/extensions/image/image-resize.tsx +++ b/packages/editor/src/core/extensions/image/image-resize.tsx @@ -1,5 +1,5 @@ -import { Editor } from "@tiptap/react"; import { useState } from "react"; +import { Editor } from "@tiptap/react"; import Moveable from "react-moveable"; export const ImageResizer = ({ editor }: { editor: Editor }) => { diff --git a/packages/editor/src/core/extensions/image/index.ts b/packages/editor/src/core/extensions/image/index.ts new file mode 100644 index 000000000..3e2f7518d --- /dev/null +++ b/packages/editor/src/core/extensions/image/index.ts @@ -0,0 +1,4 @@ +export * from "./extension"; +export * from "./image-extension-without-props"; +export * from "./image-resize"; +export * from "./read-only-image"; diff --git a/packages/editor/core/src/ui/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx similarity index 100% rename from packages/editor/core/src/ui/extensions/image/read-only-image.tsx rename to packages/editor/src/core/extensions/image/read-only-image.tsx diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts new file mode 100644 index 000000000..220a11757 --- /dev/null +++ b/packages/editor/src/core/extensions/index.ts @@ -0,0 +1,21 @@ +export * from "./code"; +export * from "./code-inline"; +export * from "./custom-link"; +export * from "./custom-list-keymap"; +export * from "./image"; +export * from "./issue-embed"; +export * from "./mentions"; +export * from "./table"; +export * from "./typography"; +export * from "./core-without-props"; +export * from "./document-without-props"; +export * from "./custom-code-inline"; +export * from "./drag-drop"; +export * from "./drop"; +export * from "./enter-key-extension"; +export * from "./extensions"; +export * from "./horizontal-rule"; +export * from "./keymap"; +export * from "./quote"; +export * from "./read-only-extensions"; +export * from "./slash-commands"; diff --git a/packages/editor/src/core/extensions/issue-embed/index.ts b/packages/editor/src/core/extensions/issue-embed/index.ts new file mode 100644 index 000000000..f47619a03 --- /dev/null +++ b/packages/editor/src/core/extensions/issue-embed/index.ts @@ -0,0 +1,2 @@ +export * from "./widget-node"; +export * from "./issue-embed-without-props"; diff --git a/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts b/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts new file mode 100644 index 000000000..bef366cba --- /dev/null +++ b/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts @@ -0,0 +1,41 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export const IssueWidgetWithoutProps = () => + Node.create({ + name: "issue-embed-component", + group: "block", + atom: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + entity_identifier: { + default: undefined, + }, + project_identifier: { + default: undefined, + }, + workspace_identifier: { + default: undefined, + }, + id: { + default: undefined, + }, + entity_name: { + default: undefined, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "issue-embed-component", + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; + }, + }); diff --git a/packages/editor/src/core/extensions/issue-embed/widget-node.tsx b/packages/editor/src/core/extensions/issue-embed/widget-node.tsx new file mode 100644 index 000000000..a216ab6d9 --- /dev/null +++ b/packages/editor/src/core/extensions/issue-embed/widget-node.tsx @@ -0,0 +1,66 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react"; + +type Props = { + widgetCallback: ({ + issueId, + projectId, + workspaceSlug, + }: { + issueId: string; + projectId: string | undefined; + workspaceSlug: string | undefined; + }) => React.ReactNode; +}; + +export const IssueWidget = (props: Props) => + Node.create({ + name: "issue-embed-component", + group: "block", + atom: true, + selectable: true, + draggable: true, + + addAttributes() { + return { + entity_identifier: { + default: undefined, + }, + project_identifier: { + default: undefined, + }, + workspace_identifier: { + default: undefined, + }, + id: { + default: undefined, + }, + entity_name: { + default: undefined, + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer((issueProps: any) => ( + + {props.widgetCallback({ + issueId: issueProps.node.attrs.entity_identifier, + projectId: issueProps.node.attrs.project_identifier, + workspaceSlug: issueProps.node.attrs.workspace_identifier, + })} + + )); + }, + + parseHTML() { + return [ + { + tag: "issue-embed-component", + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["issue-embed-component", mergeAttributes(HTMLAttributes)]; + }, + }); diff --git a/packages/editor/core/src/ui/extensions/keymap.tsx b/packages/editor/src/core/extensions/keymap.tsx similarity index 79% rename from packages/editor/core/src/ui/extensions/keymap.tsx rename to packages/editor/src/core/extensions/keymap.tsx index 2e0bdd1fe..81d60e34f 100644 --- a/packages/editor/core/src/ui/extensions/keymap.tsx +++ b/packages/editor/src/core/extensions/keymap.tsx @@ -1,7 +1,7 @@ import { Extension } from "@tiptap/core"; +import { NodeType } from "@tiptap/pm/model"; import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { canJoin } from "@tiptap/pm/transform"; -import { NodeType } from "@tiptap/pm/model"; declare module "@tiptap/core" { // eslint-disable-next-line no-unused-vars @@ -15,9 +15,7 @@ declare module "@tiptap/core" { } } -function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { - if (!tr.isGeneric) return false; - +function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) { // Find all ranges where we might want to join. const ranges: Array = []; for (let i = 0; i < tr.mapping.maps.length; i++) { @@ -28,7 +26,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { // Figure out which joinable points exist inside those ranges, // by checking all node boundaries in their parent nodes. - const joinable = []; + const joinable: number[] = []; for (let i = 0; i < ranges.length; i += 2) { const from = ranges[i], to = ranges[i + 1]; @@ -40,7 +38,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) { if (!after) break; if (index && joinable.indexOf(pos) == -1) { const before = parent.child(index - 1); - if (before.type == after.type && before.type === nodeType) joinable.push(pos); + if (before.type == after.type && nodeTypes.includes(before.type)) joinable.push(pos); } pos += after.nodeSize; } @@ -88,25 +86,15 @@ export const CustomKeymap = Extension.create({ // Create a new transaction. const newTr = newState.tr; - let joined = false; - for (const transaction of transactions) { - const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]); - joined = anotherJoin || joined; - } - if (joined) { - return newTr; - } - }, - }), - new Plugin({ - key: new PluginKey("unordered-list-merging"), - appendTransaction(transactions, oldState, newState) { - // Create a new transaction. - const newTr = newState.tr; + const joinableNodes = [ + newState.schema.nodes["orderedList"], + newState.schema.nodes["taskList"], + newState.schema.nodes["bulletList"], + ]; let joined = false; for (const transaction of transactions) { - const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]); + const anotherJoin = autoJoin(transaction, newTr, joinableNodes); joined = anotherJoin || joined; } if (joined) { diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/src/core/extensions/mentions/extension.tsx similarity index 63% rename from packages/editor/core/src/ui/mentions/index.tsx rename to packages/editor/src/core/extensions/mentions/extension.tsx index 838622cbb..e5a447c7f 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/src/core/extensions/mentions/extension.tsx @@ -1,12 +1,18 @@ -import { CustomMention } from "src/ui/mentions/custom"; -import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; -import { ReactRenderer } from "@tiptap/react"; -import { Editor } from "@tiptap/core"; +import { Editor, mergeAttributes } from "@tiptap/core"; +import Mention, { MentionOptions } from "@tiptap/extension-mention"; +import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react"; import tippy from "tippy.js"; +// extensions +import { MentionList, MentionNodeView } from "@/extensions"; +// types +import { IMentionHighlight, IMentionSuggestion } from "@/types"; -import { MentionList } from "src/ui/mentions/mention-list"; +export interface CustomMentionOptions extends MentionOptions { + mentionHighlights: () => Promise; + readonly?: boolean; +} -export const Mentions = ({ +export const CustomMention = ({ mentionHighlights, mentionSuggestions, readonly, @@ -15,12 +21,56 @@ export const Mentions = ({ mentionHighlights?: () => Promise; readonly: boolean; }) => - CustomMention.configure({ + Mention.extend({ + addStorage(this) { + return { + mentionsOpen: false, + }; + }, + addAttributes() { + return { + id: { + default: null, + }, + label: { + default: null, + }, + target: { + default: null, + }, + self: { + default: false, + }, + redirect_uri: { + default: "/", + }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(MentionNodeView); + }, + parseHTML() { + return [ + { + tag: "mention-component", + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, + }).configure({ HTMLAttributes: { class: "mention", }, readonly: readonly, - mentionHighlights: mentionHighlights, + mentionHighlights, suggestion: { // @ts-expect-error - Tiptap types are incorrect render: () => { diff --git a/packages/editor/src/core/extensions/mentions/index.ts b/packages/editor/src/core/extensions/mentions/index.ts new file mode 100644 index 000000000..c7f2317a7 --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/index.ts @@ -0,0 +1,4 @@ +export * from "./extension"; +export * from "./mention-node-view"; +export * from "./mentions-list"; +export * from "./mentions-without-props"; diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx similarity index 75% rename from packages/editor/core/src/ui/mentions/mention-node-view.tsx rename to packages/editor/src/core/extensions/mentions/mention-node-view.tsx index 0a1f1b5e0..59cd2b811 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx @@ -1,14 +1,17 @@ +// TODO: fix all warnings + /* eslint-disable react/display-name */ // @ts-nocheck -import { NodeViewWrapper } from "@tiptap/react"; -import { cn } from "src/lib/utils"; -import { useRouter } from "next/router"; -import { IMentionHighlight } from "src/types/mention-suggestion"; import { useEffect, useState } from "react"; +import { NodeViewWrapper } from "@tiptap/react"; +// helpers +import { cn } from "@/helpers/common"; +// types +import { IMentionHighlight } from "@/types"; // eslint-disable-next-line import/no-anonymous-default-export export const MentionNodeView = (props) => { - const router = useRouter(); + // TODO: move it to web app const [highlightsState, setHighlightsState] = useState(); useEffect(() => { @@ -20,25 +23,20 @@ export const MentionNodeView = (props) => { hightlights(); }, [props.extension.options]); - const handleClick = () => { - if (!props.extension.options.readonly) { - router.push(props.node.attrs.redirect_uri); - } - }; - return ( - @{props.node.attrs.label} - + ); }; diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/src/core/extensions/mentions/mentions-list.tsx similarity index 97% rename from packages/editor/core/src/ui/mentions/mention-list.tsx rename to packages/editor/src/core/extensions/mentions/mentions-list.tsx index b9ac11d13..6ca6ba8eb 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/src/core/extensions/mentions/mentions-list.tsx @@ -1,9 +1,14 @@ -import { Editor } from "@tiptap/react"; +"use client"; + import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; -import { cn } from "src/lib/utils"; -import { IMentionSuggestion } from "src/types/mention-suggestion"; +import { Editor } from "@tiptap/react"; import { v4 as uuidv4 } from "uuid"; +// ui import { Avatar } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common"; +// types +import { IMentionSuggestion } from "@/types"; interface MentionListProps { command: (item: { diff --git a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx b/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx new file mode 100644 index 000000000..b03736ada --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx @@ -0,0 +1,50 @@ +import { mergeAttributes } from "@tiptap/core"; +import Mention from "@tiptap/extension-mention"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomMentionOptions, MentionNodeView } from "@/extensions"; + +export const CustomMentionWithoutProps = () => + Mention.extend({ + addAttributes() { + return { + id: { + default: null, + }, + label: { + default: null, + }, + target: { + default: null, + }, + self: { + default: false, + }, + redirect_uri: { + default: "/", + }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(MentionNodeView); + }, + parseHTML() { + return [ + { + tag: "mention-component", + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, + HTMLAttributes: { + class: "mention", + }, + }); diff --git a/packages/editor/core/src/ui/extensions/quote/index.tsx b/packages/editor/src/core/extensions/quote.tsx similarity index 100% rename from packages/editor/core/src/ui/extensions/quote/index.tsx rename to packages/editor/src/core/extensions/quote.tsx diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx similarity index 68% rename from packages/editor/core/src/ui/read-only/extensions.tsx rename to packages/editor/src/core/extensions/read-only-extensions.tsx index 3f0cd98c3..e646ed56e 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -1,25 +1,28 @@ -import StarterKit from "@tiptap/starter-kit"; -import TiptapUnderline from "@tiptap/extension-underline"; -import TextStyle from "@tiptap/extension-text-style"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; +import TextStyle from "@tiptap/extension-text-style"; +import TiptapUnderline from "@tiptap/extension-underline"; +import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; - -import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; -import { Table } from "src/ui/extensions/table/table"; -import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; -import { TableRow } from "src/ui/extensions/table/table-row/table-row"; - -import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image"; -import { isValidHttpUrl } from "src/lib/utils"; -import { Mentions } from "src/ui/mentions"; -import { IMentionHighlight } from "src/types/mention-suggestion"; -import { CustomLinkExtension } from "src/ui/extensions/custom-link"; -import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; -import { CustomQuoteExtension } from "src/ui/extensions/quote"; -import { CustomTypographyExtension } from "src/ui/extensions/typography"; -import { CustomCodeBlockExtension } from "src/ui/extensions/code"; -import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; +// extensions +import { + CustomQuoteExtension, + CustomHorizontalRule, + CustomLinkExtension, + CustomTypographyExtension, + ReadOnlyImageExtension, + CustomCodeBlockExtension, + CustomCodeInlineExtension, + TableHeader, + TableCell, + TableRow, + Table, + CustomMention, +} from "@/extensions"; +// helpers +import { isValidHttpUrl } from "@/helpers/common"; +// types +import { IMentionHighlight } from "@/types"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionHighlights?: () => Promise; @@ -97,7 +100,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { TableHeader, TableCell, TableRow, - Mentions({ + CustomMention({ mentionHighlights: mentionConfig.mentionHighlights, readonly: true, }), diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx similarity index 96% rename from packages/editor/extensions/src/extensions/slash-commands.tsx rename to packages/editor/src/core/extensions/slash-commands.tsx index c1b1ef9c0..38fc0231b 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/src/core/extensions/slash-commands.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react"; import { Editor, Range, Extension } from "@tiptap/core"; -import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; import { ReactRenderer } from "@tiptap/react"; +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; import tippy from "tippy.js"; import { CaseSensitive, @@ -17,11 +17,9 @@ import { Quote, Table, } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common"; import { - UploadImage, - ISlashCommandItem, - CommandProps, - cn, insertTableCommand, toggleBlockquote, toggleBulletList, @@ -31,7 +29,9 @@ import { toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree, -} from "@plane/editor-core"; +} from "@/helpers/editor-commands"; +// types +import { CommandProps, ISlashCommandItem, UploadImage } from "@/types"; interface CommandItemProps { key: string; @@ -69,7 +69,6 @@ const Command = Extension.create({ return true; }, - allowSpaces: true, }, }; }, @@ -184,9 +183,7 @@ const getSuggestionItems = description: "Capture a code snippet.", searchTerms: ["codeblock"], icon: , - command: ({ editor, range }: CommandProps) => - // @ts-expect-error I have to move this to the core - editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, { key: "image", @@ -205,7 +202,6 @@ const getSuggestionItems = searchTerms: ["line", "divider", "horizontal", "rule", "separate"], icon: , command: ({ editor, range }: CommandProps) => { - // @ts-expect-error I have to move this to the core editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, diff --git a/packages/editor/src/core/extensions/table/index.ts b/packages/editor/src/core/extensions/table/index.ts new file mode 100644 index 000000000..2277e09ae --- /dev/null +++ b/packages/editor/src/core/extensions/table/index.ts @@ -0,0 +1,4 @@ +export * from "./table"; +export * from "./table-cell"; +export * from "./table-header"; +export * from "./table-row"; diff --git a/packages/editor/core/src/ui/extensions/table/table-cell/index.ts b/packages/editor/src/core/extensions/table/table-cell/index.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table-cell/index.ts rename to packages/editor/src/core/extensions/table/table-cell/index.ts diff --git a/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts b/packages/editor/src/core/extensions/table/table-cell/table-cell.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts rename to packages/editor/src/core/extensions/table/table-cell/table-cell.ts diff --git a/packages/editor/core/src/ui/extensions/table/table-header/index.ts b/packages/editor/src/core/extensions/table/table-header/index.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table-header/index.ts rename to packages/editor/src/core/extensions/table/table-header/index.ts diff --git a/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts b/packages/editor/src/core/extensions/table/table-header/table-header.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table-header/table-header.ts rename to packages/editor/src/core/extensions/table/table-header/table-header.ts diff --git a/packages/editor/core/src/ui/extensions/table/table-row/index.ts b/packages/editor/src/core/extensions/table/table-row/index.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table-row/index.ts rename to packages/editor/src/core/extensions/table/table-row/index.ts diff --git a/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts b/packages/editor/src/core/extensions/table/table-row/table-row.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table-row/table-row.ts rename to packages/editor/src/core/extensions/table/table-row/table-row.ts diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/src/core/extensions/table/table/icons.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table/icons.ts rename to packages/editor/src/core/extensions/table/table/icons.ts diff --git a/packages/editor/core/src/ui/extensions/table/table/index.ts b/packages/editor/src/core/extensions/table/table/index.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table/index.ts rename to packages/editor/src/core/extensions/table/table/index.ts diff --git a/packages/editor/core/src/ui/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table/table-controls.ts rename to packages/editor/src/core/extensions/table/table/table-controls.ts index 34ad93fce..bd5f8f589 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table-controls.ts +++ b/packages/editor/src/core/extensions/table/table/table-controls.ts @@ -1,5 +1,5 @@ -import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { findParentNode } from "@tiptap/core"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { DecorationSet, Decoration } from "@tiptap/pm/view"; const key = new PluginKey("tableControls"); diff --git a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx similarity index 99% rename from packages/editor/core/src/ui/extensions/table/table/table-view.tsx rename to packages/editor/src/core/extensions/table/table/table-view.tsx index d4dfcf5c7..347e82171 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx +++ b/packages/editor/src/core/extensions/table/table/table-view.tsx @@ -6,7 +6,7 @@ import tippy, { Instance, Props } from "tippy.js"; import { Editor } from "@tiptap/core"; import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables"; -import { icons } from "src/ui/extensions/table/table/icons"; +import { icons } from "src/core/extensions/table/table/icons"; type ToolboxItem = { label: string; diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts similarity index 95% rename from packages/editor/core/src/ui/extensions/table/table/table.ts rename to packages/editor/src/core/extensions/table/table/table.ts index c1f65feec..9d788e84b 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -1,6 +1,5 @@ -import { TextSelection } from "@tiptap/pm/state"; - import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; import { addColumnAfter, addColumnBefore, @@ -21,12 +20,12 @@ import { toggleHeaderCell, } from "@tiptap/pm/tables"; -import { tableControls } from "src/ui/extensions/table/table/table-controls"; -import { TableView } from "src/ui/extensions/table/table/table-view"; -import { createTable } from "src/ui/extensions/table/table/utilities/create-table"; -import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected"; -import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; +import { tableControls } from "@/extensions/table/table/table-controls"; +import { TableView } from "@/extensions/table/table/table-view"; +import { createTable } from "@/extensions/table/table/utilities/create-table"; +import { deleteTableWhenAllCellsSelected } from "@/extensions/table/table/utilities/delete-table-when-all-cells-selected"; import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; +import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; export interface TableOptions { HTMLAttributes: Record; diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/create-cell.ts b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table/utilities/create-cell.ts rename to packages/editor/src/core/extensions/table/table/utilities/create-cell.ts diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/create-table.ts b/packages/editor/src/core/extensions/table/table/utilities/create-table.ts similarity index 84% rename from packages/editor/core/src/ui/extensions/table/table/utilities/create-table.ts rename to packages/editor/src/core/extensions/table/table/utilities/create-table.ts index 7299dd442..0c05cff77 100644 --- a/packages/editor/core/src/ui/extensions/table/table/utilities/create-table.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/create-table.ts @@ -1,7 +1,7 @@ import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"; - -import { createCell } from "src/ui/extensions/table/table/utilities/create-cell"; -import { getTableNodeTypes } from "src/ui/extensions/table/table/utilities/get-table-node-types"; +// extensions +import { createCell } from "@/extensions/table/table/utilities/create-cell"; +import { getTableNodeTypes } from "@/extensions/table/table/utilities/get-table-node-types"; export function createTable( schema: Schema, diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts similarity index 88% rename from packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts rename to packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts index c08228a00..53388fbf2 100644 --- a/packages/editor/core/src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/delete-table-when-all-cells-selected.ts @@ -1,6 +1,6 @@ import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"; - -import { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell-selection"; +// extensions +import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => { const { selection } = editor.state; diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/get-table-node-types.ts b/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table/utilities/get-table-node-types.ts rename to packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts similarity index 96% rename from packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts rename to packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts index 865bce8b7..ca5ed3d7e 100644 --- a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -1,5 +1,6 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; -import { findParentNodeOfType } from "src/lib/utils"; +// helpers +import { findParentNodeOfType } from "@/helpers/common"; export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => { // Check if the current selection or the closest node is a table diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts similarity index 96% rename from packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts rename to packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts index 6ce0fa4c4..7edca9f30 100644 --- a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -1,5 +1,6 @@ import { KeyboardShortcutCommand } from "@tiptap/core"; -import { findParentNodeOfType } from "src/lib/utils"; +// helpers +import { findParentNodeOfType } from "@/helpers/common"; export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => { // Check if the current selection or the closest node is a table diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/is-cell-selection.ts b/packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/table/table/utilities/is-cell-selection.ts rename to packages/editor/src/core/extensions/table/table/utilities/is-cell-selection.ts diff --git a/packages/editor/core/src/ui/extensions/typography/index.ts b/packages/editor/src/core/extensions/typography/index.ts similarity index 98% rename from packages/editor/core/src/ui/extensions/typography/index.ts rename to packages/editor/src/core/extensions/typography/index.ts index 78af3c46e..e9d48b415 100644 --- a/packages/editor/core/src/ui/extensions/typography/index.ts +++ b/packages/editor/src/core/extensions/typography/index.ts @@ -20,7 +20,7 @@ import { oneQuarter, threeQuarters, impliesArrowRight, -} from "src/ui/extensions/typography/inputRules"; +} from "./inputRules"; export const CustomTypographyExtension = Extension.create({ name: "typography", diff --git a/packages/editor/core/src/ui/extensions/typography/inputRules.ts b/packages/editor/src/core/extensions/typography/inputRules.ts similarity index 100% rename from packages/editor/core/src/ui/extensions/typography/inputRules.ts rename to packages/editor/src/core/extensions/typography/inputRules.ts diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/src/core/helpers/common.ts similarity index 71% rename from packages/editor/core/src/lib/utils.ts rename to packages/editor/src/core/helpers/common.ts index 137c70c2e..98930d94f 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -1,8 +1,9 @@ import { Extensions, generateJSON, getSchema } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; -import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props"; +import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props"; import { twMerge } from "tailwind-merge"; + interface EditorClassNames { noBorder?: boolean; borderOnFocus?: boolean; @@ -60,20 +61,3 @@ export const isValidHttpUrl = (string: string): boolean => { return url.protocol === "http:" || url.protocol === "https:"; }; - -/** - * @description return an object with contentJSON and editorSchema - * @description contentJSON- ProseMirror JSON from HTML content - * @description editorSchema- editor schema from extensions - * @param {string} html - * @returns {object} {contentJSON, editorSchema} - */ -export const generateJSONfromHTML = (html: string) => { - const extensions = CoreEditorExtensionsWithoutProps(); - const contentJSON = generateJSON(html ?? "

", extensions as Extensions); - const editorSchema = getSchema(extensions as Extensions); - return { - contentJSON, - editorSchema, - }; -}; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts similarity index 71% rename from packages/editor/core/src/lib/editor-commands.ts rename to packages/editor/src/core/helpers/editor-commands.ts index 911347e7f..db3b4d66d 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -1,8 +1,13 @@ import { Editor, Range } from "@tiptap/core"; -import { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; -import { findTableAncestor } from "src/lib/utils"; import { Selection } from "@tiptap/pm/state"; -import { UploadImage } from "src/types/upload-image"; +// extensions +import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text"; +// helpers +import { findTableAncestor } from "@/helpers/common"; +// plugins +import { startImageUpload } from "@/plugins/image"; +// types +import { UploadImage } from "@/types"; export const setText = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).clearNodes().run(); @@ -54,69 +59,11 @@ export const toggleUnderline = (editor: Editor, range?: Range) => { else editor.chain().focus().toggleUnderline().run(); }; -const replaceCodeBlockWithContent = (editor: Editor) => { - try { - const { schema } = editor.state; - const { paragraph } = schema.nodes; - let replaced = false; - - const replaceCodeBlock = (from: number, to: number, textContent: string) => { - const docSize = editor.state.doc.content.size; - - if (from < 0 || to > docSize || from > to) { - console.error("Invalid range for replacement: ", from, to, "in a document of size", docSize); - return; - } - - // split the textContent by new lines to handle each line as a separate paragraph - const lines = textContent.split(/\r?\n/); - - const tr = editor.state.tr; - - // Calculate the position for inserting the first paragraph - let insertPos = from; - - // Remove the code block first - tr.delete(from, to); - - // For each line, create a paragraph node and insert it - lines.forEach((line) => { - const paragraphNode = paragraph.create({}, schema.text(line)); - tr.insert(insertPos, paragraphNode); - // Update insertPos for the next insertion - insertPos += paragraphNode.nodeSize; - }); - - // Dispatch the transaction - editor.view.dispatch(tr); - replaced = true; - }; - - editor.state.doc.nodesBetween(editor.state.selection.from, editor.state.selection.to, (node, pos) => { - if (node.type === schema.nodes.codeBlock) { - const startPos = pos; - const endPos = pos + node.nodeSize; - const textContent = node.textContent; - if (textContent.length === 0) { - editor.chain().focus().toggleCodeBlock().run(); - } - replaceCodeBlock(startPos, endPos, textContent); - return false; - } - }); - - if (!replaced) { - console.log("No code block to replace."); - } - } catch (error) { - console.error("An error occurred while replacing code block content:", error); - } -}; - export const toggleCodeBlock = (editor: Editor, range?: Range) => { try { + // if it's a code block, replace it with the code with paragraphs if (editor.isActive("codeBlock")) { - replaceCodeBlockWithContent(editor); + replaceCodeWithText(editor); return; } @@ -124,11 +71,16 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => { const text = editor.state.doc.textBetween(from, to, "\n"); const isMultiline = text.includes("\n"); + // if the selection is not a range i.e. empty, then simply convert it into a code block if (editor.state.selection.empty) { editor.chain().focus().toggleCodeBlock().run(); } else if (isMultiline) { + // if the selection is multiline, then also replace the text content with + // a code block editor.chain().focus().deleteRange({ from, to }).insertContentAt(from, `\`\`\`\n${text}\n\`\`\``).run(); } else { + // if the selection is single line, then simply convert it into inline + // code editor.chain().focus().toggleCode().run(); } } catch (error) { diff --git a/packages/editor/core/src/helpers/insert-content-at-cursor-position.ts b/packages/editor/src/core/helpers/insert-content-at-cursor-position.ts similarity index 100% rename from packages/editor/core/src/helpers/insert-content-at-cursor-position.ts rename to packages/editor/src/core/helpers/insert-content-at-cursor-position.ts index f17858d3b..eefe69e3e 100644 --- a/packages/editor/core/src/helpers/insert-content-at-cursor-position.ts +++ b/packages/editor/src/core/helpers/insert-content-at-cursor-position.ts @@ -1,6 +1,6 @@ +import { MutableRefObject } from "react"; import { Selection } from "@tiptap/pm/state"; import { Editor } from "@tiptap/react"; -import { MutableRefObject } from "react"; export const insertContentAtSavedSelection = ( editorRef: MutableRefObject, diff --git a/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts new file mode 100644 index 000000000..ffad88d4e --- /dev/null +++ b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts @@ -0,0 +1,96 @@ +import { KeyboardShortcutCommand } from "@tiptap/core"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +type Direction = "up" | "down"; + +export const insertEmptyParagraphAtNodeBoundaries: ( + direction: Direction, + nodeType: string +) => KeyboardShortcutCommand = + (direction, nodeType) => + ({ editor }) => { + try { + const { selection, doc } = editor.state; + const { $from, $to } = selection; + + let targetNode: ProseMirrorNode | null = null; + let targetNodePos: number | null = null; + + // Check if the selection itself is the target node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === nodeType) { + targetNode = node; + targetNodePos = pos; + return false; // Stop iterating once the target node is found + } + return true; + }); + + if (targetNode === null || targetNodePos === null) return false; + + const docSize = doc.content.size; // Get the size of the document + + switch (direction) { + case "up": { + const insertPosUp = targetNodePos; + + // Ensure the insert position is within the document boundaries + if (insertPosUp < 0 || insertPosUp > docSize) return false; + + if (insertPosUp === 0) { + // If at the very start of the document, insert a new paragraph at the start + editor.chain().insertContentAt(insertPosUp, { type: "paragraph" }).run(); + editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph + } else { + // Otherwise, check the node immediately before the target node + const prevNode = doc.nodeAt(insertPosUp - 1); + + if (prevNode && prevNode.type.name === "paragraph") { + // If the previous node is a paragraph, move the cursor there + editor + .chain() + .setTextSelection(insertPosUp - 1) + .run(); + } else { + return false; // If the previous node is not a paragraph, do not proceed + } + } + break; + } + + case "down": { + const insertPosDown = targetNodePos + (targetNode as ProseMirrorNode).nodeSize; + + // Ensure the insert position is within the document boundaries + if (insertPosDown < 0 || insertPosDown > docSize) return false; + + // Check the node immediately after the target node + const nextNode = doc.nodeAt(insertPosDown); + + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is a paragraph, move the cursor to the end of it + const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If there is no next node (end of document), insert a new paragraph + editor.chain().insertContentAt(insertPosDown, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(insertPosDown + 1) + .run(); // Set the cursor to the new paragraph + } else { + return false; // If the next node is not a paragraph, do not proceed + } + break; + } + + default: + return false; // If the direction is not recognized, do not proceed + } + + return true; // Return true if the operation was successful + } catch (error) { + console.error(`An error occurred while inserting a line ${direction} the ${nodeType}:`, error); + return false; // Return false if an error occurred + } + }; diff --git a/packages/editor/core/src/helpers/scroll-to-node.ts b/packages/editor/src/core/helpers/scroll-to-node.ts similarity index 100% rename from packages/editor/core/src/helpers/scroll-to-node.ts rename to packages/editor/src/core/helpers/scroll-to-node.ts diff --git a/packages/editor/document-editor/src/utils/yjs.ts b/packages/editor/src/core/helpers/yjs.ts similarity index 100% rename from packages/editor/document-editor/src/utils/yjs.ts rename to packages/editor/src/core/helpers/yjs.ts diff --git a/packages/editor/src/core/hooks/use-document-editor.ts b/packages/editor/src/core/hooks/use-document-editor.ts new file mode 100644 index 000000000..b2e87662b --- /dev/null +++ b/packages/editor/src/core/hooks/use-document-editor.ts @@ -0,0 +1,114 @@ +import { useLayoutEffect, useMemo, useState } from "react"; +import Collaboration from "@tiptap/extension-collaboration"; +import { EditorProps } from "@tiptap/pm/view"; +import * as Y from "yjs"; +// extensions +import { DragAndDrop, IssueWidget } from "@/extensions"; +// hooks +import { TFileHandler, useEditor } from "@/hooks/use-editor"; +// plane editor extensions +import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; +// plane editor provider +import { CollaborationProvider } from "@/plane-editor/providers"; +// plane editor types +import { TEmbedConfig } from "@/plane-editor/types"; +// types +import { EditorRefApi, IMentionHighlight, IMentionSuggestion } from "@/types"; + +type DocumentEditorProps = { + editorClassName: string; + editorProps?: EditorProps; + embedHandler?: TEmbedConfig; + fileHandler: TFileHandler; + forwardedRef?: React.MutableRefObject; + handleEditorReady?: (value: boolean) => void; + id: string; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + onChange: (updates: Uint8Array) => void; + placeholder?: string | ((isFocused: boolean, value: string) => string); + setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void; + tabIndex?: number; + value: Uint8Array; +}; + +export const useDocumentEditor = (props: DocumentEditorProps) => { + const { + editorClassName, + editorProps = {}, + embedHandler, + fileHandler, + forwardedRef, + handleEditorReady, + id, + mentionHandler, + onChange, + placeholder, + setHideDragHandleFunction, + tabIndex, + value, + } = props; + + const provider = useMemo( + () => + new CollaborationProvider({ + name: id, + onChange, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [id] + ); + + const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false); + + // update document on value change from server + useLayoutEffect(() => { + if (value.length > 0) { + Y.applyUpdate(provider.document, value); + } + }, [value, provider.document, id]); + + // watch for indexedDb to complete syncing, only after which the editor is + // rendered + useLayoutEffect(() => { + async function checkIndexDbSynced() { + const hasSynced = await provider.hasIndexedDBSynced(); + setIndexedDbIsSynced(hasSynced); + } + checkIndexDbSynced(); + return () => { + setIndexedDbIsSynced(false); + }; + }, [provider]); + + const editor = useEditor({ + id, + editorProps, + editorClassName, + fileHandler, + handleEditorReady, + forwardedRef, + mentionHandler, + extensions: [ + DragAndDrop(setHideDragHandleFunction), + embedHandler?.issue && + IssueWidget({ + widgetCallback: embedHandler.issue.widgetCallback, + }), + Collaboration.configure({ + document: provider.document, + }), + ...DocumentEditorAdditionalExtensions({ + fileHandler, + issueEmbedConfig: embedHandler?.issue, + }), + ], + placeholder, + provider, + tabIndex, + }); + + return { editor, isIndexedDbSynced }; +}; diff --git a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx b/packages/editor/src/core/hooks/use-editor-markings.tsx similarity index 90% rename from packages/editor/document-editor/src/hooks/use-editor-markings.tsx rename to packages/editor/src/core/hooks/use-editor-markings.tsx index 88f125a26..76d02cd68 100644 --- a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx +++ b/packages/editor/src/core/hooks/use-editor-markings.tsx @@ -1,5 +1,11 @@ import { useCallback, useState } from "react"; -import { IMarking } from "src/types/editor-types"; + +export interface IMarking { + type: "heading"; + level: number; + text: string; + sequence: number; +} export const useEditorMarkings = () => { const [markings, setMarkings] = useState([]); diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/src/core/hooks/use-editor.ts similarity index 87% rename from packages/editor/core/src/hooks/use-editor.tsx rename to packages/editor/src/core/hooks/use-editor.ts index 76071791b..7cc26862b 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,17 +1,20 @@ -import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; -import { CoreEditorProps } from "src/ui/props"; -import { CoreEditorExtensions } from "src/ui/extensions"; -import { EditorProps } from "@tiptap/pm/view"; -import { DeleteImage } from "src/types/delete-image"; -import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion"; -import { RestoreImage } from "src/types/restore-image"; -import { UploadImage } from "src/types/upload-image"; import { Selection } from "@tiptap/pm/state"; -import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position"; -import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items"; -import { EditorRefApi } from "src/types/editor-ref-api"; -import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; +import { EditorProps } from "@tiptap/pm/view"; +import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +// components +import { EditorMenuItemNames, getEditorMenuItems } from "@/components/menus"; +// extensions +import { CoreEditorExtensions } from "@/extensions"; +// helpers +import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position"; +import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; +// plane editor providers +import { CollaborationProvider } from "@/plane-editor/providers"; +// props +import { CoreEditorProps } from "@/props"; +// types +import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; export type TFileHandler = { cancel: () => void; @@ -28,6 +31,7 @@ export interface CustomEditorProps { // undefined when prop is not passed, null if intentionally passed to stop // swr syncing value?: string | null | undefined; + provider?: CollaborationProvider; onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; @@ -53,6 +57,7 @@ export const useEditor = ({ forwardedRef, tabIndex, handleEditorReady, + provider, mentionHandler, placeholder, }: CustomEditorProps) => { @@ -112,7 +117,7 @@ export const useEditor = ({ if (value === null || value === undefined) return; if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) { try { - editor.commands.setContent(value); + editor.commands.setContent(value, false, { preserveWhitespace: "full" }); const currentSavedSelection = savedSelectionRef.current; if (currentSavedSelection) { const docLength = editor.state.doc.content.size; @@ -186,6 +191,18 @@ export const useEditor = ({ if (!editorRef.current) return; scrollSummary(editorRef.current, marking); }, + setSynced: () => { + if (provider) { + provider.setSynced(); + } + }, + hasUnsyncedChanges: () => { + if (provider) { + return provider.hasUnsyncedChanges(); + } else { + return false; + } + }, isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editorRef.current || editorRef.current.isDestroyed) { diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/src/core/hooks/use-read-only-editor.ts similarity index 87% rename from packages/editor/core/src/hooks/use-read-only-editor.tsx rename to packages/editor/src/core/hooks/use-read-only-editor.ts index 8b16d1e76..fcaf0c6dd 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,11 +1,14 @@ -import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; -import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions"; -import { CoreReadOnlyEditorProps } from "src/ui/read-only/props"; import { EditorProps } from "@tiptap/pm/view"; -import { EditorReadOnlyRefApi } from "src/types/editor-ref-api"; -import { IMarking, scrollSummary } from "src/helpers/scroll-to-node"; -import { IMentionHighlight } from "src/types/mention-suggestion"; +import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +// extensions +import { CoreReadOnlyEditorExtensions } from "@/extensions"; +// helpers +import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; +// props +import { CoreReadOnlyEditorProps } from "@/props"; +// types +import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; interface CustomReadOnlyEditorProps { initialValue: string; diff --git a/packages/editor/core/src/ui/plugins/image/constants.ts b/packages/editor/src/core/plugins/image/constants.ts similarity index 100% rename from packages/editor/core/src/ui/plugins/image/constants.ts rename to packages/editor/src/core/plugins/image/constants.ts diff --git a/packages/editor/core/src/ui/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts similarity index 89% rename from packages/editor/core/src/ui/plugins/image/delete-image.ts rename to packages/editor/src/core/plugins/image/delete-image.ts index 645dda99e..8dc1bf072 100644 --- a/packages/editor/core/src/ui/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -1,9 +1,9 @@ -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; -import { DeleteImage } from "src/types/delete-image"; import { Editor } from "@tiptap/core"; - -import { type ImageNode } from "src/ui/plugins/image/types/image-node"; -import { deleteKey, IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants"; +import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +// plugins +import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image"; +// types +import { DeleteImage } from "@/types"; export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin => new Plugin({ diff --git a/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts b/packages/editor/src/core/plugins/image/image-upload-handler.ts similarity index 67% rename from packages/editor/core/src/ui/plugins/image/image-upload-handler.ts rename to packages/editor/src/core/plugins/image/image-upload-handler.ts index 0be22e0dd..d0bd339da 100644 --- a/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts +++ b/packages/editor/src/core/plugins/image/image-upload-handler.ts @@ -1,14 +1,10 @@ -import { type UploadImage } from "src/types/upload-image"; - -// utilities -import { v4 as uuidv4 } from "uuid"; - -// types -import { isFileValid } from "src/ui/plugins/image/utils/validate-file"; import { Editor } from "@tiptap/core"; import { EditorView } from "@tiptap/pm/view"; -import { uploadKey } from "./constants"; -import { removePlaceholder, findPlaceholder } from "./utils/placeholder"; +import { v4 as uuidv4 } from "uuid"; +// plugins +import { findPlaceholder, isFileValid, removePlaceholder, uploadKey } from "@/plugins/image"; +// types +import { UploadImage } from "@/types"; export async function startImageUpload( editor: Editor, @@ -50,9 +46,25 @@ export async function startImageUpload( }; try { + const fileNameTrimmed = trimFileName(file.name); + const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); + + const resolvedPos = view.state.doc.resolve(pos ?? 0); + const nodeBefore = resolvedPos.nodeBefore; + + // if the image is at the start of the line i.e. when nodeBefore is null + if (nodeBefore === null) { + if (pos) { + // so that the image is not inserted at the next line, else incase the + // image is inserted at any line where there's some content, the + // position is kept as it is to be inserted at the next line + pos -= 1; + } + } + view.focus(); - const src = await uploadAndValidateImage(file, uploadFile); + const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile); if (src == null) { throw new Error("Resolved image URL is undefined."); @@ -112,3 +124,14 @@ async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Prom throw error; } } + +function trimFileName(fileName: string, maxLength = 100) { + if (fileName.length > maxLength) { + const extension = fileName.split(".").pop(); + const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1)); + const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot + return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`; + } + + return fileName; +} diff --git a/packages/editor/src/core/plugins/image/index.ts b/packages/editor/src/core/plugins/image/index.ts new file mode 100644 index 000000000..e5a290abe --- /dev/null +++ b/packages/editor/src/core/plugins/image/index.ts @@ -0,0 +1,7 @@ +export * from "./types"; +export * from "./utils"; +export * from "./constants"; +export * from "./delete-image"; +export * from "./image-upload-handler"; +export * from "./restore-image"; +export * from "./upload-image"; diff --git a/packages/editor/core/src/ui/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts similarity index 92% rename from packages/editor/core/src/ui/plugins/image/restore-image.ts rename to packages/editor/src/core/plugins/image/restore-image.ts index 61a7a7a34..036df9b88 100644 --- a/packages/editor/core/src/ui/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -1,9 +1,9 @@ import { Editor } from "@tiptap/core"; import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; -import { RestoreImage } from "src/types/restore-image"; - -import { restoreKey, IMAGE_NODE_TYPE } from "./constants"; -import { type ImageNode } from "./types/image-node"; +// plugins +import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image"; +// types +import { RestoreImage } from "@/types"; export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin => new Plugin({ diff --git a/packages/editor/core/src/ui/plugins/image/types/image-node.ts b/packages/editor/src/core/plugins/image/types/image-node.ts similarity index 100% rename from packages/editor/core/src/ui/plugins/image/types/image-node.ts rename to packages/editor/src/core/plugins/image/types/image-node.ts diff --git a/packages/editor/src/core/plugins/image/types/index.ts b/packages/editor/src/core/plugins/image/types/index.ts new file mode 100644 index 000000000..2fddf3bf6 --- /dev/null +++ b/packages/editor/src/core/plugins/image/types/index.ts @@ -0,0 +1 @@ +export * from "./image-node"; diff --git a/packages/editor/core/src/ui/plugins/image/upload-image.ts b/packages/editor/src/core/plugins/image/upload-image.ts similarity index 94% rename from packages/editor/core/src/ui/plugins/image/upload-image.ts rename to packages/editor/src/core/plugins/image/upload-image.ts index 554e37de2..e3db70d13 100644 --- a/packages/editor/core/src/ui/plugins/image/upload-image.ts +++ b/packages/editor/src/core/plugins/image/upload-image.ts @@ -1,12 +1,8 @@ import { Editor } from "@tiptap/core"; import { Plugin } from "@tiptap/pm/state"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; - -// utils -import { removePlaceholder } from "src/ui/plugins/image/utils/placeholder"; - -// constants -import { uploadKey } from "src/ui/plugins/image/constants"; +// plugins +import { removePlaceholder, uploadKey } from "@/plugins/image"; export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { let currentView: EditorView | null = null; diff --git a/packages/editor/src/core/plugins/image/utils/index.ts b/packages/editor/src/core/plugins/image/utils/index.ts new file mode 100644 index 000000000..217ec4117 --- /dev/null +++ b/packages/editor/src/core/plugins/image/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./placeholder"; +export * from "./validate-file"; diff --git a/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts b/packages/editor/src/core/plugins/image/utils/placeholder.ts similarity index 91% rename from packages/editor/core/src/ui/plugins/image/utils/placeholder.ts rename to packages/editor/src/core/plugins/image/utils/placeholder.ts index 9636da4a7..f05f4d890 100644 --- a/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts +++ b/packages/editor/src/core/plugins/image/utils/placeholder.ts @@ -1,7 +1,8 @@ import { Editor } from "@tiptap/core"; import { EditorState } from "@tiptap/pm/state"; import { DecorationSet, EditorView } from "@tiptap/pm/view"; -import { uploadKey } from "src/ui/plugins/image/constants"; +// plugins +import { uploadKey } from "@/plugins/image"; export function findPlaceholder(state: EditorState, id: string): number | null { const decos = uploadKey.getState(state) as DecorationSet; diff --git a/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts similarity index 100% rename from packages/editor/core/src/ui/plugins/image/utils/validate-file.ts rename to packages/editor/src/core/plugins/image/utils/validate-file.ts diff --git a/packages/editor/src/core/props/index.ts b/packages/editor/src/core/props/index.ts new file mode 100644 index 000000000..eaa89f059 --- /dev/null +++ b/packages/editor/src/core/props/index.ts @@ -0,0 +1,2 @@ +export * from "./props"; +export * from "./read-only"; diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/src/core/props/props.tsx similarity index 93% rename from packages/editor/core/src/ui/props.tsx rename to packages/editor/src/core/props/props.tsx index 32d1510c7..11e829162 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/src/core/props/props.tsx @@ -1,5 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; -import { cn } from "src/lib/utils"; +// helpers +import { cn } from "@/helpers/common"; export function CoreEditorProps(editorClassName: string): EditorProps { return { diff --git a/packages/editor/core/src/ui/read-only/props.tsx b/packages/editor/src/core/props/read-only.tsx similarity index 85% rename from packages/editor/core/src/ui/read-only/props.tsx rename to packages/editor/src/core/props/read-only.tsx index bd9b6713b..ea583938f 100644 --- a/packages/editor/core/src/ui/read-only/props.tsx +++ b/packages/editor/src/core/props/read-only.tsx @@ -1,5 +1,6 @@ import { EditorProps } from "@tiptap/pm/view"; -import { cn } from "src/lib/utils"; +// helpers +import { cn } from "@/helpers/common"; export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({ attributes: { diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts new file mode 100644 index 000000000..84e522b55 --- /dev/null +++ b/packages/editor/src/core/types/editor.ts @@ -0,0 +1,66 @@ +// components +import { EditorMenuItemNames } from "@/components/menus"; +// helpers +import { IMarking } from "@/helpers/scroll-to-node"; +// hooks +import { TFileHandler } from "@/hooks/use-editor"; +// types +import { IMentionHighlight, IMentionSuggestion } from "@/types"; + +export type EditorReadOnlyRefApi = { + getMarkDown: () => string; + getHTML: () => string; + clearEditor: () => void; + setEditorValue: (content: string) => void; + scrollSummary: (marking: IMarking) => void; +}; + +export interface EditorRefApi extends EditorReadOnlyRefApi { + setEditorValueAtCursorPosition: (content: string) => void; + executeMenuItemCommand: (itemName: EditorMenuItemNames) => void; + isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; + onStateChange: (callback: () => void) => () => void; + setFocusAtPosition: (position: number) => void; + isEditorReadyToDiscard: () => boolean; + setSynced: () => void; + hasUnsyncedChanges: () => boolean; +} + +export interface IEditorProps { + containerClassName?: string; + editorClassName?: string; + fileHandler: TFileHandler; + forwardedRef?: React.MutableRefObject; + id?: string; + initialValue: string; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + onChange?: (json: object, html: string) => void; + onEnterKeyPress?: (descriptionHTML: string) => void; + placeholder?: string | ((isFocused: boolean, value: string) => string); + tabIndex?: number; + value?: string | null; +} + +export interface ILiteTextEditor extends IEditorProps {} + +export interface IRichTextEditor extends IEditorProps { + dragDropEnabled?: boolean; +} + +export interface IReadOnlyEditorProps { + containerClassName?: string; + editorClassName?: string; + forwardedRef?: React.MutableRefObject; + initialValue: string; + mentionHandler: { + highlights: () => Promise; + }; + tabIndex?: number; +} + +export interface ILiteTextReadOnlyEditor extends IReadOnlyEditorProps {} + +export interface IRichTextReadOnlyEditor extends IReadOnlyEditorProps {} diff --git a/packages/editor/src/core/types/embed.ts b/packages/editor/src/core/types/embed.ts new file mode 100644 index 000000000..3acd662be --- /dev/null +++ b/packages/editor/src/core/types/embed.ts @@ -0,0 +1,8 @@ +export type TEmbedItem = { + id: string; + title: string; + subTitle: string; + icon: React.ReactNode; + projectId: string; + workspaceSlug: string; +}; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts new file mode 100644 index 000000000..c1b174a48 --- /dev/null +++ b/packages/editor/src/core/types/image.ts @@ -0,0 +1,5 @@ +export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; + +export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; + +export type UploadImage = (file: File) => Promise; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts new file mode 100644 index 000000000..f4dd89412 --- /dev/null +++ b/packages/editor/src/core/types/index.ts @@ -0,0 +1,5 @@ +export * from "./editor"; +export * from "./embed"; +export * from "./image"; +export * from "./mention-suggestion"; +export * from "./slash-commands-suggestion"; diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/src/core/types/mention-suggestion.ts similarity index 67% rename from packages/editor/core/src/types/mention-suggestion.ts rename to packages/editor/src/core/types/mention-suggestion.ts index aa2ad4ba2..a51bed704 100644 --- a/packages/editor/core/src/types/mention-suggestion.ts +++ b/packages/editor/src/core/types/mention-suggestion.ts @@ -1,4 +1,3 @@ -import { Editor, Range } from "@tiptap/react"; export type IMentionSuggestion = { id: string; type: string; @@ -10,9 +9,4 @@ export type IMentionSuggestion = { redirect_uri: string; }; -export type CommandProps = { - editor: Editor; - range: Range; -}; - export type IMentionHighlight = string; diff --git a/packages/editor/core/src/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts similarity index 100% rename from packages/editor/core/src/types/slash-commands-suggestion.ts rename to packages/editor/src/core/types/slash-commands-suggestion.ts diff --git a/packages/editor/src/ee/extensions/index.ts b/packages/editor/src/ee/extensions/index.ts new file mode 100644 index 000000000..1c59af5c6 --- /dev/null +++ b/packages/editor/src/ee/extensions/index.ts @@ -0,0 +1 @@ +export * from "src/ce/extensions"; diff --git a/packages/editor/src/ee/providers/index.ts b/packages/editor/src/ee/providers/index.ts new file mode 100644 index 000000000..3f53c1e7a --- /dev/null +++ b/packages/editor/src/ee/providers/index.ts @@ -0,0 +1 @@ +export * from "src/ce/providers"; diff --git a/packages/editor/src/ee/types/index.ts b/packages/editor/src/ee/types/index.ts new file mode 100644 index 000000000..00cebc05e --- /dev/null +++ b/packages/editor/src/ee/types/index.ts @@ -0,0 +1 @@ +export * from "src/ce/types"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts new file mode 100644 index 000000000..828fab021 --- /dev/null +++ b/packages/editor/src/index.ts @@ -0,0 +1,38 @@ +// styles +// import "./styles/tailwind.css"; +import "src/styles/editor.css"; +import "src/styles/table.css"; +import "src/styles/github-dark.css"; +import "src/styles/drag-drop.css"; + +// editors +export { + DocumentEditorWithRef, + DocumentReadOnlyEditorWithRef, + LiteTextEditorWithRef, + LiteTextReadOnlyEditorWithRef, + RichTextEditorWithRef, + RichTextReadOnlyEditorWithRef, +} from "@/components/editors"; + +export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection"; + +// helpers +export * from "@/helpers/common"; +export * from "@/components/editors/document/helpers"; +export * from "@/helpers/editor-commands"; +export * from "@/helpers/yjs"; +export * from "@/extensions/table/table"; +export { startImageUpload } from "@/plugins/image"; + +// components +export * from "@/components/menus"; + +// hooks +export { useEditor } from "@/hooks/use-editor"; +export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings"; +export { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; + +// types +export type { CustomEditorProps, TFileHandler } from "@/hooks/use-editor"; +export * from "@/types"; diff --git a/packages/editor/extensions/src/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css similarity index 67% rename from packages/editor/extensions/src/styles/drag-drop.css rename to packages/editor/src/styles/drag-drop.css index d46d26ecc..74eaeb475 100644 --- a/packages/editor/extensions/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -75,6 +75,37 @@ border-radius: 4px; } +/* for targetting the taks list items */ +li.ProseMirror-selectednode:not(.dragging)[data-checked]::after { + margin-left: -5px; +} + +/* for targetting the unordered list items */ +ul > li.ProseMirror-selectednode:not(.dragging)::after { + margin-left: -10px; /* Adjust as needed */ +} + +/* Initialize a counter for the ordered list */ +ol { + counter-reset: item; +} + +/* for targetting the ordered list items */ +ol > li.ProseMirror-selectednode:not(.dragging)::after { + counter-increment: item; + margin-left: -18px; +} + +/* for targetting the ordered list items after the 9th item */ +ol > li:nth-child(n + 10).ProseMirror-selectednode:not(.dragging)::after { + margin-left: -25px; +} + +/* for targetting the ordered list items after the 99th item */ +ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { + margin-left: -35px; +} + .ProseMirror img { transition: filter 0.1s ease-in-out; cursor: pointer; diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/src/styles/editor.css similarity index 97% rename from packages/editor/core/src/styles/editor.css rename to packages/editor/src/styles/editor.css index 00312cb00..28fb2dd11 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -17,6 +17,11 @@ height: 0; } +.ProseMirror li blockquote { + margin-top: 10px; + padding-inline-start: 1em; +} + /* block quotes */ .ProseMirror blockquote { font-style: normal; @@ -110,6 +115,11 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover { } } +/* the p tag just after the ul tag */ +ul[data-type="taskList"] + p { + margin-top: 0.4rem !important; +} + ul[data-type="taskList"] li > label input[type="checkbox"] { position: relative; -webkit-appearance: none; @@ -152,6 +162,10 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { } } +ul[data-type="taskList"] li > div > p { + margin-top: 10px; +} + ul[data-type="taskList"] li[data-checked="true"] > div > p { color: rgb(var(--color-text-400)); text-decoration: line-through; diff --git a/packages/editor/core/src/styles/github-dark.css b/packages/editor/src/styles/github-dark.css similarity index 100% rename from packages/editor/core/src/styles/github-dark.css rename to packages/editor/src/styles/github-dark.css diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/src/styles/table.css similarity index 98% rename from packages/editor/core/src/styles/table.css rename to packages/editor/src/styles/table.css index d5adac9b5..6b45abcf5 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -20,7 +20,7 @@ .table-wrapper table th { min-width: 1em; border: 1px solid rgba(var(--color-border-200)); - padding: 10px 15px; + padding: 10px 20px; vertical-align: top; box-sizing: border-box; position: relative; @@ -82,6 +82,7 @@ .table-wrapper .table-controls .rows-control { width: 20px; transform: translateX(-50%); + left: -8px; } .table-wrapper .table-controls .rows-control .rows-control-div { @@ -114,3 +115,4 @@ opacity: 0; pointer-events: none; } + diff --git a/packages/editor/core/src/styles/tailwind.css b/packages/editor/src/styles/tailwind.css similarity index 100% rename from packages/editor/core/src/styles/tailwind.css rename to packages/editor/src/styles/tailwind.css diff --git a/packages/editor/core/tailwind.config.js b/packages/editor/tailwind.config.js similarity index 100% rename from packages/editor/core/tailwind.config.js rename to packages/editor/tailwind.config.js diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json new file mode 100644 index 000000000..cfe8401f6 --- /dev/null +++ b/packages/editor/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["src/**/*", "index.d.ts"], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/core/*"], + "@/styles/*": ["src/styles/*"], + "@/plane-editor/*": ["src/ce/*"] + } + } +} diff --git a/packages/editor/core/tsup.config.ts b/packages/editor/tsup.config.ts similarity index 100% rename from packages/editor/core/tsup.config.ts rename to packages/editor/tsup.config.ts diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 39d657d93..6f651f08e 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -1,10 +1,5 @@ module.exports = { - extends: [ - "next", - "turbo", - "prettier", - "plugin:@typescript-eslint/recommended", - ], + extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"], parser: "@typescript-eslint/parser", parserOptions: { ecmaVersion: 2021, // Or the ECMAScript version you are using @@ -16,11 +11,20 @@ module.exports = { rootDir: ["web/", "space/", "admin/", "packages/*/"], }, }, + globals: { + React: "readonly", + JSX: "readonly", + }, rules: { + "no-useless-escape": "off", "prefer-const": "error", "no-irregular-whitespace": "error", "no-trailing-spaces": "error", "no-duplicate-imports": "error", + "no-useless-catch": "warn", + "no-case-declarations": "error", + "no-undef": "error", + "no-unreachable": "error", "arrow-body-style": ["error", "as-needed"], "@next/next/no-html-link-for-pages": "off", "@next/next/no-img-element": "off", @@ -28,6 +32,7 @@ module.exports = { "react/self-closing-comp": ["error", { component: true, html: true }], "react/jsx-boolean-value": "error", "react/jsx-no-duplicate-props": "error", + "react-hooks/exhaustive-deps": "warn", "@typescript-eslint/no-unused-vars": ["error"], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-useless-empty-export": "error", @@ -37,6 +42,7 @@ module.exports = { { selector: ["function", "variable"], format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"], + leadingUnderscore: "allow", }, ], }, diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index fb34284c3..161ca7182 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,7 +1,7 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.21.0", + "version": "0.22.0", "main": "index.js", "license": "MIT", "devDependencies": {}, diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 187188029..b40c4f37c 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.21.0", + "version": "0.22.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 42e176043..7cece3a9b 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -8,6 +8,10 @@ module.exports = { content: { relative: true, files: [ + "./app/**/*.{js,ts,jsx,tsx}", + "./core/**/*.{js,ts,jsx,tsx}", + "./ce/**/*.{js,ts,jsx,tsx}", + "./ee/**/*.{js,ts,jsx,tsx}", "./components/**/*.tsx", "./constants/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.tsx", diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index a2f084dbc..9e5d22ecc 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.21.0", + "version": "0.22.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 918e9e77d..3d7d663f6 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.21.0", + "version": "0.22.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts index 35da4b723..2fb7ad51a 100644 --- a/packages/types/src/analytics.d.ts +++ b/packages/types/src/analytics.d.ts @@ -54,7 +54,7 @@ export type TXAxisValues = | "state__group" | "labels__id" | "assignees__id" - | "estimate_point" + | "estimate_point__value" | "issue_cycle__cycle_id" | "issue_module__module_id" | "priority" diff --git a/packages/types/src/current-user/accounts.d.ts b/packages/types/src/current-user/accounts.d.ts index 6c5146a7a..d328f0529 100644 --- a/packages/types/src/current-user/accounts.d.ts +++ b/packages/types/src/current-user/accounts.d.ts @@ -4,7 +4,7 @@ export type TCurrentUserAccount = { user: string | undefined; provider_account_id: string | undefined; - provider: "google" | "github" | string | undefined; + provider: "google" | "github" | "gitlab" | string | undefined; access_token: string | undefined; access_token_expired_at: Date | undefined; refresh_token: string | undefined; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index e93d6e444..b0528ccc1 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -2,32 +2,81 @@ import type { TIssue, IIssueFilterOptions } from "@plane/types"; export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; -export interface ICycle { - backlog_issues: number; - cancelled_issues: number; +export type TCycleCompletionChartDistribution = { + [key: string]: number | null; +}; + +export type TCycleDistributionBase = { + total_issues: number; + pending_issues: number; completed_issues: number; +}; + +export type TCycleEstimateDistributionBase = { + total_estimates: number; + pending_estimates: number; + completed_estimates: number; +}; + +export type TCycleAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; +}; + +export type TCycleLabelsDistribution = { + color: string | null; + label_id: string | null; + label_name: string | null; +}; + +export type TCycleDistribution = { + assignees: (TCycleAssigneesDistribution & TCycleDistributionBase)[]; + completion_chart: TCycleCompletionChartDistribution; + labels: (TCycleLabelsDistribution & TCycleDistributionBase)[]; +}; + +export type TCycleEstimateDistribution = { + assignees: (TCycleAssigneesDistribution & TCycleEstimateDistributionBase)[]; + completion_chart: TCycleCompletionChartDistribution; + labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[]; +}; + +export type TProgressSnapshot = { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + distribution?: TCycleDistribution; + estimate_distribution?: TCycleEstimateDistribution; +}; + +export interface ICycle extends TProgressSnapshot { + progress_snapshot: TProgressSnapshot | undefined; + created_at?: string; created_by?: string; description: string; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; end_date: string | null; id: string; is_favorite?: boolean; name: string; owned_by_id: string; - progress_snapshot: TProgressSnapshot; project_id: string; status?: TCycleGroups; sort_order: number; start_date: string | null; - started_issues: number; sub_issues?: number; - total_issues: number; - unstarted_issues: number; updated_at?: string; updated_by?: string; archived_at: string | null; @@ -38,47 +87,6 @@ export interface ICycle { workspace_id: string; } -export type TProgressSnapshot = { - backlog_issues: number; - cancelled_issues: number; - completed_estimates: number | null; - completed_issues: number; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; - started_estimates: number | null; - started_issues: number; - total_estimates: number | null; - total_issues: number; - unstarted_issues: number; -}; - -export type TAssigneesDistribution = { - assignee_id: string | null; - avatar: string | null; - completed_issues: number; - first_name: string | null; - last_name: string | null; - display_name: string | null; - pending_issues: number; - total_issues: number; -}; - -export type TCompletionChartDistribution = { - [key: string]: number | null; -}; - -export type TLabelsDistribution = { - color: string | null; - completed_issues: number; - label_id: string | null; - label_name: string | null; - pending_issues: number; - total_issues: number; -}; - export interface CycleIssueResponse { id: string; issue_detail: TIssue; @@ -102,3 +110,5 @@ export type CycleDateCheckData = { end_date: string; cycle_id?: string; }; + +export type TCyclePlotType = "burndown" | "points"; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index a4d098506..08949bd17 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -24,3 +24,23 @@ export enum EIssueCommentAccessSpecifier { EXTERNAL = "EXTERNAL", INTERNAL = "INTERNAL", } + +// estimates +export enum EEstimateSystem { + POINTS = "points", + CATEGORIES = "categories", + TIME = "time", +} + +export enum EEstimateUpdateStages { + CREATE = "create", + EDIT = "edit", + SWITCH = "switch", +} + +// workspace notifications +export enum ENotificationFilterType { + CREATED = "created", + ASSIGNED = "assigned", + SUBSCRIBED = "subscribed", +} diff --git a/packages/types/src/estimate.d.ts b/packages/types/src/estimate.d.ts index 96b584ce1..145edf117 100644 --- a/packages/types/src/estimate.d.ts +++ b/packages/types/src/estimate.d.ts @@ -1,40 +1,87 @@ -export interface IEstimate { - created_at: Date; - created_by: string; - description: string; - id: string; - name: string; - project: string; - project_detail: IProject; - updated_at: Date; - updated_by: string; - points: IEstimatePoint[]; - workspace: string; - workspace_detail: IWorkspace; -} +import { EEstimateSystem, EEstimateUpdateStages } from "./enums"; export interface IEstimatePoint { - created_at: string; - created_by: string; - description: string; - estimate: string; - id: string; - key: number; - project: string; - updated_at: string; - updated_by: string; - value: string; - workspace: string; + id: string | undefined; + key: number | undefined; + value: string | undefined; + description: string | undefined; + workspace: string | undefined; + project: string | undefined; + estimate: string | undefined; + created_at: Date | undefined; + updated_at: Date | undefined; + created_by: string | undefined; + updated_by: string | undefined; +} + +export type TEstimateSystemKeys = + | EEstimateSystem.POINTS + | EEstimateSystem.CATEGORIES + | EEstimateSystem.TIME; + +export interface IEstimate { + id: string | undefined; + name: string | undefined; + description: string | undefined; + type: TEstimateSystemKeys | undefined; // categories, points, time + points: IEstimatePoint[] | undefined; + workspace: string | undefined; + project: string | undefined; + last_used: boolean | undefined; + created_at: Date | undefined; + updated_at: Date | undefined; + created_by: string | undefined; + updated_by: string | undefined; } export interface IEstimateFormData { - estimate: { - name: string; - description: string; + estimate?: { + name?: string; + type?: string; + last_used?: boolean; }; estimate_points: { - id?: string; + id?: string | undefined; key: number; value: string; }[]; } + +export type TEstimatePointsObject = { + id?: string | undefined; + key: number; + value: string; +}; + +export type TTemplateValues = { + title: string; + values: TEstimatePointsObject[]; + hide?: boolean; +}; + +export type TEstimateSystem = { + name: string; + templates: Record; + is_available: boolean; + is_ee: boolean; +}; + +export type TEstimateSystems = { + [K in TEstimateSystemKeys]: TEstimateSystem; +}; + +// update estimates +export type TEstimateUpdateStageKeys = + | EEstimateUpdateStages.CREATE + | EEstimateUpdateStages.EDIT + | EEstimateUpdateStages.SWITCH; + +export type TEstimateTypeErrorObject = { + oldValue: string; + newValue: string; + message: string | undefined; +}; + +export type TEstimateTypeError = + | Record + | undefined; diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox.d.ts index 0f0b93e4f..afb744f6c 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox.d.ts @@ -81,7 +81,7 @@ export type TInboxDuplicateIssueDetails = { export type TInboxIssue = { id: string; status: TInboxIssueStatus; - snoozed_till: Date | undefined; + snoozed_till: Date | null; duplicate_to: string | undefined; source: string; issue: TIssue; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index b8dd2d3c1..353aeaf08 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -15,12 +15,10 @@ export * from "./importer"; export * from "./inbox"; export * from "./analytics"; export * from "./api_token"; -export * from "./app"; export * from "./auth"; export * from "./calendar"; export * from "./instance"; export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable -export * from "./notifications"; export * from "./reaction"; export * from "./view-props"; export * from "./waitlist"; @@ -28,3 +26,5 @@ export * from "./webhook"; export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; +export * from "./publish"; +export * from "./workspace-notifications"; diff --git a/packages/types/src/instance/auth.d.ts b/packages/types/src/instance/auth.d.ts index 0366ce660..d71cfa0bb 100644 --- a/packages/types/src/instance/auth.d.ts +++ b/packages/types/src/instance/auth.d.ts @@ -1,9 +1,19 @@ +export type TInstanceAuthenticationModes = { + key: string; + name: string; + description: string; + icon: JSX.Element; + config: JSX.Element; + unavailable?: boolean; +}; + export type TInstanceAuthenticationMethodKeys = | "ENABLE_SIGNUP" | "ENABLE_MAGIC_LINK_LOGIN" | "ENABLE_EMAIL_PASSWORD" | "IS_GOOGLE_ENABLED" - | "IS_GITHUB_ENABLED"; + | "IS_GITHUB_ENABLED" + | "IS_GITLAB_ENABLED"; export type TInstanceGoogleAuthenticationConfigurationKeys = | "GOOGLE_CLIENT_ID" @@ -13,10 +23,22 @@ export type TInstanceGithubAuthenticationConfigurationKeys = | "GITHUB_CLIENT_ID" | "GITHUB_CLIENT_SECRET"; +export type TInstanceGitlabAuthenticationConfigurationKeys = + | "GITLAB_HOST" + | "GITLAB_CLIENT_ID" + | "GITLAB_CLIENT_SECRET"; + type TInstanceAuthenticationConfigurationKeys = | TInstanceGoogleAuthenticationConfigurationKeys - | TInstanceGithubAuthenticationConfigurationKeys; + | TInstanceGithubAuthenticationConfigurationKeys + | TInstanceGitlabAuthenticationConfigurationKeys; export type TInstanceAuthenticationKeys = | TInstanceAuthenticationMethodKeys | TInstanceAuthenticationConfigurationKeys; + +export type TGetBaseAuthenticationModeProps = { + disabled: boolean; + updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void; + resolvedTheme: string | undefined; +}; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 53804dec3..1332102b7 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -38,6 +38,7 @@ export interface IInstance { export interface IInstanceConfig { is_google_enabled: boolean; is_github_enabled: boolean; + is_gitlab_enabled: boolean; is_magic_login_enabled: boolean; is_email_password_enabled: boolean; github_app_name: string | undefined; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index ae210d3b1..1ad8530cd 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -1,3 +1,6 @@ +import { StateGroup } from "components/states"; +import { TIssuePriorities } from "../issues"; + // issues export * from "./issue"; export * from "./issue_reaction"; @@ -7,16 +10,30 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; -export type TLoader = "init-loader" | "mutation" | undefined; +export type TLoader = "init-loader" | "mutation" | "pagination" | undefined; export type TGroupedIssues = { [group_id: string]: string[]; }; export type TSubGroupedIssues = { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; + [sub_grouped_id: string]: TGroupedIssues; }; -export type TUnGroupedIssues = string[]; +export type TIssues = TGroupedIssues | TSubGroupedIssues; + +export type TPaginationData = { + nextCursor: string; + prevCursor: string; + nextPageResults: boolean; +}; + +export type TIssuePaginationData = { + [group_id: string]: TPaginationData; +}; + +export type TGroupedIssueCount = { + [group_id: string]: number; +}; + +export type TUnGroupedIssues = string[]; \ No newline at end of file diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 42c95dc4e..d86ab24d2 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -4,24 +4,24 @@ import { TIssueLink } from "./issue_link"; import { TIssueReaction } from "./issue_reaction"; // new issue structure types -export type TIssue = { + +export type TBaseIssue = { id: string; sequence_id: number; name: string; - description_html: string; sort_order: number; - state_id: string; - priority: TIssuePriorities; + state_id: string | null; + priority: TIssuePriorities | null; label_ids: string[]; assignee_ids: string[]; - estimate_point: number | null; + estimate_point: string | null; sub_issues_count: number; attachment_count: number; link_count: number; - project_id: string; + project_id: string | null; parent_id: string | null; cycle_id: string | null; module_ids: string[] | null; @@ -37,9 +37,14 @@ export type TIssue = { updated_by: string; is_draft: boolean; +}; + +export type TIssue = TBaseIssue & { + description_html?: string; is_subscribed?: boolean; parent?: partial; + issue_reactions?: TIssueReaction[]; issue_attachment?: TIssueAttachment[]; issue_link?: TIssueLink[]; @@ -51,3 +56,47 @@ export type TIssue = { export type TIssueMap = { [issue_id: string]: TIssue; }; + +type TIssueResponseResults = + | TBaseIssue[] + | { + [key: string]: { + results: + | TBaseIssue[] + | { + [key: string]: { + results: TBaseIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +} + +export type TBulkIssueProperties = Pick< + TIssue, + | "state_id" + | "priority" + | "label_ids" + | "assignee_ids" + | "start_date" + | "target_date" +>; + +export type TBulkOperationsPayload = { + issue_ids: string[]; + properties: Partial; +}; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 0019781ba..6a5a09231 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -1,11 +1,4 @@ -import type { - TIssue, - IIssueFilterOptions, - ILinkDetails, - TAssigneesDistribution, - TCompletionChartDistribution, - TLabelsDistribution, -} from "@plane/types"; +import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types"; export type TModuleStatus = | "backlog" @@ -15,42 +8,88 @@ export type TModuleStatus = | "completed" | "cancelled"; -export interface IModule { - backlog_issues: number; - cancelled_issues: number; +export type TModuleCompletionChartDistribution = { + [key: string]: number | null; +}; + +export type TModuleDistributionBase = { + total_issues: number; + pending_issues: number; completed_issues: number; - created_at: string; - created_by?: string; +}; + +export type TModuleEstimateDistributionBase = { + total_estimates: number; + pending_estimates: number; + completed_estimates: number; +}; + +export type TModuleAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + first_name: string | null; + last_name: string | null; + display_name: string | null; +}; + +export type TModuleLabelsDistribution = { + color: string | null; + label_id: string | null; + label_name: string | null; +}; + +export type TModuleDistribution = { + assignees: (TModuleAssigneesDistribution & TModuleDistributionBase)[]; + completion_chart: TModuleCompletionChartDistribution; + labels: (TModuleLabelsDistribution & TModuleDistributionBase)[]; +}; + +export type TModuleEstimateDistribution = { + assignees: (TModuleAssigneesDistribution & TModuleEstimateDistributionBase)[]; + completion_chart: TModuleCompletionChartDistribution; + labels: (TModuleLabelsDistribution & TModuleEstimateDistributionBase)[]; +}; + +export interface IModule { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + distribution?: TModuleDistribution; + estimate_distribution?: TModuleEstimateDistribution; + + id: string; + name: string; description: string; description_text: any; description_html: any; - distribution?: { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - }; - id: string; - lead_id: string | null; - link_module?: ILinkDetails[]; - member_ids: string[]; - is_favorite: boolean; - name: string; + workspace_id: string; project_id: string; - sort_order: number; + lead_id: string | null; + member_ids: string[]; + link_module?: ILinkDetails[]; sub_issues?: number; - start_date: string | null; - started_issues: number; - status?: TModuleStatus; - target_date: string | null; - total_issues: number; - unstarted_issues: number; - updated_at: string; - updated_by?: string; - archived_at: string | null; + is_favorite: boolean; + sort_order: number; view_props: { filters: IIssueFilterOptions; }; - workspace_id: string; + status?: TModuleStatus; + archived_at: string | null; + start_date: string | null; + target_date: string | null; + created_at: string; + updated_at: string; + created_by?: string; + updated_by?: string; } export interface ModuleIssueResponse { @@ -76,3 +115,5 @@ export type ModuleLink = { export type SelectModuleType = | (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined; + +export type TModulePlotType = "burndown" | "points"; diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts deleted file mode 100644 index d739b2309..000000000 --- a/packages/types/src/notifications.d.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { IUserLite } from "./users"; - -export interface PaginatedUserNotification { - next_cursor: string; - prev_cursor: string; - next_page_results: boolean; - prev_page_results: boolean; - count: number; - total_pages: number; - extra_stats: null; - results: IUserNotification[]; -} - -export interface IUserNotification { - archived_at: string | null; - created_at: string; - created_by: null; - data: Data; - entity_identifier: string; - entity_name: string; - id: string; - message: null; - message_html: string; - message_stripped: null; - project: string; - read_at: Date | null; - receiver: string; - sender: string; - snoozed_till: Date | null; - title: string; - triggered_by: string; - triggered_by_details: IUserLite; - updated_at: Date; - updated_by: null; - workspace: string; -} - -export interface Data { - issue: INotificationIssueLite; - issue_activity: { - actor: string; - field: string; - id: string; - issue_comment: string | null; - new_value: string; - old_value: string; - verb: "created" | "updated"; - }; -} - -export interface INotificationIssueLite { - id: string; - name: string; - identifier: string; - state_name: string; - sequence_id: number; - state_group: string; -} - -export type NotificationType = "created" | "assigned" | "watching" | "all"; - -export interface INotificationParams { - snoozed?: boolean; - type?: NotificationType; - archived?: boolean; - read?: boolean; -} - -export type NotificationCount = { - created_issues: number; - my_issues: number; - watching_issues: number; -}; - -export interface IMarkAllAsReadPayload { - archived?: boolean; - snoozed?: boolean; - type?: NotificationType; -} diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 1c94dfc06..9b7249bdc 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -3,6 +3,7 @@ import { EPageAccess } from "./enums"; export type TPage = { access: EPageAccess | undefined; + anchor?: string | null | undefined; archived_at: string | null | undefined; color: string | undefined; created_at: Date | undefined; @@ -11,10 +12,10 @@ export type TPage = { id: string | undefined; is_favorite: boolean; is_locked: boolean; - labels: string[] | undefined; + label_ids: string[] | undefined; name: string | undefined; owned_by: string | undefined; - project: string | undefined; + project_ids: string[] | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index ee974fd63..59ccf73b6 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -32,7 +32,7 @@ export interface IProject { estimate: string | null; id: string; identifier: string; - is_deployed: boolean; + anchor: string | null; is_favorite: boolean; is_member: boolean; logo_props: TLogoProps; diff --git a/packages/types/src/publish.d.ts b/packages/types/src/publish.d.ts new file mode 100644 index 000000000..83d21d90b --- /dev/null +++ b/packages/types/src/publish.d.ts @@ -0,0 +1,44 @@ +import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types"; + +export type TPublishEntityType = "project" | "page"; + +export type TProjectPublishLayouts = + | "calendar" + | "gantt" + | "kanban" + | "list" + | "spreadsheet"; + +export type TProjectPublishViewProps = { + calendar?: boolean; + gantt?: boolean; + kanban?: boolean; + list?: boolean; + spreadsheet?: boolean; +}; + +export type TProjectDetails = IProjectLite & + Pick; + +type TPublishSettings = { + anchor: string | undefined; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + is_comments_enabled: boolean; + is_reactions_enabled: boolean; + is_votes_enabled: boolean; + project: string | undefined; + project_details: TProjectDetails | undefined; + updated_at: string | undefined; + updated_by: string | undefined; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; +}; + +export type TProjectPublishSettings = TPublishSettings & { + view_props: TProjectPublishViewProps | undefined; +}; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index c191cac89..99ba7a4a8 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -5,7 +5,7 @@ import { TStateGroups, } from "."; -type TLoginMediums = "email" | "magic-code" | "github" | "google"; +type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google"; export interface IUser { id: string; @@ -128,6 +128,7 @@ export interface IUserActivityResponse { prev_page_results: boolean; results: IIssueActivity[]; total_pages: number; + total_results: number; } export type UserAuth = { @@ -185,6 +186,8 @@ export interface IUserEmailNotificationSettings { issue_completed: boolean; } +export type TProfileViews = "assigned" | "created" | "subscribed"; + // export interface ICurrentUser { // id: readonly string; // avatar: string; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index c2c98def3..82302dda1 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -1,3 +1,5 @@ +import { EIssueLayoutTypes } from "constants/issue"; + export type TIssueLayouts = | "list" | "kanban" @@ -13,9 +15,9 @@ export type TIssueGroupByOptions = | "state_detail.group" | "project" | "assignees" - | "mentions" | "cycle" | "module" + | "target_date" | null; export type TIssueOrderByOptions = @@ -32,10 +34,10 @@ export type TIssueOrderByOptions = | "-assignees__first_name" | "labels__name" | "-labels__name" - | "modules__name" - | "-modules__name" - | "cycle__name" - | "-cycle__name" + | "issue_module__module__name" + | "-issue_module__module__name" + | "issue_cycle__cycle__name" + | "-issue_cycle__cycle__name" | "target_date" | "-target_date" | "estimate_point" @@ -72,7 +74,9 @@ export type TIssueParams = | "order_by" | "type" | "sub_issue" - | "show_empty_groups"; + | "show_empty_groups" + | "cursor" + | "per_page"; export type TCalendarLayouts = "month" | "week"; @@ -82,9 +86,9 @@ export interface IIssueFilterOptions { created_by?: string[] | null; labels?: string[] | null; priority?: string[] | null; - project?: string[] | null; cycle?: string[] | null; module?: string[] | null; + project?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; @@ -99,7 +103,7 @@ export interface IIssueDisplayFilterOptions { }; group_by?: TIssueGroupByOptions; sub_group_by?: TIssueGroupByOptions; - layout?: TIssueLayouts; + layout?: EIssueLayoutTypes; order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; @@ -191,3 +195,11 @@ export interface IWorkspaceGlobalViewProps { display_filters: IWorkspaceIssueDisplayFilterOptions | undefined; display_properties: IIssueDisplayProperties; } + +export interface IssuePaginationOptions { + canGroup: boolean; + perPageCount: number; + before?: string; + after?: string; + groupedBy?: TIssueGroupByOptions; +} diff --git a/packages/types/src/views.d.ts b/packages/types/src/views.d.ts index 9415f7488..1c61ab69c 100644 --- a/packages/types/src/views.d.ts +++ b/packages/types/src/views.d.ts @@ -1,3 +1,4 @@ +import { EViewAccess } from "@/constants/views"; import { TLogoProps } from "./common"; import { IIssueDisplayFilterOptions, @@ -7,7 +8,7 @@ import { export interface IProjectView { id: string; - access: string; + access: EViewAccess; created_at: Date; updated_at: Date; is_favorite: boolean; @@ -23,4 +24,24 @@ export interface IProjectView { project: string; workspace: string; logo_props: TLogoProps | undefined; + is_locked: boolean; + owned_by: string; } + +export type TViewFiltersSortKey = "name" | "created_at" | "updated_at"; + +export type TViewFiltersSortBy = "asc" | "desc"; + +export type TViewFilterProps = { + created_at?: string[] | null; + owned_by?: string[] | null; + favorites?: boolean; + view_type?: EViewAccess[]; +}; + +export type TViewFilters = { + searchQuery: string; + sortKey: TViewFiltersSortKey; + sortBy: TViewFiltersSortBy; + filters?: TViewFilterProps; +}; diff --git a/packages/types/src/workspace-notifications.d.ts b/packages/types/src/workspace-notifications.d.ts new file mode 100644 index 000000000..0e5bb0975 --- /dev/null +++ b/packages/types/src/workspace-notifications.d.ts @@ -0,0 +1,97 @@ +import type { IUserLite } from "./users"; +import { ENotificationFilterType } from "./enums"; + +// filters +export type TNotificationFilter = { + type: { + [key in ENotificationFilterType]: boolean; + }; + snoozed: boolean; + archived: boolean; + read: boolean; +}; + +// notification payload +export type TNotificationIssueLite = { + id: string | undefined; + sequence_id: number | undefined; + identifier: string | undefined; + name: string | undefined; + state_name: string | undefined; + state_group: string | undefined; +}; + +export type TNotificationData = { + issue: TNotificationIssueLite | undefined; + issue_activity: { + id: string | undefined; + actor: string | undefined; + field: string | undefined; + issue_comment: string | undefined; + verb: "created" | "updated"; + new_value: string | undefined; + old_value: string | undefined; + }; +}; + +export type TNotification = { + id: string | undefined; + title: string | undefined; + data: TNotificationData | undefined; + entity_identifier: string | undefined; + entity_name: string | undefined; + message_html: string | undefined; + message: undefined; + message_stripped: undefined; + sender: string | undefined; + receiver: string | undefined; + triggered_by: string | undefined; + triggered_by_details: IUserLite | undefined; + read_at: string | undefined; + archived_at: string | undefined; + snoozed_till: string | undefined; + is_inbox_issue: boolean | undefined; + workspace: string | undefined; + project: string | undefined; + created_at: string | undefined; + updated_at: string | undefined; + created_by: string | undefined; + updated_by: string | undefined; +}; + +// notification paginated information +export type TNotificationPaginatedInfoQueryParams = { + type?: string | undefined; + snoozed?: boolean; + archived?: boolean; + read?: boolean; + per_page?: number; + cursor?: string; +}; + +export type TNotificationPaginatedInfo = { + next_cursor: string | undefined; + prev_cursor: string | undefined; + next_page_results: boolean | undefined; + prev_page_results: boolean | undefined; + total_pages: number | undefined; + extra_stats: string | undefined; + count: number | undefined; // current paginated results count + total_count: number | undefined; // total available results count + results: TNotification[] | undefined; + grouped_by: string | undefined; + sub_grouped_by: string | undefined; +}; + +// notification count +export type TUnreadNotificationsCount = { + total_unread_notifications_count: number; +}; + +export type TCurrentSelectedNotification = { + workspace_slug: string | undefined; + project_id: string | undefined; + notification_id: string | undefined; + issue_id: string | undefined; + is_inbox_issue: boolean | undefined; +}; diff --git a/packages/types/src/workspace-views.d.ts b/packages/types/src/workspace-views.d.ts index e270f4f69..5bc900767 100644 --- a/packages/types/src/workspace-views.d.ts +++ b/packages/types/src/workspace-views.d.ts @@ -1,3 +1,4 @@ +import { EViewAccess } from "@/constants/views"; import { IWorkspaceViewProps, IIssueDisplayFilterOptions, @@ -7,7 +8,7 @@ import { export interface IWorkspaceView { id: string; - access: string; + access: EViewAccess; created_at: Date; updated_at: Date; is_favorite: boolean; @@ -22,6 +23,8 @@ export interface IWorkspaceView { query_data: IWorkspaceViewProps; project: string; workspace: string; + is_locked: boolean; + owned_by: string; workspace_detail?: { id: string; name: string; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index ceaa53d02..4e40009e1 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -112,6 +112,14 @@ export interface IWorkspaceIssueSearchResult { workspace__slug: string; } +export interface IWorkspacePageSearchResult { + id: string; + name: string; + project_ids: string[]; + project__identifiers: string[]; + workspace__slug: string; +} + export interface IWorkspaceProjectSearchResult { id: string; identifier: string; @@ -127,7 +135,7 @@ export interface IWorkspaceSearchResults { cycle: IWorkspaceDefaultSearchResult[]; module: IWorkspaceDefaultSearchResult[]; issue_view: IWorkspaceDefaultSearchResult[]; - page: IWorkspaceDefaultSearchResult[]; + page: IWorkspacePageSearchResult[]; }; } diff --git a/packages/ui/package.json b/packages/ui/package.json index 5444b673e..3f3ee777b 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.21.0", + "version": "0.22.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -20,18 +20,22 @@ "postcss": "postcss styles/globals.css -o styles/output.css --watch" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.10", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@blueprintjs/core": "^4.16.3", "@blueprintjs/popover2": "^1.13.3", - "@headlessui/react": "^1.7.17", + "@headlessui/react": "^1.7.3", "@popperjs/core": "^2.11.8", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", + "lodash": "^4.17.21", "lucide-react": "^0.379.0", "react-color": "^2.19.3", "react-dom": "^18.2.0", "react-popper": "^2.3.0", - "sonner": "^1.4.2", - "tailwind-merge": "^2.0.0" + "sonner": "^1.4.41", + "tailwind-merge": "^2.0.0", + "use-font-face-observer": "^1.2.2" }, "devDependencies": { "@chromatic-com/storybook": "^1.4.0", diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 253e8308e..031825691 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -4,9 +4,10 @@ import { ChevronRight } from "lucide-react"; type BreadcrumbsProps = { children: React.ReactNode; onBack?: () => void; + isLoading?: boolean; }; -const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => { +const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) => { const [isSmallScreen, setIsSmallScreen] = React.useState(false); React.useEffect(() => { @@ -21,6 +22,13 @@ const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => { const childrenArray = React.Children.toArray(children); + const BreadcrumbItemLoader = ( +
+ + +
+ ); + return (
{!isSmallScreen && ( @@ -33,7 +41,7 @@ const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => {
)}
0 ? "hidden sm:flex" : "flex"}`}> - {child} + {isLoading ? BreadcrumbItemLoader : child}
))} @@ -50,7 +58,9 @@ const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => { )}
-
{childrenArray[childrenArray.length - 1]}
+
+ {isLoading ? BreadcrumbItemLoader : childrenArray[childrenArray.length - 1]} +
)} {isSmallScreen && childrenArray.length === 1 && childrenArray} diff --git a/packages/ui/src/collapsible/collapsible.tsx b/packages/ui/src/collapsible/collapsible.tsx new file mode 100644 index 000000000..6c08563f0 --- /dev/null +++ b/packages/ui/src/collapsible/collapsible.tsx @@ -0,0 +1,52 @@ +import React, { FC, useState, useEffect, useCallback } from "react"; +import { Disclosure, Transition } from "@headlessui/react"; + +export type TCollapsibleProps = { + title: string | React.ReactNode; + children: React.ReactNode; + buttonClassName?: string; + isOpen?: boolean; + onToggle?: () => void; + defaultOpen?: boolean; +}; + +export const Collapsible: FC = (props) => { + const { title, children, buttonClassName, isOpen, onToggle, defaultOpen } = props; + // state + const [localIsOpen, setLocalIsOpen] = useState(isOpen || defaultOpen ? true : false); + + useEffect(() => { + if (isOpen !== undefined) { + setLocalIsOpen(isOpen); + } + }, [isOpen]); + + // handlers + const handleOnClick = useCallback(() => { + if (isOpen !== undefined) { + if (onToggle) onToggle(); + } else { + setLocalIsOpen((prev) => !prev); + } + }, [isOpen, onToggle]); + + return ( + + + {title} + + + {children} + + + ); +}; diff --git a/packages/ui/src/collapsible/index.ts b/packages/ui/src/collapsible/index.ts new file mode 100644 index 000000000..dbd926237 --- /dev/null +++ b/packages/ui/src/collapsible/index.ts @@ -0,0 +1 @@ +export * from "./collapsible"; diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index df1958476..83c719ca1 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes & { target?: string; disabled?: boolean; className?: string; + draggable?: boolean; }; export const ControlLink = React.forwardRef((props, ref) => { - const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props; + const { href, onClick, children, target = "_blank", disabled = false, className, draggable = false, ...rest } = props; const LEFT_CLICK_EVENT_CODE = 0; const handleOnClick = (event: React.MouseEvent) => { @@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef((pr if (disabled) return <>{children}; return ( -
+ {children} ); diff --git a/packages/ui/src/dropdown/Readme.md b/packages/ui/src/dropdown/Readme.md new file mode 100644 index 000000000..314347b1e --- /dev/null +++ b/packages/ui/src/dropdown/Readme.md @@ -0,0 +1,44 @@ +Below is a detailed list of the props included: + +### Root Props +- value: string | string[]; - Current selected value. +- onChange: (value: string | string []) => void; - Callback function for handling value changes. +- options: TDropdownOption[] | undefined; - Array of options. +- onOpen?: () => void; - Callback function triggered when the dropdown opens. +- onClose?: () => void; - Callback function triggered when the dropdown closes. +- containerClassName?: (isOpen: boolean) => string; - Function to return the class name for the container based on the open state. +- tabIndex?: number; - Sets the tab index for the dropdown. +- placement?: Placement; - Determines the placement of the dropdown (e.g., top, bottom, left, right). +- disabled?: boolean; - Disables the dropdown if set to true. + +--- + +### Button Props +- buttonContent?: (isOpen: boolean) => React.ReactNode; - Function to render the content of the button based on the open state. +- buttonContainerClassName?: string; - Class name for the button container. +- buttonClassName?: string; - Class name for the button itself. + +--- + +### Input Props +- disableSearch?: boolean; - Disables the search input if set to true. +- inputPlaceholder?: string; - Placeholder text for the search input. +- inputClassName?: string; - Class name for the search input. +- inputIcon?: React.ReactNode; - Icon to be displayed in the search input. +- inputContainerClassName?: string; - Class name for the search input container. + +--- + +### Options Props +- keyExtractor: (option: TDropdownOption) => string; - Function to extract the key from each option. +- optionsContainerClassName?: string; - Class name for the options container. +- queryArray: string[]; - Array of strings to be used for querying the options. +- sortByKey: string; - Key to sort the options by. +- firstItem?: (optionValue: string) => boolean; - Function to determine if an option should be the first item. +- renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; - Function to render each option. +- loader?: React.ReactNode; - Loader element to be displayed while options are being loaded. +- disableSorting?: boolean; - Disables sorting of the options if set to true. + +--- + +These properties offer extensive control over the dropdown's behavior and presentation, making it a highly versatile component suitable for various scenarios. \ No newline at end of file diff --git a/packages/ui/src/dropdown/common/button.tsx b/packages/ui/src/dropdown/common/button.tsx new file mode 100644 index 000000000..39d9cd538 --- /dev/null +++ b/packages/ui/src/dropdown/common/button.tsx @@ -0,0 +1,38 @@ +import React, { Fragment } from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// helper +import { cn } from "../../../helpers"; +import { IMultiSelectDropdownButton, ISingleSelectDropdownButton } from "../dropdown"; + +export const DropdownButton: React.FC = (props) => { + const { + isOpen, + buttonContent, + buttonClassName, + buttonContainerClassName, + handleOnClick, + value, + setReferenceElement, + disabled, + } = props; + return ( + + + + ); +}; diff --git a/packages/ui/src/dropdown/common/index.ts b/packages/ui/src/dropdown/common/index.ts new file mode 100644 index 000000000..f9a6d7388 --- /dev/null +++ b/packages/ui/src/dropdown/common/index.ts @@ -0,0 +1,4 @@ +export * from "./input-search"; +export * from "./button"; +export * from "./options"; +export * from "./loader"; diff --git a/packages/ui/src/dropdown/common/input-search.tsx b/packages/ui/src/dropdown/common/input-search.tsx new file mode 100644 index 000000000..10fc258e1 --- /dev/null +++ b/packages/ui/src/dropdown/common/input-search.tsx @@ -0,0 +1,58 @@ +import React, { FC, useEffect, useRef } from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { Search } from "lucide-react"; +// helpers +import { cn } from "../../../helpers"; + +interface IInputSearch { + isOpen: boolean; + query: string; + updateQuery: (query: string) => void; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + inputClassName?: string; + inputPlaceholder?: string; +} + +export const InputSearch: FC = (props) => { + const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props; + + const inputRef = useRef(null); + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + updateQuery(""); + } + }; + + useEffect(() => { + if (isOpen) { + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + return ( +
+ {inputIcon ? <>{inputIcon} :
+ ); +}; diff --git a/packages/ui/src/dropdown/common/loader.tsx b/packages/ui/src/dropdown/common/loader.tsx new file mode 100644 index 000000000..0ec1f053b --- /dev/null +++ b/packages/ui/src/dropdown/common/loader.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export const DropdownOptionsLoader = () => ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+); diff --git a/packages/ui/src/dropdown/common/options.tsx b/packages/ui/src/dropdown/common/options.tsx new file mode 100644 index 000000000..5448f7b7f --- /dev/null +++ b/packages/ui/src/dropdown/common/options.tsx @@ -0,0 +1,90 @@ +import React from "react"; +// headless ui +import { Combobox } from "@headlessui/react"; +// icons +import { Check } from "lucide-react"; +// components +import { DropdownOptionsLoader, InputSearch } from "."; +// helpers +import { cn } from "../../../helpers"; +// types +import { IMultiSelectDropdownOptions, ISingleSelectDropdownOptions } from "../dropdown"; + +export const DropdownOptions: React.FC = (props) => { + const { + isOpen, + query, + setQuery, + inputIcon, + inputPlaceholder, + inputClassName, + inputContainerClassName, + disableSearch, + keyExtractor, + options, + handleClose, + value, + renderItem, + loader, + } = props; + return ( + <> + {!disableSearch && ( + setQuery(query)} + inputIcon={inputIcon} + inputPlaceholder={inputPlaceholder} + inputClassName={inputClassName} + inputContainerClassName={inputContainerClassName} + /> + )} +
+ <> + {options ? ( + options.length > 0 ? ( + options?.map((option) => ( + + cn( + "flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", + { + "bg-custom-background-80": active, + "text-custom-text-100": selected, + "text-custom-text-200": !selected, + }, + option.className && option.className({ active, selected }) + ) + } + onClick={handleClose} + > + {({ selected }) => ( + <> + {renderItem ? ( + <>{renderItem({ value: keyExtractor(option), selected })} + ) : ( + <> + {option.value} + {selected && } + + )} + + )} + + )) + ) : ( +

No matching results

+ ) + ) : loader ? ( + <> {loader} + ) : ( + + )} + +
+ + ); +}; diff --git a/packages/ui/src/dropdown/dropdown.d.ts b/packages/ui/src/dropdown/dropdown.d.ts new file mode 100644 index 000000000..8264bda21 --- /dev/null +++ b/packages/ui/src/dropdown/dropdown.d.ts @@ -0,0 +1,96 @@ +import { Placement } from "@popperjs/core"; + +export interface IDropdown { + // root props + onOpen?: () => void; + onClose?: () => void; + containerClassName?: (isOpen: boolean) => string; + tabIndex?: number; + placement?: Placement; + disabled?: boolean; + + // button props + buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode; + buttonContainerClassName?: string; + buttonClassName?: string; + + // input props + disableSearch?: boolean; + inputPlaceholder?: string; + inputClassName?: string; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + + // options props + keyExtractor: (option: TDropdownOption) => string; + optionsContainerClassName?: string; + queryArray?: string[]; + sortByKey?: string; + firstItem?: (optionValue: string) => boolean; + renderItem?: ({ value, selected }: { value: string; selected: boolean }) => React.ReactNode; + loader?: React.ReactNode; + disableSorting?: boolean; +} + +export interface TDropdownOption { + data: any; + value: string; + className?: ({ active, selected }: { active: boolean; selected: boolean }) => string; +} + +export interface IMultiSelectDropdown extends IDropdown { + value: string[]; + onChange: (value: string[]) => void; + options: TDropdownOption[] | undefined; +} + +export interface ISingleSelectDropdown extends IDropdown { + value: string; + onChange: (value: string) => void; + options: TDropdownOption[] | undefined; +} + +export interface IDropdownButton { + isOpen: boolean; + buttonContent?: (isOpen: boolean, value: string | string[] | undefined) => React.ReactNode; + buttonClassName?: string; + buttonContainerClassName?: string; + handleOnClick: (e: React.MouseEvent) => void; + setReferenceElement: (element: HTMLButtonElement | null) => void; + disabled?: boolean; +} + +export interface IMultiSelectDropdownButton extends IDropdownButton { + value: string[]; +} + +export interface ISingleSelectDropdownButton extends IDropdownButton { + value: string; +} + +export interface IDropdownOptions { + isOpen: boolean; + query: string; + setQuery: (query: string) => void; + + inputPlaceholder?: string; + inputClassName?: string; + inputIcon?: React.ReactNode; + inputContainerClassName?: string; + disableSearch?: boolean; + + handleClose?: () => void; + + keyExtractor: (option: TDropdownOption) => string; + renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined; + options: TDropdownOption[] | undefined; + loader?: React.ReactNode; +} + +export interface IMultiSelectDropdownOptions extends IDropdownOptions { + value: string[]; +} + +export interface ISingleSelectDropdownOptions extends IDropdownOptions { + value: string; +} diff --git a/packages/ui/src/dropdown/index.ts b/packages/ui/src/dropdown/index.ts new file mode 100644 index 000000000..a15df9567 --- /dev/null +++ b/packages/ui/src/dropdown/index.ts @@ -0,0 +1,3 @@ +export * from "./common"; +export * from "./multi-select"; +export * from "./single-select"; diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx new file mode 100644 index 000000000..3b5135117 --- /dev/null +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -0,0 +1,169 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import sortBy from "lodash/sortBy"; +// headless ui +import { Combobox } from "@headlessui/react"; +// popper-js +import { usePopper } from "react-popper"; +// components +import { DropdownButton } from "./common"; +import { DropdownOptions } from "./common/options"; +// hooks +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helper +import { cn } from "../../helpers"; +// types +import { IMultiSelectDropdown } from "./dropdown"; + +export const MultiSelectDropdown: FC = (props) => { + const { + value, + onChange, + options, + onOpen, + onClose, + containerClassName, + tabIndex, + placement, + disabled, + buttonContent, + buttonContainerClassName, + buttonClassName, + disableSearch, + inputPlaceholder, + inputClassName, + inputIcon, + inputContainerClassName, + keyExtractor, + optionsContainerClassName, + queryArray, + sortByKey, + firstItem, + renderItem, + loader = false, + disableSorting, + } = props; + + // states + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + // handlers + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // options + const sortedOptions = useMemo(() => { + if (!options) return undefined; + + const filteredOptions = queryArray + ? (options || []).filter((options) => { + const queryString = queryArray.map((query) => options.data[query]).join(" "); + return queryString.toLowerCase().includes(query.toLowerCase()); + }) + : options; + + if (disableSorting) return filteredOptions; + + return sortBy(filteredOptions, [ + (option) => firstItem && firstItem(option.data[option.value]), + (option) => !(value ?? []).includes(option.data[option.value]), + () => sortByKey && sortByKey.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, options]); + + // hooks + const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx new file mode 100644 index 000000000..06987e10f --- /dev/null +++ b/packages/ui/src/dropdown/single-select.tsx @@ -0,0 +1,169 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import sortBy from "lodash/sortBy"; +// headless ui +import { Combobox } from "@headlessui/react"; +// popper-js +import { usePopper } from "react-popper"; +// components +import { DropdownButton } from "./common"; +import { DropdownOptions } from "./common/options"; +// hooks +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; +import useOutsideClickDetector from "../hooks/use-outside-click-detector"; +// helper +import { cn } from "../../helpers"; +// types +import { ISingleSelectDropdown } from "./dropdown"; + +export const Dropdown: FC = (props) => { + const { + value, + onChange, + options, + onOpen, + onClose, + containerClassName, + tabIndex, + placement, + disabled, + buttonContent, + buttonContainerClassName, + buttonClassName, + disableSearch, + inputPlaceholder, + inputClassName, + inputIcon, + inputContainerClassName, + keyExtractor, + optionsContainerClassName, + queryArray, + sortByKey, + firstItem, + renderItem, + loader = false, + disableSorting, + } = props; + + // states + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + // handlers + const toggleDropdown = () => { + if (!isOpen) onOpen?.(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose?.(); + }; + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose?.(); + setQuery?.(""); + }; + + // options + const sortedOptions = useMemo(() => { + if (!options) return undefined; + + const filteredOptions = queryArray + ? (options || []).filter((options) => { + const queryString = queryArray.map((query) => options.data[query]).join(" "); + return queryString.toLowerCase().includes(query.toLowerCase()); + }) + : options; + + if (disableSorting || !sortByKey) return filteredOptions; + + return sortBy(filteredOptions, [ + (option) => firstItem && firstItem(option.data[option.value]), + (option) => !(value ?? []).includes(option.data[option.value]), + () => sortByKey && sortByKey.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, options]); + + // hooks + const handleKeyDown = useDropdownKeyPressed(toggleDropdown, handleClose); + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + + {isOpen && ( + +
+ +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 316cc6960..9df40f1a8 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -34,6 +34,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { onMenuClose, tabIndex, closeOnSelect, + openOnHover = false, } = props; const [referenceElement, setReferenceElement] = React.useState(null); @@ -68,12 +69,24 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (closeOnSelect) closeDropdown(); }; - const handleMenuButtonClick = (e:React.MouseEvent)=>{ + const handleMenuButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); - e.preventDefault() + e.preventDefault(); isOpen ? closeDropdown() : openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); - } + }; + + const handleMouseEnter = () => { + if (openOnHover) openDropdown(); + }; + + const handleMouseLeave = () => { + if (openOnHover && isOpen) { + setTimeout(() => { + closeDropdown(); + }, 500); + } + }; useOutsideClickDetector(dropdownRef, closeDropdown); @@ -111,6 +124,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { className={cn("relative w-min text-left", className)} onKeyDownCapture={handleKeyDown} onClick={handleOnClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} > {({ open }) => ( <> @@ -203,4 +218,4 @@ const MenuItem: React.FC = (props) => { CustomMenu.MenuItem = MenuItem; -export { CustomMenu }; +export { CustomMenu }; \ No newline at end of file diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 93ac63b97..0eda5ac5c 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -28,6 +28,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { onMenuClose?: () => void; closeOnSelect?: boolean; portalElement?: Element | null; + openOnHover?: boolean; } export interface ICustomSelectProps extends IDropdownProps { diff --git a/packages/ui/src/emoji/emoji-icon-picker-new.tsx b/packages/ui/src/emoji/emoji-icon-picker-new.tsx index 557b39658..a8651b349 100644 --- a/packages/ui/src/emoji/emoji-icon-picker-new.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker-new.tsx @@ -112,7 +112,7 @@ export const EmojiIconPicker: React.FC = (props) => { }} /> - + { diff --git a/packages/ui/src/emoji/emoji-icon-picker.tsx b/packages/ui/src/emoji/emoji-icon-picker.tsx index c531dd168..ea3fcf002 100644 --- a/packages/ui/src/emoji/emoji-icon-picker.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker.tsx @@ -112,7 +112,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { }} /> - + { diff --git a/packages/ui/src/emoji/helpers.ts b/packages/ui/src/emoji/helpers.ts new file mode 100644 index 000000000..faa8ebdd9 --- /dev/null +++ b/packages/ui/src/emoji/helpers.ts @@ -0,0 +1,11 @@ +export const emojiCodeToUnicode = (emoji: string) => { + if (!emoji) return ""; + + // convert emoji code to unicode + const uniCodeEmoji = emoji + .split("-") + .map((emoji) => parseInt(emoji, 10).toString(16)) + .join("-"); + + return uniCodeEmoji; +}; diff --git a/packages/ui/src/emoji/icons-list.tsx b/packages/ui/src/emoji/icons-list.tsx index 0352e1ec8..e0bb87b37 100644 --- a/packages/ui/src/emoji/icons-list.tsx +++ b/packages/ui/src/emoji/icons-list.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useState } from "react"; +// icons +import { Search } from "lucide-react"; +import { MATERIAL_ICONS_LIST } from "./icons"; +import { InfoIcon } from "../icons"; // components import { Input } from "../form-fields"; +// hooks +import useFontFaceObserver from "use-font-face-observer"; // helpers import { cn } from "../../helpers"; import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper"; -// icons -import { MATERIAL_ICONS_LIST } from "./icons"; -import { InfoIcon } from "../icons"; -import { Search } from "lucide-react"; export const IconsList: React.FC = (props) => { const { defaultColor, onChange } = props; @@ -28,82 +30,93 @@ export const IconsList: React.FC = (props) => { const filteredArray = MATERIAL_ICONS_LIST.filter((icon) => icon.name.toLowerCase().includes(query.toLowerCase())); + const isMaterialSymbolsFontLoaded = useFontFaceObserver([ + { + family: `Material Symbols Rounded`, + style: `normal`, + weight: `normal`, + stretch: `condensed`, + }, + ]); + return ( <> -
-
setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - > - - setQuery(e.target.value)} - className="text-[1rem] border-none p-0 h-full w-full " - /> -
-
-
- {showHexInput ? ( -
- - HEX - # +
+
+
setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + > + { - const value = e.target.value; - setHexValue(value); - if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`)); - }} - className="flex-grow pl-0 text-xs text-custom-text-200" - mode="true-transparent" - autoFocus + placeholder="Search" + value={query} + onChange={(e) => setQuery(e.target.value)} + className="text-[1rem] border-none p-0 h-full w-full " />
- ) : ( - DEFAULT_COLORS.map((curCol) => ( - - )) - )} -
+
{showHexInput ? ( - +
+ + HEX + # + { + const value = e.target.value; + setHexValue(value); + if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`)); + }} + className="flex-grow pl-0 text-xs text-custom-text-200" + mode="true-transparent" + autoFocus + /> +
) : ( - # + DEFAULT_COLORS.map((curCol) => ( + + )) )} - -
-
- -

Colors will be adjusted to ensure sufficient contrast.

+ +
+
+ +

Colors will be adjusted to ensure sufficient contrast.

+
{filteredArray.map((icon) => ( @@ -118,12 +131,16 @@ export const IconsList: React.FC = (props) => { }); }} > - - {icon.name} - + {isMaterialSymbolsFontLoaded ? ( + + {icon.name} + + ) : ( + + )} ))}
diff --git a/packages/ui/src/emoji/index.ts b/packages/ui/src/emoji/index.ts index 128b80292..c881d8897 100644 --- a/packages/ui/src/emoji/index.ts +++ b/packages/ui/src/emoji/index.ts @@ -2,3 +2,4 @@ export * from "./emoji-icon-picker-new"; export * from "./emoji-icon-picker"; export * from "./emoji-icon-helper"; export * from "./icons"; +export * from "./logo"; diff --git a/packages/ui/src/emoji/logo.tsx b/packages/ui/src/emoji/logo.tsx new file mode 100644 index 000000000..528e16047 --- /dev/null +++ b/packages/ui/src/emoji/logo.tsx @@ -0,0 +1,100 @@ +import React, { FC } from "react"; +import { Emoji } from "emoji-picker-react"; +import useFontFaceObserver from "use-font-face-observer"; +// icons +import { LUCIDE_ICONS_LIST } from "./icons"; +// helpers +import { emojiCodeToUnicode } from "./helpers"; + +type TLogoProps = { + in_use: "emoji" | "icon"; + emoji?: { + value?: string; + url?: string; + }; + icon?: { + name?: string; + color?: string; + }; +}; + +type Props = { + logo: TLogoProps; + size?: number; + type?: "lucide" | "material"; +}; + +export const Logo: FC = (props) => { + const { logo, size = 16, type = "material" } = props; + + // destructuring the logo object + const { in_use, emoji, icon } = logo; + + // derived values + const value = in_use === "emoji" ? emoji?.value : icon?.name; + const color = icon?.color; + const lucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === value); + + const isMaterialSymbolsFontLoaded = useFontFaceObserver([ + { + family: `Material Symbols Rounded`, + style: `normal`, + weight: `normal`, + stretch: `condensed`, + }, + ]); + // if no value, return empty fragment + if (!value) return <>; + + if (!isMaterialSymbolsFontLoaded) { + return ( + + ); + } + + // emoji + if (in_use === "emoji") { + return ; + } + + // icon + if (in_use === "icon") { + return ( + <> + {type === "lucide" ? ( + <> + {lucideIcon && ( + + )} + + ) : ( + + {value} + + )} + + ); + } + + // if no value, return empty fragment + return <>; +}; diff --git a/packages/ui/src/emoji/lucide-icons-list.tsx b/packages/ui/src/emoji/lucide-icons-list.tsx index 799f0919d..5ffda34e6 100644 --- a/packages/ui/src/emoji/lucide-icons-list.tsx +++ b/packages/ui/src/emoji/lucide-icons-list.tsx @@ -31,80 +31,82 @@ export const LucideIconsList: React.FC = (props) => { return ( <> -
-
setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - > - - setQuery(e.target.value)} - className="text-[1rem] border-none p-0 h-full w-full " - /> -
-
-
- {showHexInput ? ( -
- - HEX - # +
+
+
setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + > + { - const value = e.target.value; - setHexValue(value); - if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`)); - }} - className="flex-grow pl-0 text-xs text-custom-text-200" - mode="true-transparent" - autoFocus + placeholder="Search" + value={query} + onChange={(e) => setQuery(e.target.value)} + className="text-[1rem] border-none p-0 h-full w-full " />
- ) : ( - DEFAULT_COLORS.map((curCol) => ( - - )) - )} -
+
{showHexInput ? ( - +
+ + HEX + # + { + const value = e.target.value; + setHexValue(value); + if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(adjustColorForContrast(`#${value}`)); + }} + className="flex-grow pl-0 text-xs text-custom-text-200" + mode="true-transparent" + autoFocus + /> +
) : ( - # + DEFAULT_COLORS.map((curCol) => ( + + )) )} - -
-
- -

Colors will be adjusted to ensure sufficient contrast.

+ +
+
+ +

Colors will be adjusted to ensure sufficient contrast.

+
{filteredArray.map((icon) => ( diff --git a/web/components/core/favorite-star.tsx b/packages/ui/src/favorite-star.tsx similarity index 92% rename from web/components/core/favorite-star.tsx rename to packages/ui/src/favorite-star.tsx index 40e8ddf08..b71714ab0 100644 --- a/web/components/core/favorite-star.tsx +++ b/packages/ui/src/favorite-star.tsx @@ -1,6 +1,7 @@ +import React from "react"; import { Star } from "lucide-react"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "../helpers"; type Props = { buttonClassName?: string; diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts new file mode 100644 index 000000000..cc1450686 --- /dev/null +++ b/packages/ui/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-page-title"; diff --git a/packages/ui/src/hooks/use-dropdown-key-pressed.ts b/packages/ui/src/hooks/use-dropdown-key-pressed.ts new file mode 100644 index 000000000..15552d34d --- /dev/null +++ b/packages/ui/src/hooks/use-dropdown-key-pressed.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; + +type TUseDropdownKeyPressed = { + ( + onEnterKeyDown: () => void, + onEscKeyDown: () => void, + stopPropagation?: boolean + ): (event: React.KeyboardEvent) => void; +}; + +export const useDropdownKeyPressed: TUseDropdownKeyPressed = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => { + const stopEventPropagation = useCallback( + (event: React.KeyboardEvent) => { + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + }, + [stopPropagation] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + stopEventPropagation(event); + onEnterKeyDown(); + } else if (event.key === "Escape") { + stopEventPropagation(event); + onEscKeyDown(); + } else if (event.key === "Tab") onEscKeyDown(); + }, + [onEnterKeyDown, onEscKeyDown, stopEventPropagation] + ); + + return handleKeyDown; +}; diff --git a/packages/ui/src/hooks/use-page-title.tsx b/packages/ui/src/hooks/use-page-title.tsx new file mode 100644 index 000000000..79fb6857e --- /dev/null +++ b/packages/ui/src/hooks/use-page-title.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +interface IUseHeadParams { + title?: string; +} + +export const useHead = ({ title }: IUseHeadParams) => { + useEffect(() => { + if (title) { + document.title = title ?? "Plane | Simple, extensible, open-source project management tool."; + } + }, [title]); +}; diff --git a/packages/ui/src/icons/dropdown-icon.tsx b/packages/ui/src/icons/dropdown-icon.tsx new file mode 100644 index 000000000..ed375d117 --- /dev/null +++ b/packages/ui/src/icons/dropdown-icon.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const DropdownIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + +); diff --git a/packages/ui/src/icons/gitlab-icon.tsx b/packages/ui/src/icons/gitlab-icon.tsx new file mode 100644 index 000000000..958a68641 --- /dev/null +++ b/packages/ui/src/icons/gitlab-icon.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const GitlabIcon: React.FC = ({ width = "24", height = "24", className, color }) => ( + + + + + + + + + + +); diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index c51375282..3d44e77f3 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -12,6 +12,7 @@ export * from "./dice-icon"; export * from "./discord-icon"; export * from "./full-screen-panel-icon"; export * from "./github-icon"; +export * from "./gitlab-icon"; export * from "./layer-stack"; export * from "./layers-icon"; export * from "./photo-filter-icon"; @@ -20,3 +21,5 @@ export * from "./related-icon"; export * from "./side-panel-icon"; export * from "./transfer-icon"; export * from "./info-icon"; +export * from "./relations-icon"; +export * from "./dropdown-icon"; diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index 031b769f1..ffa74a374 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -7,7 +7,7 @@ type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; interface IPriorityIcon { className?: string; containerClassName?: string; - priority: TIssuePriorities; + priority: TIssuePriorities | undefined | null; size?: number; withContainer?: boolean; } @@ -31,7 +31,7 @@ export const PriorityIcon: React.FC = (props) => { low: SignalLow, none: Ban, }; - const Icon = icons[priority]; + const Icon = icons[priority ?? "none"]; if (!Icon) return null; @@ -41,7 +41,7 @@ export const PriorityIcon: React.FC = (props) => {
diff --git a/packages/ui/src/icons/relations-icon.tsx b/packages/ui/src/icons/relations-icon.tsx new file mode 100644 index 000000000..0f17da6dd --- /dev/null +++ b/packages/ui/src/icons/relations-icon.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const RelationsIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + + + + + + +); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dae012381..59e7d609f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,16 +1,23 @@ export * from "./avatar"; -export * from "./breadcrumbs"; export * from "./badge"; +export * from "./breadcrumbs"; export * from "./button"; -export * from "./emoji"; -export * from "./dropdowns"; -export * from "./form-fields"; -export * from "./icons"; -export * from "./progress"; -export * from "./spinners"; -export * from "./tooltip"; -export * from "./loader"; export * from "./control-link"; +export * from "./dropdown"; +export * from "./dropdowns"; +export * from "./emoji"; +export * from "./form-fields"; +export * from "./hooks"; +export * from "./icons"; +export * from "./modals"; +export * from "./progress"; +export * from "./sortable"; +export * from "./spinners"; export * from "./toast"; +export * from "./tooltip"; +export * from "./typography"; export * from "./drag-handle"; -export * from "./drop-indicator"; \ No newline at end of file +export * from "./drop-indicator"; +export * from "./favorite-star"; +export * from "./loader"; +export * from "./collapsible"; diff --git a/web/components/core/modals/alert-modal.tsx b/packages/ui/src/modals/alert-modal.tsx similarity index 91% rename from web/components/core/modals/alert-modal.tsx rename to packages/ui/src/modals/alert-modal.tsx index d864c2b38..eb32d57ea 100644 --- a/web/components/core/modals/alert-modal.tsx +++ b/packages/ui/src/modals/alert-modal.tsx @@ -1,10 +1,12 @@ +import React from "react"; import { AlertTriangle, Info, LucideIcon } from "lucide-react"; -// ui -import { Button, TButtonVariant } from "@plane/ui"; // components -import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; +import { Button, TButtonVariant } from "../button"; +import { ModalCore } from "./modal-core"; +// constants +import { EModalPosition, EModalWidth } from "./constants"; // helpers -import { cn } from "@/helpers/common.helper"; +import { cn } from "../../helpers"; export type TModalVariant = "danger" | "primary"; diff --git a/packages/ui/src/modals/constants.ts b/packages/ui/src/modals/constants.ts new file mode 100644 index 000000000..fd0c82831 --- /dev/null +++ b/packages/ui/src/modals/constants.ts @@ -0,0 +1,11 @@ +export enum EModalPosition { + TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20", + CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full", +} + +export enum EModalWidth { + XL = "sm:max-w-xl", + XXL = "sm:max-w-2xl", + XXXL = "sm:max-w-3xl", + XXXXL = "sm:max-w-4xl", +} diff --git a/packages/ui/src/modals/index.ts b/packages/ui/src/modals/index.ts new file mode 100644 index 000000000..964864f7b --- /dev/null +++ b/packages/ui/src/modals/index.ts @@ -0,0 +1,3 @@ +export * from "./alert-modal"; +export * from "./constants"; +export * from "./modal-core"; diff --git a/web/components/core/modals/modal-core.tsx b/packages/ui/src/modals/modal-core.tsx similarity index 78% rename from web/components/core/modals/modal-core.tsx rename to packages/ui/src/modals/modal-core.tsx index 5d24df01c..3ef3f801e 100644 --- a/web/components/core/modals/modal-core.tsx +++ b/packages/ui/src/modals/modal-core.tsx @@ -1,23 +1,13 @@ -import { Fragment } from "react"; +import React, { Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; +// constants +import { EModalPosition, EModalWidth } from "./constants"; // helpers -import { cn } from "@/helpers/common.helper"; - -export enum EModalPosition { - TOP = "flex items-center justify-center text-center mx-4 my-10 md:my-20", - CENTER = "flex items-end sm:items-center justify-center p-4 min-h-full", -} - -export enum EModalWidth { - XL = "sm:max-w-xl", - XXL = "sm:max-w-2xl", - XXXL = "sm:max-w-3xl", - XXXXL = "sm:max-w-4xl", -} +import { cn } from "../../helpers"; type Props = { children: React.ReactNode; - handleClose: () => void; + handleClose?: () => void; isOpen: boolean; position?: EModalPosition; width?: EModalWidth; @@ -27,7 +17,7 @@ export const ModalCore: React.FC = (props) => { return ( - + handleClose && handleClose()}> { + const ref = useRef(null); + const [dragging, setDragging] = useState(false); // NEW + const [isDraggedOver, setIsDraggedOver] = useState(false); + + const [closestEdge, setClosestEdge] = useState(null); + useEffect(() => { + const el = ref.current; + + if (el) { + combine( + draggable({ + element: el, + onDragStart: () => setDragging(true), // NEW + onDrop: () => setDragging(false), // NEW + getInitialData: () => data, + }), + dropTargetForElements({ + element: el, + onDragEnter: (args) => { + setIsDraggedOver(true); + setClosestEdge(extractClosestEdge(args.self.data)); + }, + onDragLeave: () => setIsDraggedOver(false), + onDrop: () => { + setIsDraggedOver(false); + }, + canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__, + getData: ({ input, element }) => + attachClosestEdge(data, { + input, + element, + allowedEdges: ["top", "bottom"], + }), + }) + ); + } + }, [data]); + + return ( +
+ {} + {children} + {} +
+ ); +}; + +export { Draggable }; diff --git a/packages/ui/src/sortable/index.ts b/packages/ui/src/sortable/index.ts new file mode 100644 index 000000000..9dde5a404 --- /dev/null +++ b/packages/ui/src/sortable/index.ts @@ -0,0 +1,2 @@ +export * from "./sortable"; +export * from "./draggable"; diff --git a/packages/ui/src/sortable/sortable.stories.tsx b/packages/ui/src/sortable/sortable.stories.tsx new file mode 100644 index 000000000..b701af95d --- /dev/null +++ b/packages/ui/src/sortable/sortable.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { Sortable } from "./sortable"; + +const meta: Meta = { + title: "Sortable", + component: Sortable, +}; + +export default meta; +type Story = StoryObj; + +const data = [ + { id: "1", name: "John Doe" }, + { id: "2", name: "Satish" }, + { id: "3", name: "Alice" }, + { id: "4", name: "Bob" }, + { id: "5", name: "Charlie" }, +]; +export const Default: Story = { + args: { + data, + render: (item: any) => ( + // +
{item.name}
+ //
+ ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange: (data) => console.log(data.map(({ id }: any) => id)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keyExtractor: (item: any) => item.id, + }, +}; diff --git a/packages/ui/src/sortable/sortable.tsx b/packages/ui/src/sortable/sortable.tsx new file mode 100644 index 000000000..b495d535e --- /dev/null +++ b/packages/ui/src/sortable/sortable.tsx @@ -0,0 +1,79 @@ +import React, { Fragment, useEffect, useMemo } from "react"; +import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { Draggable } from "./draggable"; + +type Props = { + data: T[]; + render: (item: T, index: number) => React.ReactNode; + onChange: (data: T[]) => void; + keyExtractor: (item: T, index: number) => string; + containerClassName?: string; + id?: string; +}; + +const moveItem = ( + data: T[], + source: T, + destination: T & Record, + keyExtractor: (item: T, index: number) => string +) => { + const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0)); + if (sourceIndex === -1) return data; + + const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0)); + + if (destinationIndex === -1) return data; + + const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)"); + const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol + const newData = [...data]; + const [movedItem] = newData.splice(sourceIndex, 1); + + let adjustedDestinationIndex = destinationIndex; + if (position === "bottom") { + adjustedDestinationIndex++; + } + + // Prevent moving item out of bounds + if (adjustedDestinationIndex > newData.length) { + adjustedDestinationIndex = newData.length; + } + + newData.splice(adjustedDestinationIndex, 0, movedItem); + + return newData; +}; + +export const Sortable = ({ data, render, onChange, keyExtractor, containerClassName, id }: Props) => { + useEffect(() => { + const unsubscribe = monitorForElements({ + onDrop({ source, location }) { + const destination = location?.current?.dropTargets[0]; + if (!destination) return; + onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor)); + }, + }); + + // Clean up the subscription on unmount + return () => { + if (unsubscribe) unsubscribe(); + }; + }, [data, keyExtractor, onChange]); + + const enhancedData = useMemo(() => { + const uuid = id ? id : Math.random().toString(36).substring(7); + return data.map((item) => ({ ...item, __uuid__: uuid })); + }, [data, id]); + + return ( + <> + {enhancedData.map((item, index) => ( + + {render(item, index)} + + ))} + + ); +}; + +export default Sortable; diff --git a/packages/ui/src/toast/index.tsx b/packages/ui/src/toast/index.tsx index f38050532..ce2d05ef7 100644 --- a/packages/ui/src/toast/index.tsx +++ b/packages/ui/src/toast/index.tsx @@ -25,13 +25,16 @@ type SetToastProps = type: Exclude; title: string; message?: string; + actionItems?: React.ReactNode; }; type PromiseToastCallback = (data: ToastData) => string; +type ActionItemsPromiseToastCallback = (data: ToastData) => JSX.Element; type PromiseToastData = { title: string; message?: PromiseToastCallback; + actionItems?: ActionItemsPromiseToastCallback; }; type PromiseToastOptions = { @@ -54,7 +57,7 @@ type ToastProps = { export const Toast = (props: ToastProps) => { const { theme } = props; - return ; + return ; }; export const setToast = (props: SetToastProps) => { @@ -66,29 +69,27 @@ export const setToast = (props: SetToastProps) => { borderColorClassName, }: ToastContentProps) => props.type === TOAST_TYPE.LOADING ? ( -
{ - e.stopPropagation(); - e.preventDefault(); - }} - className={cn( - "w-[350px] h-[67.3px] rounded-lg border shadow-sm p-2", - backgroundColorClassName, - borderColorClassName - )} - > -
- {icon &&
{icon}
} -
-
{props.title ?? "Loading..."}
-
- toast.dismiss(toastId)} - /> +
+
{ + e.stopPropagation(); + e.preventDefault(); + }} + className={cn("w-full rounded-lg border shadow-sm p-2", backgroundColorClassName, borderColorClassName)} + > +
+ {icon &&
{icon}
} +
+
{props.title ?? "Loading..."}
+
+ toast.dismiss(toastId)} + /> +
@@ -100,7 +101,7 @@ export const setToast = (props: SetToastProps) => { e.preventDefault(); }} className={cn( - "relative flex flex-col w-[350px] rounded-lg border shadow-sm p-2", + "relative group flex flex-col w-[350px] rounded-lg border shadow-sm p-2", backgroundColorClassName, borderColorClassName )} @@ -112,12 +113,15 @@ export const setToast = (props: SetToastProps) => { height={14} onClick={() => toast.dismiss(toastId)} /> -
- {icon &&
{icon}
} -
-
{props.title}
- {props.message &&
{props.message}
} +
+
+ {icon &&
{icon}
} +
+
{props.title}
+ {props.message &&
{props.message}
} +
+ {props.actionItems &&
{props.actionItems}
}
); @@ -128,7 +132,7 @@ export const setToast = (props: SetToastProps) => { (toastId) => renderToastContent({ toastId, - icon: , + icon: , textColorClassName: "text-toast-text-success", backgroundColorClassName: "bg-toast-background-success", borderColorClassName: "border-toast-border-success", @@ -140,7 +144,7 @@ export const setToast = (props: SetToastProps) => { (toastId) => renderToastContent({ toastId, - icon: , + icon: , textColorClassName: "text-toast-text-error", backgroundColorClassName: "bg-toast-background-error", borderColorClassName: "border-toast-border-error", @@ -152,7 +156,7 @@ export const setToast = (props: SetToastProps) => { (toastId) => renderToastContent({ toastId, - icon: , + icon: , textColorClassName: "text-toast-text-warning", backgroundColorClassName: "bg-toast-background-warning", borderColorClassName: "border-toast-border-warning", @@ -197,6 +201,7 @@ export const setPromiseToast = ( id: tId, title: options.success.title, message: options.success.message?.(data), + actionItems: options.success.actionItems?.(data), }); }) .catch((data: ToastData) => { @@ -205,6 +210,7 @@ export const setPromiseToast = ( id: tId, title: options.error.title, message: options.error.message?.(data), + actionItems: options.error.actionItems?.(data), }); }); }; diff --git a/packages/ui/src/typography/index.tsx b/packages/ui/src/typography/index.tsx new file mode 100644 index 000000000..0b1b7ffe1 --- /dev/null +++ b/packages/ui/src/typography/index.tsx @@ -0,0 +1 @@ +export * from "./sub-heading"; diff --git a/packages/ui/src/typography/sub-heading.tsx b/packages/ui/src/typography/sub-heading.tsx new file mode 100644 index 000000000..9e7075583 --- /dev/null +++ b/packages/ui/src/typography/sub-heading.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { cn } from "../../helpers"; + +type Props = { + children: React.ReactNode; + className?: string; + noMargin?: boolean; +}; +const SubHeading = ({ children, className, noMargin }: Props) => ( +

+ {children} +

+); + +export { SubHeading }; diff --git a/space/app/[workspaceSlug]/[projectId]/page.ts b/space/app/[workspaceSlug]/[projectId]/page.ts new file mode 100644 index 000000000..1f8b8345d --- /dev/null +++ b/space/app/[workspaceSlug]/[projectId]/page.ts @@ -0,0 +1,42 @@ +import { notFound, redirect } from "next/navigation"; +// types +import { TProjectPublishSettings } from "@plane/types"; +// services +import PublishService from "@/services/publish.service"; + +const publishService = new PublishService(); + +type Props = { + params: { + workspaceSlug: string; + projectId: string; + }; + searchParams: any; +}; + +export default async function IssuesPage(props: Props) { + const { params, searchParams } = props; + // query params + const { workspaceSlug, projectId } = params; + const { board, peekId } = searchParams; + + let response: TProjectPublishSettings | undefined = undefined; + try { + response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId); + } catch (error) { + // redirect to 404 page on error + notFound(); + } + + let url = ""; + if (response?.entity_name === "project") { + url = `/issues/${response?.anchor}`; + const params = new URLSearchParams(); + if (board) params.append("board", board); + if (peekId) params.append("peekId", peekId); + if (params.toString()) url += `?${params.toString()}`; + redirect(url); + } else { + notFound(); + } +} diff --git a/space/app/[workspace_slug]/[project_id]/layout.tsx b/space/app/[workspace_slug]/[project_id]/layout.tsx deleted file mode 100644 index b1e134ea6..000000000 --- a/space/app/[workspace_slug]/[project_id]/layout.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Image from "next/image"; -import { notFound } from "next/navigation"; -// components -import IssueNavbar from "@/components/issues/navbar"; -// assets -import planeLogo from "public/plane-logo.svg"; - -export default async function ProjectLayout({ - children, - params, -}: { - children: React.ReactNode; - params: { workspace_slug: string; project_id: string }; -}) { - const { workspace_slug, project_id } = params; - - if (!workspace_slug || !project_id) notFound(); - - return ( - - ); -} diff --git a/space/app/[workspace_slug]/[project_id]/page.tsx b/space/app/[workspace_slug]/[project_id]/page.tsx deleted file mode 100644 index 0d08ae7eb..000000000 --- a/space/app/[workspace_slug]/[project_id]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -// components -import { ProjectDetailsView } from "@/components/views"; - -export default function WorkspaceProjectPage({ params }: { params: { workspace_slug: any; project_id: any } }) { - const { workspace_slug, project_id } = params; - - const searchParams = useSearchParams(); - const peekId = searchParams.get("peekId") || undefined; - - if (!workspace_slug || !project_id) return <>; - - return ; -} diff --git a/space/app/error.tsx b/space/app/error.tsx index 2d6f22e90..1f9e1ca19 100644 --- a/space/app/error.tsx +++ b/space/app/error.tsx @@ -1,38 +1,47 @@ "use client"; -import Image from "next/image"; -import { useTheme } from "next-themes"; +// ui import { Button } from "@plane/ui"; -// assets -import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; -import InstanceFailureImage from "@/public/instance/instance-failure.svg"; - -export default function InstanceError() { - const { resolvedTheme } = useTheme(); - - const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; +const ErrorPage = () => { const handleRetry = () => { window.location.reload(); }; return ( -
-
-
- Plane instance failure image -

Unable to fetch instance details.

-

- We were unable to fetch the details of the instance.
- Fret not, it might just be a connectivity issue. +

+
+
+

Yikes! That doesn{"'"}t look good.

+

+ That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more + details, please write to{" "} + + support@plane.so + {" "} + or on our{" "} + + Discord + + .

-
- + {/* */}
); -} +}; + +export default ErrorPage; diff --git a/space/app/issues/[anchor]/layout.tsx b/space/app/issues/[anchor]/layout.tsx new file mode 100644 index 000000000..651facb8c --- /dev/null +++ b/space/app/issues/[anchor]/layout.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import useSWR from "swr"; +// components +import { LogoSpinner } from "@/components/common"; +import { IssuesNavbarRoot } from "@/components/issues"; +// hooks +import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store"; +// assets +import planeLogo from "@/public/plane-logo.svg"; + +type Props = { + children: React.ReactNode; + params: { + anchor: string; + }; +}; + +const IssuesLayout = observer((props: Props) => { + const { children, params } = props; + // params + const { anchor } = params; + // store hooks + const { fetchPublishSettings } = usePublishList(); + const publishSettings = usePublish(anchor); + const { updateLayoutOptions } = useIssueFilter(); + // fetch publish settings + useSWR( + anchor ? `PUBLISH_SETTINGS_${anchor}` : null, + anchor + ? async () => { + const response = await fetchPublishSettings(anchor); + if (response.view_props) { + updateLayoutOptions({ + list: !!response.view_props.list, + kanban: !!response.view_props.kanban, + calendar: !!response.view_props.calendar, + gantt: !!response.view_props.gantt, + spreadsheet: !!response.view_props.spreadsheet, + }); + } + } + : null + ); + + if (!publishSettings) return ; + + return ( + + ); +}); + +export default IssuesLayout; diff --git a/space/app/issues/[anchor]/page.tsx b/space/app/issues/[anchor]/page.tsx new file mode 100644 index 000000000..1b16def82 --- /dev/null +++ b/space/app/issues/[anchor]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// components +import { IssuesLayoutsRoot } from "@/components/issues"; +// hooks +import { usePublish } from "@/hooks/store"; + +type Props = { + params: { + anchor: string; + }; +}; + +const IssuesPage = observer((props: Props) => { + const { params } = props; + const { anchor } = params; + // params + const searchParams = useSearchParams(); + const peekId = searchParams.get("peekId") || undefined; + + const publishSettings = usePublish(anchor); + + if (!publishSettings) return null; + + return ; +}); + +export default IssuesPage; diff --git a/space/app/layout.tsx b/space/app/layout.tsx index b2bbd1227..ca6d11ea1 100644 --- a/space/app/layout.tsx +++ b/space/app/layout.tsx @@ -9,11 +9,11 @@ import "@/styles/globals.css"; import { ToastProvider } from "@/lib/toast-provider"; export const metadata: Metadata = { - title: "Plane Deploy | Make your Plane boards public with one-click", - description: "Plane Deploy is a customer feedback management tool built on top of plane.so", + title: "Plane Publish | Make your Plane boards public with one-click", + description: "Plane Publish is a customer feedback management tool built on top of plane.so", openGraph: { - title: "Plane Deploy | Make your Plane boards public with one-click", - description: "Plane Deploy is a customer feedback management tool built on top of plane.so", + title: "Plane Publish | Make your Plane boards public with one-click", + description: "Plane Publish is a customer feedback management tool built on top of plane.so", url: "https://sites.plane.so/", }, keywords: diff --git a/space/app/not-found.tsx b/space/app/not-found.tsx index cae576319..9b6050ed1 100644 --- a/space/app/not-found.tsx +++ b/space/app/not-found.tsx @@ -2,22 +2,22 @@ import Image from "next/image"; // assets -import UserLoggedInImage from "public/user-logged-in.svg"; +import SomethingWentWrongImage from "public/something-went-wrong.svg"; -export default function NotFound() { - return ( -
-
-
-
-
- User already logged in -
-
-

Not Found

-

Please enter the appropriate project URL to view the issue board.

+const NotFound = () => ( +
+
+
+
+ User already logged in
+

That didn{"'"}t work

+

+ Check the URL you are entering in the browser{"'"}s address bar and try again. +

- ); -} +
+); + +export default NotFound; diff --git a/space/app/page.tsx b/space/app/page.tsx index a6058fb8a..a905f71b7 100644 --- a/space/app/page.tsx +++ b/space/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // components import { UserLoggedIn } from "@/components/account"; import { LogoSpinner } from "@/components/common"; diff --git a/space/ce/store/root.store.ts b/space/ce/store/root.store.ts new file mode 100644 index 000000000..710462e13 --- /dev/null +++ b/space/ce/store/root.store.ts @@ -0,0 +1,8 @@ +// store +import { CoreRootStore } from "@/store/root.store"; + +export class RootStore extends CoreRootStore { + constructor() { + super(); + } +} diff --git a/space/components/account/helpers/password-strength-meter.tsx b/space/components/account/helpers/password-strength-meter.tsx deleted file mode 100644 index c12d78421..000000000 --- a/space/components/account/helpers/password-strength-meter.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"use client"; - -// icons -import { CircleCheck } from "lucide-react"; -// helpers -import { cn } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; - -type Props = { - password: string; -}; - -export const PasswordStrengthMeter: React.FC = (props: Props) => { - const { password } = props; - - const strength = getPasswordStrength(password); - let bars = []; - let text = ""; - let textColor = ""; - - if (password.length === 0) { - bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password requirements"; - } else if (password.length < 8) { - bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password is too short"; - textColor = `text-[#DC3E42]`; - } else if (strength < 3) { - bars = [`bg-[#FFBA18]`, `bg-[#FFBA18]`, `bg-[#F0F0F3]`]; - text = "Password is weak"; - textColor = `text-[#FFBA18]`; - } else { - bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; - text = "Password is strong"; - textColor = `text-[#3E9B4F]`; - } - - const criteria = [ - { label: "Min 8 characters", isValid: password.length >= 8 }, - { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) }, - { label: "Min 1 number", isValid: /\d/.test(password) }, - { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) }, - ]; - - return ( -
-
- {bars.map((color, index) => ( -
- ))} -
-

{text}

-
- {criteria.map((criterion, index) => ( -
- - {criterion.label} -
- ))} -
-
- ); -}; diff --git a/space/components/account/user-logged-in.tsx b/space/components/account/user-logged-in.tsx deleted file mode 100644 index 33be330fa..000000000 --- a/space/components/account/user-logged-in.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import Image from "next/image"; -// components -import { UserAvatar } from "@/components/issues/navbar/user-avatar"; -// hooks -import { useUser } from "@/hooks/store"; -// assets -import PlaneLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; -import UserLoggedInImage from "@/public/user-logged-in.svg"; - -export const UserLoggedIn = () => { - const { data: user } = useUser(); - - if (!user) return null; - - return ( -
-
-
- User already logged in -
- -
- -
-
-
-
- User already logged in -
-
-

Logged in Successfully!

-

- You{"'"}ve successfully logged in. Please enter the appropriate project URL to view the issue board. -

-
-
-
- ); -}; diff --git a/space/components/common/latest-feature-block.tsx b/space/components/common/latest-feature-block.tsx deleted file mode 100644 index c1b5db954..000000000 --- a/space/components/common/latest-feature-block.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -// icons -import { Lightbulb } from "lucide-react"; -// images -import latestFeatures from "public/onboarding/onboarding-pages.svg"; - -export const LatestFeatureBlock = () => { - const { resolvedTheme } = useTheme(); - - return ( - <> -
- -

- Pages gets a facelift! Write anything and use Galileo to help you start.{" "} - - Learn more - -

-
-
-
- Plane Issues -
-
- - ); -}; diff --git a/space/components/instance/not-ready-view.tsx b/space/components/instance/not-ready-view.tsx deleted file mode 100644 index be46a9473..000000000 --- a/space/components/instance/not-ready-view.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { FC } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useTheme } from "next-themes"; -// ui -import { Button } from "@plane/ui"; -// helper -import { GOD_MODE_URL, SPACE_BASE_PATH } from "@/helpers/common.helper"; -// images -import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png"; -import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg"; -import PlaneBackgroundPattern from "public/auth/background-pattern.svg"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; - -export const InstanceNotReady: FC = () => { - const { resolvedTheme } = useTheme(); - const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; - - const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; - - return ( -
-
-
-
- - Plane logo - -
-
- -
- Plane background pattern -
- -
-
-
-
-

Welcome aboard Plane!

- Plane Logo -

- Get started by setting up your instance and workspace -

-
- -
-
-
-
-
- ); -}; diff --git a/space/components/issues/board-views/block-downvotes.tsx b/space/components/issues/board-views/block-downvotes.tsx deleted file mode 100644 index 4326a8823..000000000 --- a/space/components/issues/board-views/block-downvotes.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -export const IssueBlockDownVotes = ({ number }: { number: number }) => ( -
- - arrow_upward_alt - - {number} -
-); diff --git a/space/components/issues/board-views/block-due-date.tsx b/space/components/issues/board-views/block-due-date.tsx deleted file mode 100644 index ecf229562..000000000 --- a/space/components/issues/board-views/block-due-date.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -// helpers -import { renderFullDate } from "@/helpers/date-time.helper"; - -export const dueDateIconDetails = ( - date: string, - stateGroup: string -): { - iconName: string; - className: string; -} => { - let iconName = "calendar_today"; - let className = ""; - - if (!date || ["completed", "cancelled"].includes(stateGroup)) { - iconName = "calendar_today"; - className = ""; - } else { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const targetDate = new Date(date); - targetDate.setHours(0, 0, 0, 0); - - const timeDifference = targetDate.getTime() - today.getTime(); - - if (timeDifference < 0) { - iconName = "event_busy"; - className = "text-red-500"; - } else if (timeDifference === 0) { - iconName = "today"; - className = "text-red-500"; - } else if (timeDifference === 24 * 60 * 60 * 1000) { - iconName = "event"; - className = "text-yellow-500"; - } else { - iconName = "calendar_today"; - className = ""; - } - } - - return { - iconName, - className, - }; -}; - -export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => { - const iconDetails = dueDateIconDetails(due_date, group); - - return ( -
- - {iconDetails.iconName} - - {renderFullDate(due_date)} -
- ); -}; diff --git a/space/components/issues/board-views/block-labels.tsx b/space/components/issues/board-views/block-labels.tsx deleted file mode 100644 index 05f6a039f..000000000 --- a/space/components/issues/board-views/block-labels.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -export const IssueBlockLabels = ({ labels }: any) => ( -
- {labels && - labels.length > 0 && - labels.map((_label: any) => ( -
-
-
-
{_label?.name}
-
-
- ))} -
-); diff --git a/space/components/issues/board-views/block-state.tsx b/space/components/issues/board-views/block-state.tsx deleted file mode 100644 index 39b10ceb0..000000000 --- a/space/components/issues/board-views/block-state.tsx +++ /dev/null @@ -1,18 +0,0 @@ -// ui -import { StateGroupIcon } from "@plane/ui"; -// constants -import { issueGroupFilter } from "@/constants/issue"; - -export const IssueBlockState = ({ state }: any) => { - const stateGroup = issueGroupFilter(state.group); - - if (stateGroup === null) return <>; - return ( -
-
- -
{state?.name}
-
-
- ); -}; diff --git a/space/components/issues/board-views/block-upvotes.tsx b/space/components/issues/board-views/block-upvotes.tsx deleted file mode 100644 index 3927acac4..000000000 --- a/space/components/issues/board-views/block-upvotes.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -export const IssueBlockUpVotes = ({ number }: { number: number }) => ( -
- arrow_upward_alt - {number} -
-); diff --git a/space/components/issues/board-views/calendar/index.tsx b/space/components/issues/board-views/calendar/index.tsx deleted file mode 100644 index 0edeca96c..000000000 --- a/space/components/issues/board-views/calendar/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const IssueCalendarView = () =>
; diff --git a/space/components/issues/board-views/gantt/index.tsx b/space/components/issues/board-views/gantt/index.tsx deleted file mode 100644 index 5da924b2c..000000000 --- a/space/components/issues/board-views/gantt/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const IssueGanttView = () =>
; diff --git a/space/components/issues/board-views/kanban/block.tsx b/space/components/issues/board-views/kanban/block.tsx deleted file mode 100644 index e34222dd4..000000000 --- a/space/components/issues/board-views/kanban/block.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter, useSearchParams } from "next/navigation"; -// components -import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date"; -import { IssueBlockPriority } from "@/components/issues/board-views/block-priority"; -import { IssueBlockState } from "@/components/issues/board-views/block-state"; -// helpers -import { queryParamGenerator } from "@/helpers/query-param-generator"; -// hooks -import { useIssueDetails, useProject } from "@/hooks/store"; -// interfaces -import { IIssue } from "@/types/issue"; - -type IssueKanBanBlockProps = { - issue: IIssue; - workspaceSlug: string; - projectId: string; - params: any; -}; - -export const IssueKanBanBlock: FC = observer((props) => { - const router = useRouter(); - const searchParams = useSearchParams(); - // query params - const board = searchParams.get("board") || undefined; - const state = searchParams.get("state") || undefined; - const priority = searchParams.get("priority") || undefined; - const labels = searchParams.get("labels") || undefined; - // props - const { workspaceSlug, projectId, issue } = props; - // hooks - const { project } = useProject(); - const { setPeekId } = useIssueDetails(); - - const handleBlockClick = () => { - setPeekId(issue.id); - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); - }; - - return ( -
- {/* id */} -
- {project?.identifier}-{issue?.sequence_id} -
- - {/* name */} -
- {issue.name} -
- -
- {/* priority */} - {issue?.priority && ( -
- -
- )} - {/* state */} - {issue?.state_detail && ( -
- -
- )} - {/* due date */} - {issue?.target_date && ( -
- -
- )} -
-
- ); -}); diff --git a/space/components/issues/board-views/kanban/header.tsx b/space/components/issues/board-views/kanban/header.tsx deleted file mode 100644 index baf5612b3..000000000 --- a/space/components/issues/board-views/kanban/header.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; -// mobx react lite -import { observer } from "mobx-react-lite"; -// ui -import { StateGroupIcon } from "@plane/ui"; -// constants -import { issueGroupFilter } from "@/constants/issue"; -// mobx hook -// import { useIssue } from "@/hooks/store"; -// interfaces -import { IIssueState } from "@/types/issue"; - -export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => { - // const { getCountOfIssuesByState } = useIssue(); - const stateGroup = issueGroupFilter(state.group); - - if (stateGroup === null) return <>; - - return ( -
-
- -
-
{state?.name}
- {/* {getCountOfIssuesByState(state.id)} */} -
- ); -}); diff --git a/space/components/issues/board-views/kanban/index.tsx b/space/components/issues/board-views/kanban/index.tsx deleted file mode 100644 index e2e4e9900..000000000 --- a/space/components/issues/board-views/kanban/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { IssueKanBanBlock } from "@/components/issues/board-views/kanban/block"; -import { IssueKanBanHeader } from "@/components/issues/board-views/kanban/header"; -// ui -import { Icon } from "@/components/ui"; -// mobx hook -import { useIssue } from "@/hooks/store"; -// interfaces -import { IIssueState, IIssue } from "@/types/issue"; - -type IssueKanbanViewProps = { - workspaceSlug: string; - projectId: string; -}; - -export const IssueKanbanView: FC = observer((props) => { - const { workspaceSlug, projectId } = props; - // store hooks - const { states, getFilteredIssuesByState } = useIssue(); - - return ( -
- {states && - states.length > 0 && - states.map((_state: IIssueState) => ( -
-
- -
-
- {getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? ( -
- {getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - - ))} -
- ) : ( -
- - No issues in this state -
- )} -
-
- ))} -
- ); -}); diff --git a/space/components/issues/board-views/list/header.tsx b/space/components/issues/board-views/list/header.tsx deleted file mode 100644 index 2f8f6c018..000000000 --- a/space/components/issues/board-views/list/header.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; -import { observer } from "mobx-react-lite"; -// ui -import { StateGroupIcon } from "@plane/ui"; -// constants -import { issueGroupFilter } from "@/constants/issue"; -// mobx hook -// import { useIssue } from "@/hooks/store"; -// types -import { IIssueState } from "@/types/issue"; - -export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { - // const { getCountOfIssuesByState } = useIssue(); - const stateGroup = issueGroupFilter(state.group); - // const count = getCountOfIssuesByState(state.id); - - if (stateGroup === null) return <>; - - return ( -
-
- -
-
{state?.name}
- {/*
{count}
*/} -
- ); -}); diff --git a/space/components/issues/board-views/list/index.tsx b/space/components/issues/board-views/list/index.tsx deleted file mode 100644 index 2a2b958be..000000000 --- a/space/components/issues/board-views/list/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { IssueListBlock } from "@/components/issues/board-views/list/block"; -import { IssueListHeader } from "@/components/issues/board-views/list/header"; -// mobx hook -import { useIssue } from "@/hooks/store"; -// types -import { IIssueState, IIssue } from "@/types/issue"; - -type IssueListViewProps = { - workspaceSlug: string; - projectId: string; -}; - -export const IssueListView: FC = observer((props) => { - const { workspaceSlug, projectId } = props; - // store hooks - const { states, getFilteredIssuesByState } = useIssue(); - - return ( - <> - {states && - states.length > 0 && - states.map((_state: IIssueState) => ( -
- - {getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? ( -
- {getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( - - ))} -
- ) : ( -
No issues.
- )} -
- ))} - - ); -}); diff --git a/space/components/issues/board-views/spreadsheet/index.tsx b/space/components/issues/board-views/spreadsheet/index.tsx deleted file mode 100644 index 45ebf2792..000000000 --- a/space/components/issues/board-views/spreadsheet/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export const IssueSpreadsheetView = () =>
; diff --git a/space/components/issues/navbar/issue-board-view.tsx b/space/components/issues/navbar/issue-board-view.tsx deleted file mode 100644 index 711229961..000000000 --- a/space/components/issues/navbar/issue-board-view.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter, useSearchParams } from "next/navigation"; -// constants -import { issueLayoutViews } from "@/constants/issue"; -// helpers -import { queryParamGenerator } from "@/helpers/query-param-generator"; -// hooks -import { useIssueFilter } from "@/hooks/store"; -// mobx -import { TIssueLayout } from "@/types/issue"; - -type NavbarIssueBoardViewProps = { - workspaceSlug: string; - projectId: string; -}; - -export const NavbarIssueBoardView: FC = observer((props) => { - const router = useRouter(); - const searchParams = useSearchParams(); - // query params - const labels = searchParams.get("labels") || undefined; - const state = searchParams.get("state") || undefined; - const priority = searchParams.get("priority") || undefined; - const peekId = searchParams.get("peekId") || undefined; - // props - const { workspaceSlug, projectId } = props; - // hooks - const { layoutOptions, issueFilters, updateIssueFilters } = useIssueFilter(); - - // derived values - const activeLayout = issueFilters?.display_filters?.layout || undefined; - - const handleCurrentBoardView = (boardView: TIssueLayout) => { - updateIssueFilters(projectId, "display_filters", "layout", boardView); - const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels }); - router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); - }; - - return ( - <> - {issueLayoutViews && - Object.keys(issueLayoutViews).map((key: string) => { - const layoutKey = key as TIssueLayout; - if (layoutOptions[layoutKey]) { - return ( -
handleCurrentBoardView(layoutKey)} - title={layoutKey} - > - - {issueLayoutViews[layoutKey]?.icon} - -
- ); - } - })} - - ); -}); diff --git a/space/components/issues/peek-overview/issue-activity.tsx b/space/components/issues/peek-overview/issue-activity.tsx deleted file mode 100644 index ec73bda7b..000000000 --- a/space/components/issues/peek-overview/issue-activity.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { Button } from "@plane/ui"; -// components -import { CommentCard, AddComment } from "@/components/issues/peek-overview"; -import { Icon } from "@/components/ui"; -// hooks -import { useIssueDetails, useProject, useUser } from "@/hooks/store"; -import useIsInIframe from "@/hooks/use-is-in-iframe"; -// types -import { IIssue } from "@/types/issue"; - -type Props = { - issueDetails: IIssue; - workspaceSlug: string; - projectId: string; -}; - -export const PeekOverviewIssueActivity: React.FC = observer((props) => { - const { workspaceSlug, projectId } = props; - // router - const pathname = usePathname(); - // store - const { canComment } = useProject(); - const { details, peekId } = useIssueDetails(); - const { data: currentUser } = useUser(); - const isInIframe = useIsInIframe(); - - const comments = details[peekId || ""]?.comments || []; - - return ( -
-

Comments

- {workspaceSlug && ( -
-
- {comments.map((comment: any) => ( - - ))} -
- {!isInIframe && - (currentUser ? ( - <> - {canComment && ( -
- -
- )} - - ) : ( -
-

- - Sign in to add your comment -

- - - -
- ))} -
- )} -
- ); -}); diff --git a/space/components/issues/peek-overview/issue-details.tsx b/space/components/issues/peek-overview/issue-details.tsx deleted file mode 100644 index 5fe73f67a..000000000 --- a/space/components/issues/peek-overview/issue-details.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// components -import { RichTextReadOnlyEditor } from "@/components/editor"; -import { IssueReactions } from "@/components/issues/peek-overview"; -// types -import { IIssue } from "@/types/issue"; - -type Props = { - issueDetails: IIssue; -}; - -export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => ( -
-
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} -
-

{issueDetails.name}

- {issueDetails.description_html !== "" && issueDetails.description_html !== "

" && ( -

" - : issueDetails.description_html - } - /> - )} - -
-); diff --git a/space/components/issues/peek-overview/issue-reaction.tsx b/space/components/issues/peek-overview/issue-reaction.tsx deleted file mode 100644 index 87210f377..000000000 --- a/space/components/issues/peek-overview/issue-reaction.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useParams } from "next/navigation"; -import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview"; -import { useProject } from "@/hooks/store"; -import useIsInIframe from "@/hooks/use-is-in-iframe"; - -// type IssueReactionsProps = { -// workspaceSlug: string; -// projectId: string; -// }; - -export const IssueReactions: React.FC = () => { - const { workspace_slug: workspaceSlug, project_id: projectId } = useParams(); - - const { canVote, canReact } = useProject(); - const isInIframe = useIsInIframe(); - - return ( -
- {canVote && ( - <> -
- -
- - )} - {!isInIframe && canReact && ( -
- -
- )} -
- ); -}; diff --git a/space/components/ui/dropdown.tsx b/space/components/ui/dropdown.tsx deleted file mode 100644 index 788627094..000000000 --- a/space/components/ui/dropdown.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Fragment, useState, useRef } from "react"; -import Link from "next/link"; -import { Check, ChevronLeft } from "lucide-react"; -import { Popover, Transition } from "@headlessui/react"; -// hooks -import useOutSideClick from "hooks/use-outside-click"; - -type ItemOptionType = { - display: React.ReactNode; - as?: "button" | "link" | "div"; - href?: string; - isSelected?: boolean; - onClick?: () => void; - children?: ItemOptionType[] | null; -}; - -type DropdownItemProps = { - item: ItemOptionType; -}; - -type DropDownListProps = { - open: boolean; - handleClose?: () => void; - items: ItemOptionType[]; -}; - -type DropdownProps = { - button: React.ReactNode | (() => React.ReactNode); - items: ItemOptionType[]; -}; - -const DropdownList: React.FC = (props) => { - const { open, items, handleClose } = props; - - const ref = useRef(null); - - useOutSideClick(ref, () => { - if (handleClose) handleClose(); - }); - - return ( - - - -
- {items.map((item, index) => ( - - ))} -
-
-
-
- ); -}; - -const DropdownItem: React.FC = (props) => { - const { item } = props; - const { display, children, as: itemAs, href, onClick, isSelected } = item; - - const [open, setOpen] = useState(false); - - return ( -
- {(!itemAs || itemAs === "button" || itemAs === "div") && ( - - )} - - {itemAs === "link" && {display}} - - {children && setOpen(false)} items={children} />} -
- ); -}; - -const Dropdown: React.FC = (props) => { - const { button, items } = props; - - return ( - - {({ open }) => ( - <> - - {typeof button === "function" ? button() : button} - - - - -
- {items.map((item, index) => ( - - ))} -
-
-
- - )} -
- ); -}; - -export { Dropdown }; diff --git a/space/components/ui/icon.tsx b/space/components/ui/icon.tsx deleted file mode 100644 index 7ddc76843..000000000 --- a/space/components/ui/icon.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; - -type Props = { - iconName: string; - className?: string; -}; - -export const Icon: React.FC = ({ iconName, className = "" }) => ( - {iconName} -); diff --git a/space/components/views/index.ts b/space/components/views/index.ts deleted file mode 100644 index 251de14e3..000000000 --- a/space/components/views/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./auth"; -export * from "./project-details"; diff --git a/space/constants/issue.ts b/space/constants/issue.ts deleted file mode 100644 index fb9c78fcd..000000000 --- a/space/constants/issue.ts +++ /dev/null @@ -1,138 +0,0 @@ -// interfaces -import { - TIssueLayout, - TIssueLayoutViews, - TIssueFilterKeys, - TIssueFilterPriority, - TIssueFilterPriorityObject, - TIssueFilterState, - TIssueFilterStateObject, -} from "types/issue"; - -// issue filters -export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]> } = { - list: { - filters: ["priority", "state", "labels"], - }, - kanban: { - filters: ["priority", "state", "labels"], - }, - calendar: { - filters: ["priority", "state", "labels"], - }, - spreadsheet: { - filters: ["priority", "state", "labels"], - }, - gantt: { - filters: ["priority", "state", "labels"], - }, -}; - -export const issueLayoutViews: Partial = { - list: { - title: "List View", - icon: "format_list_bulleted", - className: "", - }, - kanban: { - title: "Board View", - icon: "grid_view", - className: "", - }, -}; - -// issue priority filters -export const issuePriorityFilters: TIssueFilterPriorityObject[] = [ - { - key: "urgent", - title: "Urgent", - className: "bg-red-500 border-red-500 text-white", - icon: "error", - }, - { - key: "high", - title: "High", - className: "text-orange-500 border-custom-border-300", - icon: "signal_cellular_alt", - }, - { - key: "medium", - title: "Medium", - className: "text-yellow-500 border-custom-border-300", - icon: "signal_cellular_alt_2_bar", - }, - { - key: "low", - title: "Low", - className: "text-green-500 border-custom-border-300", - icon: "signal_cellular_alt_1_bar", - }, - { - key: "none", - title: "None", - className: "text-gray-500 border-custom-border-300", - icon: "block", - }, -]; - -export const issuePriorityFilter = (priorityKey: TIssueFilterPriority): TIssueFilterPriorityObject | undefined => { - const currentIssuePriority: TIssueFilterPriorityObject | undefined = - issuePriorityFilters && issuePriorityFilters.length > 0 - ? issuePriorityFilters.find((_priority) => _priority.key === priorityKey) - : undefined; - - if (currentIssuePriority) return currentIssuePriority; - return undefined; -}; - -// issue group filters -export const issueGroupColors: { - [key in TIssueFilterState]: string; -} = { - backlog: "#d9d9d9", - unstarted: "#3f76ff", - started: "#f59e0b", - completed: "#16a34a", - cancelled: "#dc2626", -}; - -export const issueGroups: TIssueFilterStateObject[] = [ - { - key: "backlog", - title: "Backlog", - color: "#d9d9d9", - className: `text-[#d9d9d9] bg-[#d9d9d9]/10`, - }, - { - key: "unstarted", - title: "Unstarted", - color: "#3f76ff", - className: `text-[#3f76ff] bg-[#3f76ff]/10`, - }, - { - key: "started", - title: "Started", - color: "#f59e0b", - className: `text-[#f59e0b] bg-[#f59e0b]/10`, - }, - { - key: "completed", - title: "Completed", - color: "#16a34a", - className: `text-[#16a34a] bg-[#16a34a]/10`, - }, - { - key: "cancelled", - title: "Cancelled", - color: "#dc2626", - className: `text-[#dc2626] bg-[#dc2626]/10`, - }, -]; - -export const issueGroupFilter = (issueKey: TIssueFilterState): TIssueFilterStateObject | undefined => { - const currentIssueStateGroup: TIssueFilterStateObject | undefined = - issueGroups && issueGroups.length > 0 ? issueGroups.find((group) => group.key === issueKey) : undefined; - - if (currentIssueStateGroup) return currentIssueStateGroup; - return undefined; -}; diff --git a/space/constants/seo.ts b/space/constants/seo.ts deleted file mode 100644 index b2baca612..000000000 --- a/space/constants/seo.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const SITE_NAME = "Plane Deploy | Make your Plane boards and roadmaps pubic with just one-click. "; -export const SITE_TITLE = "Plane Deploy | Make your Plane boards public with one-click"; -export const SITE_DESCRIPTION = "Plane Deploy is a customer feedback management tool built on top of plane.so"; -export const SITE_KEYWORDS = - "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration"; -export const SITE_URL = "https://app.plane.so/"; -export const TWITTER_USER_NAME = "planepowers"; diff --git a/space/constants/workspace.ts b/space/constants/workspace.ts deleted file mode 100644 index 5ae5a7cf4..000000000 --- a/space/constants/workspace.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const USER_ROLES = [ - { value: "Product / Project Manager", label: "Product / Project Manager" }, - { value: "Development / Engineering", label: "Development / Engineering" }, - { value: "Founder / Executive", label: "Founder / Executive" }, - { value: "Freelancer / Consultant", label: "Freelancer / Consultant" }, - { value: "Marketing / Growth", label: "Marketing / Growth" }, - { value: "Sales / Business Development", label: "Sales / Business Development" }, - { value: "Support / Operations", label: "Support / Operations" }, - { value: "Student / Professor", label: "Student / Professor" }, - { value: "Human Resources", label: "Human Resources" }, - { value: "Other", label: "Other" }, -]; diff --git a/space/components/account/auth-forms/auth-banner.tsx b/space/core/components/account/auth-forms/auth-banner.tsx similarity index 100% rename from space/components/account/auth-forms/auth-banner.tsx rename to space/core/components/account/auth-forms/auth-banner.tsx diff --git a/space/components/account/auth-forms/auth-header.tsx b/space/core/components/account/auth-forms/auth-header.tsx similarity index 76% rename from space/components/account/auth-forms/auth-header.tsx rename to space/core/components/account/auth-forms/auth-header.tsx index c3fff468e..8921c70b8 100644 --- a/space/components/account/auth-forms/auth-header.tsx +++ b/space/core/components/account/auth-forms/auth-header.tsx @@ -24,8 +24,8 @@ const Titles: TAuthHeaderDetails = { subHeader: "Contribute in nudging the features you want to get built.", }, [EAuthModes.SIGN_UP]: { - header: "Comment or react to issues", - subHeader: "Use plane to add your valuable inputs to features.", + header: "View, comment, and do more", + subHeader: "Sign up or log in to work with Plane Issues and Pages.", }, }; @@ -48,8 +48,8 @@ export const AuthHeader: FC = (props) => { return ( <>
-

{header}

-

{subHeader}

+

{header}

+

{subHeader}

{children} diff --git a/space/components/account/auth-forms/auth-root.tsx b/space/core/components/account/auth-forms/auth-root.tsx similarity index 97% rename from space/components/account/auth-forms/auth-root.tsx rename to space/core/components/account/auth-forms/auth-root.tsx index e02947af0..afa3bd3a5 100644 --- a/space/components/account/auth-forms/auth-root.tsx +++ b/space/core/components/account/auth-forms/auth-root.tsx @@ -1,7 +1,7 @@ "use client"; import React, { FC, useEffect, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { IEmailCheckData } from "@plane/types"; // components @@ -85,7 +85,8 @@ export const AuthRoot: FC = observer(() => { const isSMTPConfigured = config?.is_smtp_configured || false; const isMagicLoginEnabled = config?.is_magic_login_enabled || false; const isEmailPasswordEnabled = config?.is_email_password_enabled || false; - const isOAuthEnabled = (config && (config?.is_google_enabled || config?.is_github_enabled)) || false; + const isOAuthEnabled = + (config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false; // submit handler- email verification const handleEmailVerification = async (data: IEmailCheckData) => { diff --git a/space/components/account/auth-forms/email.tsx b/space/core/components/account/auth-forms/email.tsx similarity index 91% rename from space/components/account/auth-forms/email.tsx rename to space/core/components/account/auth-forms/email.tsx index 946e51916..ec29a429d 100644 --- a/space/components/account/auth-forms/email.tsx +++ b/space/core/components/account/auth-forms/email.tsx @@ -1,7 +1,7 @@ "use client"; import { FC, FormEvent, useMemo, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; // types @@ -66,9 +66,10 @@ export const AuthEmailForm: FC = observer((props) => { autoFocus /> {email.length > 0 && ( -
- setEmail("")} /> -
+ setEmail("")} + /> )}
{emailError?.email && !isFocused && ( diff --git a/space/components/account/auth-forms/index.ts b/space/core/components/account/auth-forms/index.ts similarity index 100% rename from space/components/account/auth-forms/index.ts rename to space/core/components/account/auth-forms/index.ts diff --git a/space/components/account/auth-forms/password.tsx b/space/core/components/account/auth-forms/password.tsx similarity index 94% rename from space/components/account/auth-forms/password.tsx rename to space/core/components/account/auth-forms/password.tsx index 361bd4fc3..c3a5e9c31 100644 --- a/space/components/account/auth-forms/password.tsx +++ b/space/core/components/account/auth-forms/password.tsx @@ -1,14 +1,14 @@ "use client"; import React, { useEffect, useMemo, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; import { Button, Input, Spinner } from "@plane/ui"; // components import { PasswordStrengthMeter } from "@/components/account"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // services import { AuthService } from "@/services/auth.service"; // types @@ -67,8 +67,8 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const passwordSupport = passwordFormData.password.length > 0 && mode === EAuthModes.SIGN_UP && - (getPasswordStrength(passwordFormData.password) < 3 || isPasswordInputFocused) && ( - + getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + ); const isButtonDisabled = useMemo( @@ -76,7 +76,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { !isSubmitting && !!passwordFormData.password && (mode === EAuthModes.SIGN_UP - ? getPasswordStrength(passwordFormData.password) >= 3 && + ? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && passwordFormData.password === passwordFormData.confirm_password : true) ? false @@ -117,9 +117,10 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { disabled /> {passwordFormData.email.length > 0 && ( -
- -
+ )}
diff --git a/space/components/account/auth-forms/unique-code.tsx b/space/core/components/account/auth-forms/unique-code.tsx similarity index 96% rename from space/components/account/auth-forms/unique-code.tsx rename to space/core/components/account/auth-forms/unique-code.tsx index e3c82bce6..10c7b4f00 100644 --- a/space/components/account/auth-forms/unique-code.tsx +++ b/space/core/components/account/auth-forms/unique-code.tsx @@ -101,9 +101,10 @@ export const AuthUniqueCodeForm: React.FC = (props) => { disabled /> {uniqueCodeFormData.email.length > 0 && ( -
- -
+ )}
diff --git a/space/components/account/helpers/index.ts b/space/core/components/account/helpers/index.ts similarity index 100% rename from space/components/account/helpers/index.ts rename to space/core/components/account/helpers/index.ts diff --git a/space/core/components/account/helpers/password-strength-meter.tsx b/space/core/components/account/helpers/password-strength-meter.tsx new file mode 100644 index 000000000..342f77efb --- /dev/null +++ b/space/core/components/account/helpers/password-strength-meter.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, useMemo } from "react"; +// import { CircleCheck } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { + E_PASSWORD_STRENGTH, + // PASSWORD_CRITERIA, + getPasswordStrength, +} from "@/helpers/password.helper"; + +type TPasswordStrengthMeter = { + password: string; + isFocused?: boolean; +}; + +export const PasswordStrengthMeter: FC = (props) => { + const { password, isFocused = false } = props; + // derived values + const strength = useMemo(() => getPasswordStrength(password), [password]); + const strengthBars = useMemo(() => { + switch (strength) { + case E_PASSWORD_STRENGTH.EMPTY: { + return { + bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Please enter your password.", + textColor: "text-custom-text-100", + }; + } + case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: { + return { + bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Password length should me more than 8 characters.", + textColor: "text-red-500", + }; + } + case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: { + return { + bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Password is weak.", + textColor: "text-red-500", + }; + } + case E_PASSWORD_STRENGTH.STRENGTH_VALID: { + return { + bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`], + text: "Password is strong.", + textColor: "text-green-500", + }; + } + default: { + return { + bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`], + text: "Please enter your password.", + textColor: "text-custom-text-100", + }; + } + } + }, [strength]); + + const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true; + + if (!isPasswordMeterVisible) return <>; + return ( +
+
+
+ {strengthBars?.bars.map((color, index) => ( +
+ ))} +
+
+ {strengthBars?.text} +
+
+ + {/*
+ {PASSWORD_CRITERIA.map((criteria) => ( +
+ + {criteria.label} +
+ ))} +
*/} +
+ ); +}; diff --git a/space/components/account/index.ts b/space/core/components/account/index.ts similarity index 100% rename from space/components/account/index.ts rename to space/core/components/account/index.ts diff --git a/space/components/account/oauth/github-button.tsx b/space/core/components/account/oauth/github-button.tsx similarity index 100% rename from space/components/account/oauth/github-button.tsx rename to space/core/components/account/oauth/github-button.tsx diff --git a/space/core/components/account/oauth/gitlab-button.tsx b/space/core/components/account/oauth/gitlab-button.tsx new file mode 100644 index 000000000..072a2f628 --- /dev/null +++ b/space/core/components/account/oauth/gitlab-button.tsx @@ -0,0 +1,36 @@ +import { FC } from "react"; +import { useSearchParams } from "next/navigation"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// images +import GitlabLogo from "/public/logos/gitlab-logo.svg"; + +export type GitlabOAuthButtonProps = { + text: string; +}; + +export const GitlabOAuthButton: FC = (props) => { + const searchParams = useSearchParams(); + const nextPath = searchParams.get("next_path") || undefined; + const { text } = props; + // hooks + const { resolvedTheme } = useTheme(); + + const handleSignIn = () => { + window.location.assign(`${API_BASE_URL}/auth/spaces/gitlab/${nextPath ? `?next_path=${nextPath}` : ``}`); + }; + + return ( + + ); +}; diff --git a/space/components/account/oauth/google-button.tsx b/space/core/components/account/oauth/google-button.tsx similarity index 100% rename from space/components/account/oauth/google-button.tsx rename to space/core/components/account/oauth/google-button.tsx diff --git a/space/components/account/oauth/index.ts b/space/core/components/account/oauth/index.ts similarity index 75% rename from space/components/account/oauth/index.ts rename to space/core/components/account/oauth/index.ts index ff953cf20..535e21b14 100644 --- a/space/components/account/oauth/index.ts +++ b/space/core/components/account/oauth/index.ts @@ -1,3 +1,4 @@ export * from "./oauth-options"; export * from "./google-button"; export * from "./github-button"; +export * from "./gitlab-button"; diff --git a/space/components/account/oauth/oauth-options.tsx b/space/core/components/account/oauth/oauth-options.tsx similarity index 79% rename from space/components/account/oauth/oauth-options.tsx rename to space/core/components/account/oauth/oauth-options.tsx index 011c7f189..d514f1b68 100644 --- a/space/components/account/oauth/oauth-options.tsx +++ b/space/core/components/account/oauth/oauth-options.tsx @@ -1,6 +1,6 @@ -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; // components -import { GithubOAuthButton, GoogleOAuthButton } from "@/components/account"; +import { GithubOAuthButton, GitlabOAuthButton, GoogleOAuthButton } from "@/components/account"; // hooks import { useInstance } from "@/hooks/store"; @@ -22,6 +22,7 @@ export const OAuthOptions: React.FC = observer(() => {
)} {config?.is_github_enabled && } + {config?.is_gitlab_enabled && }
); diff --git a/space/components/account/terms-and-conditions.tsx b/space/core/components/account/terms-and-conditions.tsx similarity index 100% rename from space/components/account/terms-and-conditions.tsx rename to space/core/components/account/terms-and-conditions.tsx diff --git a/space/core/components/account/user-logged-in.tsx b/space/core/components/account/user-logged-in.tsx new file mode 100644 index 000000000..4bedc4596 --- /dev/null +++ b/space/core/components/account/user-logged-in.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// components +import { UserAvatar } from "@/components/issues"; +// hooks +import { useUser } from "@/hooks/store"; +// assets +import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import UserLoggedInImage from "@/public/user-logged-in.svg"; + +export const UserLoggedIn = observer(() => { + // store hooks + const { data: user } = useUser(); + // next-themes + const { resolvedTheme } = useTheme(); + + const logo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo; + + if (!user) return null; + + return ( +
+
+
+ Plane logo +
+ +
+ +
+
+
+
+ User already logged in +
+
+

Nice! Just one more step.

+

+ Enter the public-share URL or link of the view or Page you are trying to see in the browser{"'"}s address + bar. +

+
+
+
+ ); +}); diff --git a/space/components/common/index.ts b/space/core/components/common/index.ts similarity index 61% rename from space/components/common/index.ts rename to space/core/components/common/index.ts index c4ea97f3c..1949c069b 100644 --- a/space/components/common/index.ts +++ b/space/core/components/common/index.ts @@ -1,3 +1,2 @@ -export * from "./latest-feature-block"; export * from "./project-logo"; export * from "./logo-spinner"; diff --git a/space/components/common/logo-spinner.tsx b/space/core/components/common/logo-spinner.tsx similarity index 100% rename from space/components/common/logo-spinner.tsx rename to space/core/components/common/logo-spinner.tsx diff --git a/space/components/common/project-logo.tsx b/space/core/components/common/project-logo.tsx similarity index 100% rename from space/components/common/project-logo.tsx rename to space/core/components/common/project-logo.tsx diff --git a/space/components/editor/index.ts b/space/core/components/editor/index.ts similarity index 100% rename from space/components/editor/index.ts rename to space/core/components/editor/index.ts diff --git a/space/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx similarity index 92% rename from space/components/editor/lite-text-editor.tsx rename to space/core/components/editor/lite-text-editor.tsx index b911ebecb..698a6695c 100644 --- a/space/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/lite-text-editor"; +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; // components import { IssueCommentToolbar } from "@/components/editor"; // helpers @@ -64,7 +64,11 @@ export const LiteTextEditor = React.forwardRef rest.onEnterKeyPress?.(e)} + handleSubmit={() => { + if (isMutableRefObject(ref)) { + rest.onEnterKeyPress?.(ref.current?.getHTML() ?? ""); + } + }} isCommentEmpty={isEmpty} editorRef={isMutableRefObject(ref) ? ref : null} /> diff --git a/space/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx similarity index 94% rename from space/components/editor/lite-text-read-only-editor.tsx rename to space/core/components/editor/lite-text-read-only-editor.tsx index 6b16a0b07..0659939ba 100644 --- a/space/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/lite-text-editor"; +import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; // hooks diff --git a/space/components/editor/rich-text-read-only-editor.tsx b/space/core/components/editor/rich-text-read-only-editor.tsx similarity index 94% rename from space/components/editor/rich-text-read-only-editor.tsx rename to space/core/components/editor/rich-text-read-only-editor.tsx index 56694e91f..3fd7cae57 100644 --- a/space/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -1,6 +1,6 @@ import React from "react"; // editor -import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/rich-text-editor"; +import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor"; // helpers import { cn } from "@/helpers/common.helper"; // hooks diff --git a/space/components/editor/toolbar.tsx b/space/core/components/editor/toolbar.tsx similarity index 95% rename from space/components/editor/toolbar.tsx rename to space/core/components/editor/toolbar.tsx index 19bbdab9a..d97c04d5f 100644 --- a/space/components/editor/toolbar.tsx +++ b/space/core/components/editor/toolbar.tsx @@ -1,6 +1,8 @@ +"use client"; + import React, { useEffect, useState, useCallback } from "react"; // editor -import { EditorMenuItemNames, EditorRefApi } from "@plane/lite-text-editor"; +import { EditorMenuItemNames, EditorRefApi } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants @@ -10,7 +12,7 @@ import { cn } from "@/helpers/common.helper"; type Props = { executeCommand: (commandName: EditorMenuItemNames) => void; - handleSubmit: (event: React.MouseEvent) => void; + handleSubmit: () => void; isCommentEmpty: boolean; isSubmitting: boolean; showSubmitButton: boolean; @@ -93,7 +95,7 @@ export const IssueCommentToolbar: React.FC = (props) => { {showSubmitButton && (
+ + ); + })} +
+ ); +}); diff --git a/space/components/issues/navbar/index.tsx b/space/core/components/issues/navbar/root.tsx similarity index 55% rename from space/components/issues/navbar/index.tsx rename to space/core/components/issues/navbar/root.tsx index f5d60b8b0..fb916c771 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/core/components/issues/navbar/root.tsx @@ -1,44 +1,43 @@ "use client"; import { FC } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { Briefcase } from "lucide-react"; // components import { ProjectLogo } from "@/components/common"; -import { NavbarControls } from "@/components/issues/navbar/controls"; -// hooks -import { useProject } from "@/hooks/store"; +import { NavbarControls } from "@/components/issues"; +// store +import { PublishStore } from "@/store/publish/publish.store"; -type IssueNavbarProps = { - workspaceSlug: string; - projectId: string; +type Props = { + publishSettings: PublishStore; }; -const IssueNavbar: FC = observer((props) => { - const { workspaceSlug, projectId } = props; +export const IssuesNavbarRoot: FC = observer((props) => { + const { publishSettings } = props; // hooks - const { project } = useProject(); + const { project_details } = publishSettings; return (
{/* project detail */}
- {project ? ( + {project_details ? ( - + ) : ( )} -
{project?.name || `...`}
+
+ {project_details?.name || `...`} +
- +
); }); - -export default IssueNavbar; diff --git a/space/components/issues/navbar/theme.tsx b/space/core/components/issues/navbar/theme.tsx similarity index 94% rename from space/components/issues/navbar/theme.tsx rename to space/core/components/issues/navbar/theme.tsx index e09bdda60..2078e9d12 100644 --- a/space/components/issues/navbar/theme.tsx +++ b/space/core/components/issues/navbar/theme.tsx @@ -2,7 +2,7 @@ // next theme import { useEffect, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // mobx react lite diff --git a/space/components/issues/navbar/user-avatar.tsx b/space/core/components/issues/navbar/user-avatar.tsx similarity index 99% rename from space/components/issues/navbar/user-avatar.tsx rename to space/core/components/issues/navbar/user-avatar.tsx index 4e0122198..9c1f3311d 100644 --- a/space/components/issues/navbar/user-avatar.tsx +++ b/space/core/components/issues/navbar/user-avatar.tsx @@ -1,7 +1,7 @@ "use client"; import { FC, Fragment, useEffect, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { usePopper } from "react-popper"; diff --git a/space/components/issues/peek-overview/comment/add-comment.tsx b/space/core/components/issues/peek-overview/comment/add-comment.tsx similarity index 71% rename from space/components/issues/peek-overview/comment/add-comment.tsx rename to space/core/components/issues/peek-overview/comment/add-comment.tsx index a1647c9c5..6f9dda0cd 100644 --- a/space/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -1,14 +1,16 @@ +"use client"; + import React, { useRef } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useForm, Controller } from "react-hook-form"; // editor -import { EditorRefApi } from "@plane/lite-text-editor"; +import { EditorRefApi } from "@plane/editor"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // editor components import { LiteTextEditor } from "@/components/editor/lite-text-editor"; // hooks -import { useIssueDetails, useProject, useUser } from "@/hooks/store"; +import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; // types import { Comment } from "@/types/issue"; @@ -17,22 +19,18 @@ const defaultValues: Partial = { }; type Props = { + anchor: string; disabled?: boolean; - workspaceSlug: string; - projectId: string; }; export const AddComment: React.FC = observer((props) => { - // const { disabled = false } = props; - const { workspaceSlug, projectId } = props; + const { anchor } = props; // refs const editorRef = useRef(null); // store hooks - const { workspace } = useProject(); const { peekId: issueId, addIssueComment } = useIssueDetails(); const { data: currentUser } = useUser(); - // derived values - const workspaceId = workspace?.id; + const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); // form info const { handleSubmit, @@ -43,9 +41,9 @@ export const AddComment: React.FC = observer((props) => { } = useForm({ defaultValues }); const onSubmit = async (formData: Comment) => { - if (!workspaceSlug || !projectId || !issueId || isSubmitting || !formData.comment_html) return; + if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; - await addIssueComment(workspaceSlug, projectId, issueId, formData) + await addIssueComment(anchor, issueId, formData) .then(() => { reset(defaultValues); editorRef.current?.clearEditor(); @@ -68,11 +66,11 @@ export const AddComment: React.FC = observer((props) => { control={control} render={({ field: { value, onChange } }) => ( { - if (currentUser) handleSubmit(onSubmit)(e); + onEnterKeyPress={() => { + if (currentUser) handleSubmit(onSubmit)(); }} - workspaceId={workspaceId as string} - workspaceSlug={workspaceSlug} + workspaceId={workspaceID?.toString() ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} ref={editorRef} initialValue={ !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) diff --git a/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx similarity index 89% rename from space/components/issues/peek-overview/comment/comment-detail-card.tsx rename to space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 3ede0333b..4f36cb55f 100644 --- a/space/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -1,34 +1,32 @@ import React, { useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Check, MessageSquare, MoreVertical, X } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; // components -import { EditorRefApi } from "@plane/lite-text-editor"; +import { EditorRefApi } from "@plane/editor"; import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; // hooks -import { useIssueDetails, useProject, useUser } from "@/hooks/store"; +import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; // types import { Comment } from "@/types/issue"; type Props = { - workspaceSlug: string; + anchor: string; comment: Comment; }; export const CommentCard: React.FC = observer((props) => { - const { comment, workspaceSlug } = props; + const { anchor, comment } = props; // store hooks - const { workspace } = useProject(); const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); const { data: currentUser } = useUser(); + const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); const isInIframe = useIsInIframe(); - // derived values - const workspaceId = workspace?.id; // states const [isEditing, setIsEditing] = useState(false); @@ -45,13 +43,13 @@ export const CommentCard: React.FC = observer((props) => { }); const handleDelete = () => { - if (!workspaceSlug || !peekId) return; - deleteIssueComment(workspaceSlug, comment.project, peekId, comment.id); + if (!anchor || !peekId) return; + deleteIssueComment(anchor, peekId, comment.id); }; const handleCommentUpdate = async (formData: Comment) => { - if (!workspaceSlug || !peekId) return; - updateIssueComment(workspaceSlug, comment.project, peekId, comment.id, formData); + if (!anchor || !peekId) return; + updateIssueComment(anchor, peekId, comment.id, formData); setIsEditing(false); editorRef.current?.setEditorValue(formData.comment_html); showEditorRef.current?.setEditorValue(formData.comment_html); @@ -103,9 +101,9 @@ export const CommentCard: React.FC = observer((props) => { name="comment_html" render={({ field: { onChange, value } }) => ( handleSubmit(handleCommentUpdate)()} ref={editorRef} initialValue={value} value={null} @@ -135,7 +133,7 @@ export const CommentCard: React.FC = observer((props) => {
- +
diff --git a/space/components/issues/peek-overview/comment/comment-reactions.tsx b/space/core/components/issues/peek-overview/comment/comment-reactions.tsx similarity index 91% rename from space/components/issues/peek-overview/comment/comment-reactions.tsx rename to space/core/components/issues/peek-overview/comment/comment-reactions.tsx index ed915eff4..e285e5a8a 100644 --- a/space/components/issues/peek-overview/comment/comment-reactions.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-reactions.tsx @@ -1,5 +1,7 @@ +"use client"; + import React from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Tooltip } from "@plane/ui"; // ui @@ -13,12 +15,12 @@ import { useIssueDetails, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; type Props = { + anchor: string; commentId: string; - projectId: string; - workspaceSlug: string; }; export const CommentReactions: React.FC = observer((props) => { + const { anchor, commentId } = props; const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -28,7 +30,6 @@ export const CommentReactions: React.FC = observer((props) => { const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - const { commentId, projectId, workspaceSlug } = props; // hooks const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails(); const { data: user } = useUser(); @@ -40,13 +41,13 @@ export const CommentReactions: React.FC = observer((props) => { const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id); const handleAddReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !peekId) return; - addCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); + if (!anchor || !peekId) return; + addCommentReaction(anchor, peekId, commentId, reactionHex); }; const handleRemoveReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !peekId) return; - removeCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); + if (!anchor || !peekId) return; + removeCommentReaction(anchor, peekId, commentId, reactionHex); }; const handleReactionClick = (reactionHex: string) => { diff --git a/space/components/issues/peek-overview/comment/index.ts b/space/core/components/issues/peek-overview/comment/index.ts similarity index 100% rename from space/components/issues/peek-overview/comment/index.ts rename to space/core/components/issues/peek-overview/comment/index.ts diff --git a/space/components/issues/peek-overview/full-screen-peek-view.tsx b/space/core/components/issues/peek-overview/full-screen-peek-view.tsx similarity index 83% rename from space/components/issues/peek-overview/full-screen-peek-view.tsx rename to space/core/components/issues/peek-overview/full-screen-peek-view.tsx index f5918de43..c169173db 100644 --- a/space/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/space/core/components/issues/peek-overview/full-screen-peek-view.tsx @@ -1,4 +1,6 @@ -import { observer } from "mobx-react-lite"; +"use client"; + +import { observer } from "mobx-react"; // components import { Loader } from "@plane/ui"; import { @@ -11,14 +13,13 @@ import { import { IIssue } from "@/types/issue"; type Props = { + anchor: string; handleClose: () => void; issueDetails: IIssue | undefined; - workspaceSlug: string; - projectId: string; }; export const FullScreenPeekView: React.FC = observer((props) => { - const { handleClose, issueDetails, workspaceSlug, projectId } = props; + const { anchor, handleClose, issueDetails } = props; return (
@@ -30,17 +31,13 @@ export const FullScreenPeekView: React.FC = observer((props) => {
{/* issue title and description */}
- +
{/* divider */}
{/* issue activity/comments */}
- +
) : ( diff --git a/space/components/issues/peek-overview/header.tsx b/space/core/components/issues/peek-overview/header.tsx similarity index 57% rename from space/components/issues/peek-overview/header.tsx rename to space/core/components/issues/peek-overview/header.tsx index 0e9b93ab9..156137f60 100644 --- a/space/components/issues/peek-overview/header.tsx +++ b/space/core/components/issues/peek-overview/header.tsx @@ -1,10 +1,11 @@ +"use client"; + import React from "react"; -import { observer } from "mobx-react-lite"; -import { MoveRight } from "lucide-react"; +import { observer } from "mobx-react"; +import { Link2, MoveRight } from "lucide-react"; import { Listbox, Transition } from "@headlessui/react"; // ui -import { setToast, TOAST_TYPE } from "@plane/ui"; -import { Icon } from "@/components/ui"; +import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks @@ -18,21 +19,21 @@ type Props = { issueDetails: IIssue | undefined; }; -const peekModes: { +const PEEK_MODES: { key: IPeekMode; - icon: string; + icon: any; label: string; }[] = [ - { key: "side", icon: "side_navigation", label: "Side Peek" }, + { key: "side", icon: SidePanelIcon, label: "Side Peek" }, { key: "modal", - icon: "dialogs", - label: "Modal Peek", + icon: CenterPanelIcon, + label: "Modal", }, { key: "full", - icon: "nearby", - label: "Full Screen Peek", + icon: FullScreenPanelIcon, + label: "Full Screen", }, ]; @@ -47,20 +48,22 @@ export const PeekOverviewHeader: React.FC = observer((props) => { copyTextToClipboard(urlToCopy).then(() => { setToast({ - type: TOAST_TYPE.INFO, + type: TOAST_TYPE.SUCCESS, title: "Link copied!", - message: "Issue link copied to clipboard", + message: "Issue link copied to clipboard.", }); }); }; + const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon; + return ( <>
{peekMode === "side" && ( - )} = observer((props) => { onChange={(val) => setPeekMode(val)} className="relative flex-shrink-0 text-left" > - - m.key === peekMode)?.icon ?? ""} className="text-[1rem]" /> + + = observer((props) => { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
- {peekModes.map((mode) => ( + {PEEK_MODES.map((mode) => ( `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || selected ? "bg-custom-background-80" : "" + active ? "bg-custom-background-80" : "" } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` } > - {({ selected }) => ( -
-
-
- - {mode.label} -
-
- {selected && } -
- )} +
+ + {mode.label} +
))}
@@ -117,8 +112,13 @@ export const PeekOverviewHeader: React.FC = observer((props) => {
{isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && (
-
)} diff --git a/space/components/issues/peek-overview/index.ts b/space/core/components/issues/peek-overview/index.ts similarity index 100% rename from space/components/issues/peek-overview/index.ts rename to space/core/components/issues/peek-overview/index.ts diff --git a/space/core/components/issues/peek-overview/issue-activity.tsx b/space/core/components/issues/peek-overview/issue-activity.tsx new file mode 100644 index 000000000..f027e506f --- /dev/null +++ b/space/core/components/issues/peek-overview/issue-activity.tsx @@ -0,0 +1,66 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Button } from "@plane/ui"; +// components +import { CommentCard, AddComment } from "@/components/issues/peek-overview"; +import { Icon } from "@/components/ui"; +// hooks +import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; +// types +import { IIssue } from "@/types/issue"; + +type Props = { + anchor: string; + issueDetails: IIssue; +}; + +export const PeekOverviewIssueActivity: React.FC = observer((props) => { + const { anchor } = props; + // router + const pathname = usePathname(); + // store hooks + const { details, peekId } = useIssueDetails(); + const { data: currentUser } = useUser(); + const { canComment } = usePublish(anchor); + // derived values + const comments = details[peekId || ""]?.comments || []; + const isInIframe = useIsInIframe(); + + return ( +
+

Comments

+
+
+ {comments.map((comment) => ( + + ))} +
+ {!isInIframe && + (currentUser ? ( + <> + {canComment && ( +
+ +
+ )} + + ) : ( +
+

+ + Sign in to add your comment +

+ + + +
+ ))} +
+
+ ); +}); diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx new file mode 100644 index 000000000..97a659554 --- /dev/null +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -0,0 +1,37 @@ +// components +import { RichTextReadOnlyEditor } from "@/components/editor"; +import { IssueReactions } from "@/components/issues/peek-overview"; +// types +import { IIssue } from "@/types/issue"; + +type Props = { + anchor: string; + issueDetails: IIssue; +}; + +export const PeekOverviewIssueDetails: React.FC = (props) => { + const { anchor, issueDetails } = props; + + const description = issueDetails.description_html; + + return ( +
+
+ {issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id} +
+

{issueDetails.name}

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

" && ( +

" + : description + } + /> + )} + +
+ ); +}; diff --git a/space/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx similarity index 85% rename from space/components/issues/peek-overview/issue-emoji-reactions.tsx rename to space/core/components/issues/peek-overview/issue-emoji-reactions.tsx index 4a0e61554..d2a282ace 100644 --- a/space/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -1,5 +1,6 @@ -import { useEffect } from "react"; -import { observer } from "mobx-react-lite"; +"use client"; + +import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; // lib import { Tooltip } from "@plane/ui"; @@ -11,11 +12,12 @@ import { queryParamGenerator } from "@/helpers/query-param-generator"; import { useIssueDetails, useUser } from "@/hooks/store"; type IssueEmojiReactionsProps = { - workspaceSlug: string; - projectId: string; + anchor: string; }; export const IssueEmojiReactions: React.FC = observer((props) => { + const { anchor } = props; + // router const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -25,11 +27,9 @@ export const IssueEmojiReactions: React.FC = observer( const state = searchParams.get("state") || undefined; const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - - const { workspaceSlug, projectId } = props; - // store + // store hooks const issueDetailsStore = useIssueDetails(); - const { data: user, fetchCurrentUser } = useUser(); + const { data: user } = useUser(); const issueId = issueDetailsStore.peekId; const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; @@ -38,13 +38,13 @@ export const IssueEmojiReactions: React.FC = observer( const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id); const handleAddReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - issueDetailsStore.addIssueReaction(workspaceSlug.toString(), projectId.toString(), issueId, reactionHex); + if (!issueId) return; + issueDetailsStore.addIssueReaction(anchor, issueId, reactionHex); }; const handleRemoveReaction = (reactionHex: string) => { - if (!workspaceSlug || !projectId || !issueId) return; - issueDetailsStore.removeIssueReaction(workspaceSlug.toString(), projectId.toString(), issueId, reactionHex); + if (!issueId) return; + issueDetailsStore.removeIssueReaction(anchor, issueId, reactionHex); }; const handleReactionClick = (reactionHex: string) => { @@ -53,11 +53,6 @@ export const IssueEmojiReactions: React.FC = observer( else handleAddReaction(reactionHex); }; - useEffect(() => { - if (user) return; - fetchCurrentUser(); - }, [user, fetchCurrentUser]); - // derived values const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); diff --git a/space/components/issues/peek-overview/issue-properties.tsx b/space/core/components/issues/peek-overview/issue-properties.tsx similarity index 55% rename from space/components/issues/peek-overview/issue-properties.tsx rename to space/core/components/issues/peek-overview/issue-properties.tsx index 08d22b312..8b81f8c5e 100644 --- a/space/components/issues/peek-overview/issue-properties.tsx +++ b/space/core/components/issues/peek-overview/issue-properties.tsx @@ -1,16 +1,19 @@ +"use client"; + +import { CalendarCheck2, Signal } from "lucide-react"; // ui -import { StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; -// icons +import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { Icon } from "@/components/ui"; // constants -import { issueGroupFilter, issuePriorityFilter } from "@/constants/issue"; +import { issuePriorityFilter } from "@/constants/issue"; // helpers -import { renderFullDate } from "@/helpers/date-time.helper"; +import { cn } from "@/helpers/common.helper"; +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; // types import { IIssue, IPeekMode } from "@/types/issue"; -// components -import { dueDateIconDetails } from "../board-views/block-due-date"; type Props = { issueDetails: IIssue; @@ -19,12 +22,9 @@ type Props = { export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mode }) => { const state = issueDetails.state_detail; - const stateGroup = issueGroupFilter(state.group); const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null; - const dueDateIcon = dueDateIconDetails(issueDetails.target_date, state.group); - const handleCopyLink = () => { const urlToCopy = window.location.href; @@ -51,28 +51,22 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod
)} -
-
-
- - State +
+
+
+ + State
-
- {stateGroup && ( -
-
- - {addSpaceIfCamelCase(state?.name ?? "")} -
-
- )} +
+ + {addSpaceIfCamelCase(state?.name ?? "")}
-
-
- - Priority +
+
+ + Priority
= ({ issueDetails, mod
-
-
- - Due date + +
+
+ + Due date
{issueDetails.target_date ? ( -
- - {dueDateIcon.iconName} - - {renderFullDate(issueDetails.target_date)} +
+ + {renderFormattedDate(issueDetails.target_date)}
) : ( Empty diff --git a/space/core/components/issues/peek-overview/issue-reaction.tsx b/space/core/components/issues/peek-overview/issue-reaction.tsx new file mode 100644 index 000000000..953852d05 --- /dev/null +++ b/space/core/components/issues/peek-overview/issue-reaction.tsx @@ -0,0 +1,31 @@ +import { observer } from "mobx-react"; +import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview"; +// hooks +import { usePublish } from "@/hooks/store"; +import useIsInIframe from "@/hooks/use-is-in-iframe"; + +type Props = { + anchor: string; +}; + +export const IssueReactions: React.FC = observer((props) => { + const { anchor } = props; + // store hooks + const { canVote, canReact } = usePublish(anchor); + const isInIframe = useIsInIframe(); + + return ( +
+ {canVote && ( +
+ +
+ )} + {!isInIframe && canReact && ( +
+ +
+ )} +
+ ); +}); diff --git a/space/components/issues/peek-overview/issue-vote-reactions.tsx b/space/core/components/issues/peek-overview/issue-vote-reactions.tsx similarity index 88% rename from space/components/issues/peek-overview/issue-vote-reactions.tsx rename to space/core/components/issues/peek-overview/issue-vote-reactions.tsx index 1e565e862..4e30e69cd 100644 --- a/space/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/core/components/issues/peek-overview/issue-vote-reactions.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; -import { observer } from "mobx-react-lite"; +import { useState } from "react"; +import { observer } from "mobx-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Tooltip } from "@plane/ui"; // helpers @@ -12,11 +12,14 @@ import { useIssueDetails, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; type TIssueVotes = { - workspaceSlug: string; - projectId: string; + anchor: string; }; export const IssueVotes: React.FC = observer((props) => { + const { anchor } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + // router const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); @@ -26,13 +29,9 @@ export const IssueVotes: React.FC = observer((props) => { const state = searchParams.get("state") || undefined; const priority = searchParams.get("priority") || undefined; const labels = searchParams.get("labels") || undefined; - - const { workspaceSlug, projectId } = props; - // states - const [isSubmitting, setIsSubmitting] = useState(false); - + // store hooks const issueDetailsStore = useIssueDetails(); - const { data: user, fetchCurrentUser } = useUser(); + const { data: user } = useUser(); const isInIframe = useIsInIframe(); @@ -47,28 +46,22 @@ export const IssueVotes: React.FC = observer((props) => { const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id); const handleVote = async (e: any, voteValue: 1 | -1) => { - if (!workspaceSlug || !projectId || !issueId) return; + if (!issueId) return; setIsSubmitting(true); const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue); - if (actionPerformed) - await issueDetailsStore.removeIssueVote(workspaceSlug.toString(), projectId.toString(), issueId); - else - await issueDetailsStore.addIssueVote(workspaceSlug.toString(), projectId.toString(), issueId, { + if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId); + else { + await issueDetailsStore.addIssueVote(anchor, issueId, { vote: voteValue, }); + } setIsSubmitting(false); }; - useEffect(() => { - if (user) return; - - fetchCurrentUser(); - }, [user, fetchCurrentUser]); - const VOTES_LIMIT = 1000; // derived values diff --git a/space/components/issues/peek-overview/layout.tsx b/space/core/components/issues/peek-overview/layout.tsx similarity index 76% rename from space/components/issues/peek-overview/layout.tsx rename to space/core/components/issues/peek-overview/layout.tsx index 453cc59f3..39f5d6216 100644 --- a/space/components/issues/peek-overview/layout.tsx +++ b/space/core/components/issues/peek-overview/layout.tsx @@ -1,7 +1,7 @@ "use client"; import { FC, Fragment, useEffect, useState } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Dialog, Transition } from "@headlessui/react"; // components @@ -10,13 +10,12 @@ import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overv import { useIssue, useIssueDetails } from "@/hooks/store"; type TIssuePeekOverview = { - workspaceSlug: string; - projectId: string; + anchor: string; peekId: string; }; export const IssuePeekOverview: FC = observer((props) => { - const { workspaceSlug, projectId, peekId } = props; + const { anchor, peekId } = props; const router = useRouter(); const searchParams = useSearchParams(); // query params @@ -34,21 +33,23 @@ export const IssuePeekOverview: FC = observer((props) => { const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; useEffect(() => { - if (workspaceSlug && projectId && peekId && issueStore.issues && issueStore.issues.length > 0) { + if (anchor && peekId && issueStore.issues && issueStore.issues.length > 0) { if (!issueDetails) { - issueDetailStore.fetchIssueDetails(workspaceSlug.toString(), projectId.toString(), peekId.toString()); + issueDetailStore.fetchIssueDetails(anchor, peekId.toString()); } } - }, [workspaceSlug, projectId, issueDetailStore, issueDetails, peekId, issueStore.issues]); + }, [anchor, issueDetailStore, issueDetails, peekId, issueStore.issues]); const handleClose = () => { issueDetailStore.setPeekId(null); - let queryParams: any = { board: board }; + let queryParams: any = { + board, + }; if (priority && priority.length > 0) queryParams = { ...queryParams, priority: priority }; if (state && state.length > 0) queryParams = { ...queryParams, state: state }; if (labels && labels.length > 0) queryParams = { ...queryParams, labels: labels }; queryParams = new URLSearchParams(queryParams).toString(); - router.push(`/${workspaceSlug}/${projectId}?${queryParams}`); + router.push(`/issues/${anchor}?${queryParams}`); }; useEffect(() => { @@ -80,12 +81,7 @@ export const IssuePeekOverview: FC = observer((props) => { leaveTo="translate-x-full" > - +
@@ -119,20 +115,10 @@ export const IssuePeekOverview: FC = observer((props) => { }`} > {issueDetailStore.peekMode === "modal" && ( - + )} {issueDetailStore.peekMode === "full" && ( - + )}
diff --git a/space/components/issues/peek-overview/side-peek-view.tsx b/space/core/components/issues/peek-overview/side-peek-view.tsx similarity index 74% rename from space/components/issues/peek-overview/side-peek-view.tsx rename to space/core/components/issues/peek-overview/side-peek-view.tsx index a0b544bdd..d608c0478 100644 --- a/space/components/issues/peek-overview/side-peek-view.tsx +++ b/space/core/components/issues/peek-overview/side-peek-view.tsx @@ -1,4 +1,6 @@ -import { observer } from "mobx-react-lite"; +"use client"; + +import { observer } from "mobx-react"; // components import { Loader } from "@plane/ui"; import { @@ -7,22 +9,21 @@ import { PeekOverviewIssueDetails, PeekOverviewIssueProperties, } from "@/components/issues/peek-overview"; -// hooks -import { useProject } from "@/hooks/store"; +// store hooks +import { usePublish } from "@/hooks/store"; // types import { IIssue } from "@/types/issue"; type Props = { + anchor: string; handleClose: () => void; issueDetails: IIssue | undefined; - workspaceSlug: string; - projectId: string; }; export const SidePeekView: React.FC = observer((props) => { - const { handleClose, issueDetails, workspaceSlug, projectId } = props; - - const { settings } = useProject(); + const { anchor, handleClose, issueDetails } = props; + // store hooks + const { canComment } = usePublish(anchor); return (
@@ -33,7 +34,7 @@ export const SidePeekView: React.FC = observer((props) => {
{/* issue title and description */}
- +
{/* issue properties */}
@@ -42,13 +43,9 @@ export const SidePeekView: React.FC = observer((props) => { {/* divider */}
{/* issue activity/comments */} - {settings?.comments && ( + {canComment && (
- +
)}
diff --git a/packages/editor/document-editor/src/ui/menu/icon.tsx b/space/core/components/ui/icon.tsx similarity index 100% rename from packages/editor/document-editor/src/ui/menu/icon.tsx rename to space/core/components/ui/icon.tsx diff --git a/space/components/ui/index.ts b/space/core/components/ui/index.ts similarity index 68% rename from space/components/ui/index.ts rename to space/core/components/ui/index.ts index 1e523d5dd..ccd2303c4 100644 --- a/space/components/ui/index.ts +++ b/space/core/components/ui/index.ts @@ -1,3 +1,2 @@ -export * from "./dropdown"; export * from "./icon"; export * from "./reaction-selector"; diff --git a/space/components/ui/reaction-selector.tsx b/space/core/components/ui/reaction-selector.tsx similarity index 100% rename from space/components/ui/reaction-selector.tsx rename to space/core/components/ui/reaction-selector.tsx diff --git a/space/components/views/auth.tsx b/space/core/components/views/auth.tsx similarity index 97% rename from space/components/views/auth.tsx rename to space/core/components/views/auth.tsx index 538519696..fb68d8fba 100644 --- a/space/components/views/auth.tsx +++ b/space/core/components/views/auth.tsx @@ -1,6 +1,6 @@ "use client"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; diff --git a/space/core/components/views/index.ts b/space/core/components/views/index.ts new file mode 100644 index 000000000..97ccf7649 --- /dev/null +++ b/space/core/components/views/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/space/constants/editor.ts b/space/core/constants/editor.ts similarity index 98% rename from space/constants/editor.ts rename to space/core/constants/editor.ts index eb8b99495..698faf8cb 100644 --- a/space/constants/editor.ts +++ b/space/core/constants/editor.ts @@ -19,7 +19,7 @@ import { Underline, } from "lucide-react"; // editor -import { EditorMenuItemNames } from "@plane/lite-text-editor"; +import { EditorMenuItemNames } from "@plane/editor"; type TEditorTypes = "lite" | "document"; diff --git a/space/core/constants/issue.ts b/space/core/constants/issue.ts new file mode 100644 index 000000000..5d858a70e --- /dev/null +++ b/space/core/constants/issue.ts @@ -0,0 +1,78 @@ +import { Kanban, List } from "lucide-react"; +// types +import { TIssuePriorities } from "@plane/types"; +import { TIssueLayout, TIssueFilterKeys, TIssueFilterPriorityObject } from "@/types/issue"; + +// issue filters +export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]> } = { + list: { + filters: ["priority", "state", "labels"], + }, + kanban: { + filters: ["priority", "state", "labels"], + }, + calendar: { + filters: ["priority", "state", "labels"], + }, + spreadsheet: { + filters: ["priority", "state", "labels"], + }, + gantt: { + filters: ["priority", "state", "labels"], + }, +}; + +export const ISSUE_LAYOUTS: { + key: TIssueLayout; + title: string; + icon: any; +}[] = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + // { key: "calendar", title: "Calendar", icon: Calendar }, + // { key: "spreadsheet", title: "Spreadsheet", icon: Sheet }, + // { key: "gantt", title: "Gantt chart", icon: GanttChartSquare }, +]; + +export const issuePriorityFilters: TIssueFilterPriorityObject[] = [ + { + key: "urgent", + title: "Urgent", + className: "bg-red-500 border-red-500 text-white", + icon: "error", + }, + { + key: "high", + title: "High", + className: "text-orange-500 border-custom-border-300", + icon: "signal_cellular_alt", + }, + { + key: "medium", + title: "Medium", + className: "text-yellow-500 border-custom-border-300", + icon: "signal_cellular_alt_2_bar", + }, + { + key: "low", + title: "Low", + className: "text-green-500 border-custom-border-300", + icon: "signal_cellular_alt_1_bar", + }, + { + key: "none", + title: "None", + className: "text-gray-500 border-custom-border-300", + icon: "block", + }, +]; + +export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilterPriorityObject | undefined => { + const currentIssuePriority: TIssueFilterPriorityObject | undefined = + issuePriorityFilters && issuePriorityFilters.length > 0 + ? issuePriorityFilters.find((_priority) => _priority.key === priorityKey) + : undefined; + + if (currentIssuePriority) return currentIssuePriority; + return undefined; +}; diff --git a/space/core/constants/seo.ts b/space/core/constants/seo.ts new file mode 100644 index 000000000..f681ab8b2 --- /dev/null +++ b/space/core/constants/seo.ts @@ -0,0 +1,7 @@ +export const SITE_NAME = "Plane Publish | Make your Plane boards and roadmaps pubic with just one-click. "; +export const SITE_TITLE = "Plane Publish | Make your Plane boards public with one-click"; +export const SITE_DESCRIPTION = "Plane Publish is a customer feedback management tool built on top of plane.so"; +export const SITE_KEYWORDS = + "software development, customer feedback, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration"; +export const SITE_URL = "https://app.plane.so/"; +export const TWITTER_USER_NAME = "planepowers"; diff --git a/web/constants/state.ts b/space/core/constants/state.ts similarity index 100% rename from web/constants/state.ts rename to space/core/constants/state.ts diff --git a/space/hooks/store/index.ts b/space/core/hooks/store/index.ts similarity index 86% rename from space/hooks/store/index.ts rename to space/core/hooks/store/index.ts index 76b6f9315..3f82613d5 100644 --- a/space/hooks/store/index.ts +++ b/space/core/hooks/store/index.ts @@ -1,5 +1,5 @@ +export * from "./publish"; export * from "./use-instance"; -export * from "./use-project"; export * from "./use-issue"; export * from "./use-user"; export * from "./use-user-profile"; diff --git a/space/core/hooks/store/publish/index.ts b/space/core/hooks/store/publish/index.ts new file mode 100644 index 000000000..a7b42ad5b --- /dev/null +++ b/space/core/hooks/store/publish/index.ts @@ -0,0 +1,2 @@ +export * from "./use-publish-list"; +export * from "./use-publish"; diff --git a/space/core/hooks/store/publish/use-publish-list.ts b/space/core/hooks/store/publish/use-publish-list.ts new file mode 100644 index 000000000..aa50c295a --- /dev/null +++ b/space/core/hooks/store/publish/use-publish-list.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IPublishListStore } from "@/store/publish/publish_list.store"; + +export const usePublishList = (): IPublishListStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublishList must be used within StoreProvider"); + return context.publishList; +}; diff --git a/space/core/hooks/store/publish/use-publish.ts b/space/core/hooks/store/publish/use-publish.ts new file mode 100644 index 000000000..3d920e8cb --- /dev/null +++ b/space/core/hooks/store/publish/use-publish.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { PublishStore } from "@/store/publish/publish.store"; + +export const usePublish = (anchor: string): PublishStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("usePublish must be used within StoreProvider"); + return context.publishList.publishMap?.[anchor] ?? {}; +}; diff --git a/space/hooks/store/use-instance.ts b/space/core/hooks/store/use-instance.ts similarity index 100% rename from space/hooks/store/use-instance.ts rename to space/core/hooks/store/use-instance.ts diff --git a/space/hooks/store/use-issue-details.tsx b/space/core/hooks/store/use-issue-details.tsx similarity index 100% rename from space/hooks/store/use-issue-details.tsx rename to space/core/hooks/store/use-issue-details.tsx diff --git a/space/hooks/store/use-issue-filter.ts b/space/core/hooks/store/use-issue-filter.ts similarity index 100% rename from space/hooks/store/use-issue-filter.ts rename to space/core/hooks/store/use-issue-filter.ts diff --git a/space/hooks/store/use-issue.ts b/space/core/hooks/store/use-issue.ts similarity index 100% rename from space/hooks/store/use-issue.ts rename to space/core/hooks/store/use-issue.ts diff --git a/space/hooks/store/use-user-profile.ts b/space/core/hooks/store/use-user-profile.ts similarity index 100% rename from space/hooks/store/use-user-profile.ts rename to space/core/hooks/store/use-user-profile.ts diff --git a/space/hooks/store/use-user.ts b/space/core/hooks/store/use-user.ts similarity index 100% rename from space/hooks/store/use-user.ts rename to space/core/hooks/store/use-user.ts diff --git a/space/hooks/use-clipboard-write-permission.tsx b/space/core/hooks/use-clipboard-write-permission.tsx similarity index 100% rename from space/hooks/use-clipboard-write-permission.tsx rename to space/core/hooks/use-clipboard-write-permission.tsx diff --git a/space/hooks/use-is-in-iframe.tsx b/space/core/hooks/use-is-in-iframe.tsx similarity index 100% rename from space/hooks/use-is-in-iframe.tsx rename to space/core/hooks/use-is-in-iframe.tsx diff --git a/space/hooks/use-mention.tsx b/space/core/hooks/use-mention.tsx similarity index 92% rename from space/hooks/use-mention.tsx rename to space/core/hooks/use-mention.tsx index 8b2d69720..9e33f7d90 100644 --- a/space/hooks/use-mention.tsx +++ b/space/core/hooks/use-mention.tsx @@ -1,7 +1,9 @@ import { useRef, useEffect } from "react"; import useSWR from "swr"; +// types import { IUser } from "@plane/types"; -import { UserService } from "services/user.service"; +// services +import { UserService } from "@/services/user.service"; export const useMention = () => { const userService = new UserService(); diff --git a/space/hooks/use-outside-click.tsx b/space/core/hooks/use-outside-click.tsx similarity index 100% rename from space/hooks/use-outside-click.tsx rename to space/core/hooks/use-outside-click.tsx diff --git a/space/hooks/use-timer.tsx b/space/core/hooks/use-timer.tsx similarity index 100% rename from space/hooks/use-timer.tsx rename to space/core/hooks/use-timer.tsx diff --git a/space/lib/instance-provider.tsx b/space/core/lib/instance-provider.tsx similarity index 98% rename from space/lib/instance-provider.tsx rename to space/core/lib/instance-provider.tsx index 8447d8a5f..4f28dbcf9 100644 --- a/space/lib/instance-provider.tsx +++ b/space/core/lib/instance-provider.tsx @@ -1,7 +1,7 @@ "use client"; import { ReactNode } from "react"; -import { observer } from "mobx-react-lite"; +import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; diff --git a/space/lib/store-provider.tsx b/space/core/lib/store-provider.tsx similarity index 94% rename from space/lib/store-provider.tsx rename to space/core/lib/store-provider.tsx index b77a981f8..c1256ddc2 100644 --- a/space/lib/store-provider.tsx +++ b/space/core/lib/store-provider.tsx @@ -2,8 +2,8 @@ import { ReactNode, createContext } from "react"; import { ThemeProvider } from "next-themes"; -// store -import { RootStore } from "@/store/root.store"; +// plane web store +import { RootStore } from "@/plane-web/store/root.store"; let rootStore = new RootStore(); diff --git a/space/lib/toast-provider.tsx b/space/core/lib/toast-provider.tsx similarity index 100% rename from space/lib/toast-provider.tsx rename to space/core/lib/toast-provider.tsx diff --git a/space/services/api.service.ts b/space/core/services/api.service.ts similarity index 100% rename from space/services/api.service.ts rename to space/core/services/api.service.ts diff --git a/space/services/auth.service.ts b/space/core/services/auth.service.ts similarity index 100% rename from space/services/auth.service.ts rename to space/core/services/auth.service.ts diff --git a/space/services/file.service.ts b/space/core/services/file.service.ts similarity index 69% rename from space/services/file.service.ts rename to space/core/services/file.service.ts index 0e277af1e..9fe06cd36 100644 --- a/space/services/file.service.ts +++ b/space/core/services/file.service.ts @@ -4,30 +4,6 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; -interface UnSplashImage { - id: string; - created_at: Date; - updated_at: Date; - promoted_at: Date; - width: number; - height: number; - color: string; - blur_hash: string; - description: null; - alt_description: string; - urls: UnSplashImageUrls; - [key: string]: any; -} - -interface UnSplashImageUrls { - raw: string; - full: string; - regular: string; - small: string; - thumb: string; - small_s3: string; -} - class FileService extends APIService { private cancelSource: any; @@ -123,40 +99,6 @@ class FileService extends APIService { throw error?.response?.data; }); } - - async deleteFile(workspaceId: string, assetUrl: string): Promise { - const lastIndex = assetUrl.lastIndexOf("/"); - const assetId = assetUrl.substring(lastIndex + 1); - - return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async uploadUserFile(file: FormData): Promise { - return this.post(`/api/users/file-assets/`, file, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async deleteUserFile(assetUrl: string): Promise { - const lastIndex = assetUrl.lastIndexOf("/"); - const assetId = assetUrl.substring(lastIndex + 1); - - return this.delete(`/api/users/file-assets/${assetId}`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } const fileService = new FileService(); diff --git a/space/services/instance.service.ts b/space/core/services/instance.service.ts similarity index 100% rename from space/services/instance.service.ts rename to space/core/services/instance.service.ts diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts new file mode 100644 index 000000000..f86481812 --- /dev/null +++ b/space/core/services/issue.service.ts @@ -0,0 +1,133 @@ +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; +// types +import { TIssuesResponse } from "@/types/issue"; + +class IssueService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchPublicIssues(anchor: string, params: any): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getIssueById(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getIssueVotes(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async createIssueVote(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteIssueVote(anchor: string, issueID: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getIssueReactions(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async createIssueReaction(anchor: string, issueID: string, data: any): Promise { + return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteIssueReaction(anchor: string, issueID: string, reactionId: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/${reactionId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async getIssueComments(anchor: string, issueID: string): Promise { + return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + 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) => { + throw error?.response; + }); + } + + async updateIssueComment(anchor: string, issueID: string, commentId: string, data: any): Promise { + return this.patch(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteIssueComment(anchor: string, issueID: string, commentId: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async createCommentReaction( + anchor: string, + commentId: string, + data: { + reaction: string; + } + ): Promise { + return this.post(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteCommentReaction(anchor: string, commentId: string, reactionHex: string): Promise { + return this.delete(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/${reactionHex}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default IssueService; diff --git a/space/services/project-member.service.ts b/space/core/services/project-member.service.ts similarity index 57% rename from space/services/project-member.service.ts rename to space/core/services/project-member.service.ts index 264d53386..722380efa 100644 --- a/space/services/project-member.service.ts +++ b/space/core/services/project-member.service.ts @@ -9,16 +9,16 @@ export class ProjectMemberService extends APIService { super(API_BASE_URL); } - async fetchProjectMembers(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`) + async fetchProjectMembers(anchor: string): Promise { + return this.get(`/api/anchor/${anchor}/members/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } - async getProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`) + async getProjectMember(anchor: string, memberID: string): Promise { + return this.get(`/api/anchor/${anchor}/members/${memberID}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/space/core/services/publish.service.ts b/space/core/services/publish.service.ts new file mode 100644 index 000000000..896f36ee9 --- /dev/null +++ b/space/core/services/publish.service.ts @@ -0,0 +1,30 @@ +// types +import { TProjectPublishSettings } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +class PublishService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchPublishSettings(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/settings/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async fetchAnchorFromProjectDetails(workspaceSlug: string, projectID: string): Promise { + return this.get(`/api/public/workspaces/${workspaceSlug}/projects/${projectID}/anchor/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default PublishService; diff --git a/space/services/user.service.ts b/space/core/services/user.service.ts similarity index 100% rename from space/services/user.service.ts rename to space/core/services/user.service.ts diff --git a/space/store/instance.store.ts b/space/core/store/instance.store.ts similarity index 94% rename from space/store/instance.store.ts rename to space/core/store/instance.store.ts index d2e6918a3..970824404 100644 --- a/space/store/instance.store.ts +++ b/space/core/store/instance.store.ts @@ -4,8 +4,8 @@ import { observable, action, makeObservable, runInAction } from "mobx"; import { IInstance, IInstanceConfig } from "@plane/types"; // services import { InstanceService } from "@/services/instance.service"; -// store types -import { RootStore } from "@/store/root.store"; +// store +import { CoreRootStore } from "@/store/root.store"; type TError = { status: string; @@ -35,7 +35,7 @@ export class InstanceStore implements IInstanceStore { // services instanceService; - constructor(private store: RootStore) { + constructor(private store: CoreRootStore) { makeObservable(this, { // observable isLoading: observable.ref, diff --git a/space/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts similarity index 51% rename from space/store/issue-detail.store.ts rename to space/core/store/issue-detail.store.ts index 03f611cc0..03ba4bd86 100644 --- a/space/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -2,116 +2,110 @@ import { makeObservable, observable, action, runInAction } from "mobx"; import { v4 as uuidv4 } from "uuid"; // services import IssueService from "@/services/issue.service"; -// store types -import { RootStore } from "@/store/root.store"; +// store +import { CoreRootStore } from "@/store/root.store"; // types import { IIssue, IPeekMode, IVote } from "@/types/issue"; export interface IIssueDetailStore { loader: boolean; error: any; - // peek info + // observables peekId: string | null; peekMode: IPeekMode; details: { [key: string]: IIssue; }; - // peek actions - setPeekId: (issueId: string | null) => void; + // actions + setPeekId: (issueID: string | null) => void; setPeekMode: (mode: IPeekMode) => void; - // issue details - fetchIssueDetails: (workspaceId: string, projectId: string, issueId: string) => void; - // issue comments - addIssueComment: (workspaceId: string, projectId: string, issueId: string, data: any) => Promise; - updateIssueComment: ( - workspaceId: string, - projectId: string, - issueId: string, - comment_id: string, - data: any - ) => Promise; - deleteIssueComment: (workspaceId: string, projectId: string, issueId: string, comment_id: string) => void; - addCommentReaction: ( - workspaceId: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => void; - removeCommentReaction: ( - workspaceId: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => void; - // issue reactions - addIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void; - removeIssueReaction: (workspaceId: string, projectId: string, issueId: string, reactionHex: string) => void; - // issue votes - addIssueVote: (workspaceId: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => Promise; - removeIssueVote: (workspaceId: string, projectId: string, issueId: string) => Promise; + // issue actions + fetchIssueDetails: (anchor: string, issueID: string) => void; + // comment actions + 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; + addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; + removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; + // reaction actions + addIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + removeIssueReaction: (anchor: string, issueID: string, reactionHex: string) => void; + // vote actions + addIssueVote: (anchor: string, issueID: string, data: { vote: 1 | -1 }) => Promise; + removeIssueVote: (anchor: string, issueID: string) => Promise; } export class IssueDetailStore implements IIssueDetailStore { loader: boolean = false; error: any = null; + // observables peekId: string | null = null; peekMode: IPeekMode = "side"; details: { [key: string]: IIssue; } = {}; - issueService; - rootStore: RootStore; + // root store + rootStore: CoreRootStore; + // services + issueService: IssueService; - constructor(_rootStore: RootStore) { + constructor(_rootStore: CoreRootStore) { makeObservable(this, { loader: observable.ref, error: observable.ref, - // peek + // observables peekId: observable.ref, peekMode: observable.ref, - details: observable.ref, + details: observable, // actions setPeekId: action, setPeekMode: action, + // issue actions fetchIssueDetails: action, + // comment actions addIssueComment: action, updateIssueComment: action, deleteIssueComment: action, addCommentReaction: action, removeCommentReaction: action, + // reaction actions addIssueReaction: action, removeIssueReaction: action, + // vote actions addIssueVote: action, removeIssueVote: action, }); - this.issueService = new IssueService(); this.rootStore = _rootStore; + this.issueService = new IssueService(); } - setPeekId = (issueId: string | null) => { - this.peekId = issueId; + setPeekId = (issueID: string | null) => { + this.peekId = issueID; }; setPeekMode = (mode: IPeekMode) => { this.peekMode = mode; }; - fetchIssueDetails = async (workspaceSlug: string, projectId: string, issueId: string) => { + /** + * @description fetc + * @param {string} anchor + * @param {string} issueID + */ + fetchIssueDetails = async (anchor: string, issueID: string) => { try { this.loader = true; this.error = null; - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueId); - const commentsResponse = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const commentsResponse = await this.issueService.getIssueComments(anchor, issueID); if (issueDetails) { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...(this.details[issueId] ?? issueDetails), + [issueID]: { + ...(this.details[issueID] ?? issueDetails), comments: commentsResponse, }, }; @@ -123,17 +117,17 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, data: any) => { + addIssueComment = async (anchor: string, issueID: string, data: any) => { try { - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueId); - const issueCommentResponse = await this.issueService.createIssueComment(workspaceSlug, projectId, issueId, data); + const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data); if (issueDetails) { runInAction(() => { this.details = { ...this.details, - [issueId]: { + [issueID]: { ...issueDetails, - comments: [...this.details[issueId].comments, issueCommentResponse], + comments: [...this.details[issueID].comments, issueCommentResponse], }, }; }); @@ -145,36 +139,30 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - updateIssueComment = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - data: any - ) => { + updateIssueComment = async (anchor: string, issueID: string, commentID: string, data: any) => { try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], - comments: this.details[issueId].comments.map((c) => ({ + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ ...c, - ...(c.id === commentId ? data : {}), + ...(c.id === commentID ? data : {}), })), }, }; }); - await this.issueService.updateIssueComment(workspaceSlug, projectId, issueId, commentId, data); + await this.issueService.updateIssueComment(anchor, issueID, commentID, data); } catch (error) { - const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueComments = await this.issueService.getIssueComments(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: issueComments, }, }; @@ -182,15 +170,15 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - deleteIssueComment = async (workspaceSlug: string, projectId: string, issueId: string, comment_id: string) => { + deleteIssueComment = async (anchor: string, issueID: string, commentID: string) => { try { - await this.issueService.deleteIssueComment(workspaceSlug, projectId, issueId, comment_id); - const remainingComments = this.details[issueId].comments.filter((c) => c.id != comment_id); + await this.issueService.deleteIssueComment(anchor, issueID, commentID); + const remainingComments = this.details[issueID].comments.filter((c) => c.id != commentID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: remainingComments, }, }; @@ -200,47 +188,41 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addCommentReaction = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => { + addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { const newReaction = { id: uuidv4(), - comment: commentId, + comment: commentID, reaction: reactionHex, actor_detail: this.rootStore.user.currentActor, }; - const newComments = this.details[issueId].comments.map((comment) => ({ + const newComments = this.details[issueID].comments.map((comment) => ({ ...comment, comment_reactions: - comment.id === commentId ? [...comment.comment_reactions, newReaction] : comment.comment_reactions, + comment.id === commentID ? [...comment.comment_reactions, newReaction] : comment.comment_reactions, })); try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: [...newComments], }, }; }); - await this.issueService.createCommentReaction(workspaceSlug, projectId, commentId, { + await this.issueService.createCommentReaction(anchor, commentID, { reaction: reactionHex, }); } catch (error) { - const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueComments = await this.issueService.getIssueComments(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: issueComments, }, }; @@ -248,39 +230,33 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - removeCommentReaction = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - reactionHex: string - ) => { + removeCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { try { - const comment = this.details[issueId].comments.find((c) => c.id === commentId); + const comment = this.details[issueID].comments.find((c) => c.id === commentID); const newCommentReactions = comment?.comment_reactions.filter((r) => r.reaction !== reactionHex) ?? []; runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], - comments: this.details[issueId].comments.map((c) => ({ + [issueID]: { + ...this.details[issueID], + comments: this.details[issueID].comments.map((c) => ({ ...c, - comment_reactions: c.id === commentId ? newCommentReactions : c.comment_reactions, + comment_reactions: c.id === commentID ? newCommentReactions : c.comment_reactions, })), }, }; }); - await this.issueService.deleteCommentReaction(workspaceSlug, projectId, commentId, reactionHex); + await this.issueService.deleteCommentReaction(anchor, commentID, reactionHex); } catch (error) { - const issueComments = await this.issueService.getIssueComments(workspaceSlug, projectId, issueId); + const issueComments = await this.issueService.getIssueComments(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], comments: issueComments, }, }; @@ -288,18 +264,18 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => { + addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: [ - ...this.details[issueId].reactions, + ...this.details[issueID].reactions, { id: uuidv4(), - issue: issueId, + issue: issueID, reaction: reactionHex, actor_detail: this.rootStore.user.currentActor, }, @@ -308,17 +284,17 @@ export class IssueDetailStore implements IIssueDetailStore { }; }); - await this.issueService.createIssueReaction(workspaceSlug, projectId, issueId, { + await this.issueService.createIssueReaction(anchor, issueID, { reaction: reactionHex, }); } catch (error) { console.log("Failed to add issue vote"); - const issueReactions = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId); + const issueReactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: issueReactions, }, }; @@ -326,31 +302,31 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - removeIssueReaction = async (workspaceSlug: string, projectId: string, issueId: string, reactionHex: string) => { + removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { - const newReactions = this.details[issueId].reactions.filter( + const newReactions = this.details[issueID].reactions.filter( (_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.data?.id) ); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: newReactions, }, }; }); - await this.issueService.deleteIssueReaction(workspaceSlug, projectId, issueId, reactionHex); + await this.issueService.deleteIssueReaction(anchor, issueID, reactionHex); } catch (error) { console.log("Failed to remove issue reaction"); - const reactions = await this.issueService.getIssueReactions(workspaceSlug, projectId, issueId); + const reactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], reactions: reactions, }, }; @@ -358,39 +334,44 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - addIssueVote = async (workspaceSlug: string, projectId: string, issueId: string, data: { vote: 1 | -1 }) => { + addIssueVote = async (anchor: string, issueID: string, data: { vote: 1 | -1 }) => { + const publishSettings = this.rootStore.publishList?.publishMap?.[anchor]; + const projectID = publishSettings?.project; + const workspaceSlug = publishSettings?.workspace_detail?.slug; + if (!projectID || !workspaceSlug) throw new Error("Publish settings not found"); + const newVote: IVote = { actor: this.rootStore.user.data?.id ?? "", actor_detail: this.rootStore.user.currentActor, - issue: issueId, - project: projectId, + issue: issueID, + project: projectID, workspace: workspaceSlug, vote: data.vote, }; - const filteredVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + const filteredVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: [...filteredVotes, newVote], }, }; }); - await this.issueService.createIssueVote(workspaceSlug, projectId, issueId, data); + await this.issueService.createIssueVote(anchor, issueID, data); } catch (error) { console.log("Failed to add issue vote"); - const issueVotes = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId); + const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: issueVotes, }, }; @@ -398,30 +379,30 @@ export class IssueDetailStore implements IIssueDetailStore { } }; - removeIssueVote = async (workspaceSlug: string, projectId: string, issueId: string) => { - const newVotes = this.details[issueId].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + removeIssueVote = async (anchor: string, issueID: string) => { + const newVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); try { runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: newVotes, }, }; }); - await this.issueService.deleteIssueVote(workspaceSlug, projectId, issueId); + await this.issueService.deleteIssueVote(anchor, issueID); } catch (error) { console.log("Failed to remove issue vote"); - const issueVotes = await this.issueService.getIssueVotes(workspaceSlug, projectId, issueId); + const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { this.details = { ...this.details, - [issueId]: { - ...this.details[issueId], + [issueID]: { + ...this.details[issueID], votes: issueVotes, }, }; diff --git a/space/store/issue-filters.store.ts b/space/core/store/issue-filters.store.ts similarity index 52% rename from space/store/issue-filters.store.ts rename to space/core/store/issue-filters.store.ts index b7b311af4..9e2361671 100644 --- a/space/store/issue-filters.store.ts +++ b/space/core/store/issue-filters.store.ts @@ -1,12 +1,12 @@ import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; import set from "lodash/set"; -import { action, makeObservable, observable, runInAction, computed } from "mobx"; +import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // constants import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // store -import { RootStore } from "@/store/root.store"; +import { CoreRootStore } from "@/store/root.store"; // types import { TIssueLayoutOptions, @@ -19,16 +19,17 @@ import { export interface IIssueFilterStore { // observables layoutOptions: TIssueLayoutOptions; - filters: { [projectId: string]: TIssueFilters } | undefined; + filters: { [anchor: string]: TIssueFilters } | undefined; // computed - issueFilters: TIssueFilters | undefined; - appliedFilters: TIssueQueryFiltersParams | undefined; - isIssueFiltersUpdated: (filters: TIssueFilters) => boolean; + isIssueFiltersUpdated: (anchor: string, filters: TIssueFilters) => boolean; + // helpers + getIssueFilters: (anchor: string) => TIssueFilters | undefined; + getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined; // actions updateLayoutOptions: (layout: TIssueLayoutOptions) => void; - initIssueFilters: (projectId: string, filters: TIssueFilters) => void; + initIssueFilters: (anchor: string, filters: TIssueFilters) => void; updateIssueFilters: ( - projectId: string, + anchor: string, filterKind: K, filterKey: keyof TIssueFilters[K], filters: TIssueFilters[K][typeof filterKey] @@ -44,16 +45,13 @@ export class IssueFilterStore implements IIssueFilterStore { gantt: false, spreadsheet: false, }; - filters: { [projectId: string]: TIssueFilters } | undefined = undefined; + filters: { [anchor: string]: TIssueFilters } | undefined = undefined; - constructor(private store: RootStore) { + constructor(private store: CoreRootStore) { makeObservable(this, { // observables layoutOptions: observable, filters: observable, - // computed - issueFilters: computed, - appliedFilters: computed, // actions updateLayoutOptions: action, initIssueFilters: action, @@ -82,81 +80,62 @@ export class IssueFilterStore implements IIssueFilterStore { }; // computed - get issueFilters() { - const projectId = this.store.project.project?.id; - if (!projectId) return undefined; - - const currentFilters = this.filters?.[projectId]; - if (!currentFilters) return undefined; - + getIssueFilters = computedFn((anchor: string) => { + const currentFilters = this.filters?.[anchor]; return currentFilters; - } + }); - get appliedFilters() { - const currentIssueFilters = this.issueFilters; - if (!currentIssueFilters) return undefined; + getAppliedFilters = computedFn((anchor: string) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return undefined; - const currentLayout = currentIssueFilters?.display_filters?.layout; + const currentLayout = issueFilters?.display_filters?.layout; if (!currentLayout) return undefined; const currentFilters: TIssueQueryFilters = { - priority: currentIssueFilters?.filters?.priority || undefined, - state: currentIssueFilters?.filters?.state || undefined, - labels: currentIssueFilters?.filters?.labels || undefined, + priority: issueFilters?.filters?.priority || undefined, + state: issueFilters?.filters?.state || undefined, + labels: issueFilters?.filters?.labels || undefined, }; const filteredParams = ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[currentLayout]?.filters || []; const currentFilterQueryParams: TIssueQueryFiltersParams = this.computedFilter(currentFilters, filteredParams); return currentFilterQueryParams; - } + }); - isIssueFiltersUpdated = computedFn((userFilters: TIssueFilters) => { - if (!this.issueFilters) return false; + isIssueFiltersUpdated = computedFn((anchor: string, userFilters: TIssueFilters) => { + const issueFilters = this.getIssueFilters(anchor); + if (!issueFilters) return false; const currentUserFilters = cloneDeep(userFilters?.filters || {}); - const currentIssueFilters = cloneDeep(this.issueFilters?.filters || {}); + const currentIssueFilters = cloneDeep(issueFilters?.filters || {}); return isEqual(currentUserFilters, currentIssueFilters); }); // actions updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options); - initIssueFilters = async (projectId: string, initFilters: TIssueFilters) => { - try { - if (!projectId) return; - if (this.filters === undefined) runInAction(() => (this.filters = {})); - if (this.filters && initFilters) set(this.filters, [projectId], initFilters); + initIssueFilters = async (anchor: string, initFilters: TIssueFilters) => { + if (this.filters === undefined) runInAction(() => (this.filters = {})); + if (this.filters && initFilters) set(this.filters, [anchor], initFilters); - const workspaceSlug = this.store.project.workspace?.slug; - const currentAppliedFilters = this.appliedFilters; - - if (!workspaceSlug) return; - await this.store.issue.fetchPublicIssues(workspaceSlug, projectId, currentAppliedFilters); - } catch (error) { - throw error; - } + const appliedFilters = this.getAppliedFilters(anchor); + await this.store.issue.fetchPublicIssues(anchor, appliedFilters); }; updateIssueFilters = async ( - projectId: string, + anchor: string, filterKind: K, filterKey: keyof TIssueFilters[K], filterValue: TIssueFilters[K][typeof filterKey] ) => { - try { - if (!projectId || !filterKind || !filterKey || !filterValue) return; - if (this.filters === undefined) runInAction(() => (this.filters = {})); + if (!filterKind || !filterKey || !filterValue) return; + if (this.filters === undefined) runInAction(() => (this.filters = {})); - runInAction(() => { - if (this.filters) set(this.filters, [projectId, filterKind, filterKey], filterValue); - }); + runInAction(() => { + if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue); + }); - const workspaceSlug = this.store.project.workspace?.slug; - const currentAppliedFilters = this.appliedFilters; - - if (!workspaceSlug) return; - await this.store.issue.fetchPublicIssues(workspaceSlug, projectId, currentAppliedFilters); - } catch (error) { - throw error; - } + const appliedFilters = this.getAppliedFilters(anchor); + await this.store.issue.fetchPublicIssues(anchor, appliedFilters); }; } diff --git a/space/core/store/issue.store.ts b/space/core/store/issue.store.ts new file mode 100644 index 000000000..80f5f26bd --- /dev/null +++ b/space/core/store/issue.store.ts @@ -0,0 +1,112 @@ +import { observable, action, makeObservable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// types +import { IStateLite } from "@plane/types"; +// services +import IssueService from "@/services/issue.service"; +// store +import { CoreRootStore } from "@/store/root.store"; +// types +import { IIssue, IIssueLabel } from "@/types/issue"; + +export interface IIssueStore { + loader: boolean; + error: any; + // observables + issues: IIssue[]; + states: IStateLite[]; + labels: IIssueLabel[]; + // filter observables + filteredStates: string[]; + filteredLabels: string[]; + filteredPriorities: string[]; + // actions + fetchPublicIssues: (anchor: string, params: any) => Promise; + // helpers + getCountOfIssuesByState: (stateID: string) => number; + getFilteredIssuesByState: (stateID: string) => IIssue[]; +} + +export class IssueStore implements IIssueStore { + loader: boolean = false; + error: any | null = null; + // observables + states: IStateLite[] = []; + labels: IIssueLabel[] = []; + issues: IIssue[] = []; + // filter observables + filteredStates: string[] = []; + filteredLabels: string[] = []; + filteredPriorities: string[] = []; + // root store + rootStore: CoreRootStore; + // services + issueService: IssueService; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + loader: observable.ref, + error: observable, + // observables + states: observable, + labels: observable, + issues: observable, + // filter observables + filteredStates: observable, + filteredLabels: observable, + filteredPriorities: observable, + // actions + fetchPublicIssues: action, + }); + + this.rootStore = _rootStore; + this.issueService = new IssueService(); + } + + /** + * @description fetch issues, states and labels + * @param {string} anchor + * @param params + */ + fetchPublicIssues = async (anchor: string, params: any) => { + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const response = await this.issueService.fetchPublicIssues(anchor, params); + + if (response) { + runInAction(() => { + this.states = response.states; + this.labels = response.labels; + this.issues = response.issues; + this.loader = false; + }); + } + } catch (error) { + this.loader = false; + this.error = error; + throw error; + } + }; + + /** + * @description get total count of issues under a particular state + * @param {string} stateID + * @returns {number} + */ + getCountOfIssuesByState = computedFn( + (stateID: string) => this.issues?.filter((issue) => issue.state == stateID).length || 0 + ); + + /** + * @description get array of issues under a particular state + * @param {string} stateID + * @returns {IIssue[]} + */ + getFilteredIssuesByState = computedFn( + (stateID: string) => this.issues?.filter((issue) => issue.state == stateID) || [] + ); +} diff --git a/space/store/mentions.store.ts b/space/core/store/mentions.store.ts similarity index 86% rename from space/store/mentions.store.ts rename to space/core/store/mentions.store.ts index c8ef48ce5..977df4221 100644 --- a/space/store/mentions.store.ts +++ b/space/core/store/mentions.store.ts @@ -1,6 +1,8 @@ import { computed, makeObservable } from "mobx"; -import { IMentionHighlight } from "@plane/lite-text-editor"; -import { RootStore } from "./root.store"; +// editor +import { IMentionHighlight } from "@plane/editor"; +// store +import { CoreRootStore } from "@/store/root.store"; export interface IMentionsStore { // mentionSuggestions: IMentionSuggestion[]; @@ -11,7 +13,7 @@ export class MentionsStore implements IMentionsStore { // root store rootStore; - constructor(_rootStore: RootStore) { + constructor(_rootStore: CoreRootStore) { // rootStore this.rootStore = _rootStore; diff --git a/space/store/profile.store.ts b/space/core/store/profile.store.ts similarity index 96% rename from space/store/profile.store.ts rename to space/core/store/profile.store.ts index 9086e84df..d03328058 100644 --- a/space/store/profile.store.ts +++ b/space/core/store/profile.store.ts @@ -1,10 +1,11 @@ import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; +// types import { TUserProfile } from "@plane/types"; // services import { UserService } from "@/services/user.service"; -// store types -import { RootStore } from "@/store/root.store"; +// store +import { CoreRootStore } from "@/store/root.store"; type TError = { status: string; @@ -58,7 +59,7 @@ export class ProfileStore implements IProfileStore { // services userService: UserService; - constructor(public store: RootStore) { + constructor(public store: CoreRootStore) { makeObservable(this, { // observables isLoading: observable.ref, diff --git a/space/core/store/publish/publish.store.ts b/space/core/store/publish/publish.store.ts new file mode 100644 index 000000000..ce82a8a13 --- /dev/null +++ b/space/core/store/publish/publish.store.ts @@ -0,0 +1,117 @@ +import { observable, makeObservable, computed } from "mobx"; +// types +import { + IWorkspaceLite, + TProjectDetails, + TPublishEntityType, + TProjectPublishSettings, + TProjectPublishViewProps, +} from "@plane/types"; +// store +import { CoreRootStore } from "../root.store"; + +export interface IPublishStore extends TProjectPublishSettings { + // computed + workspaceSlug: string | undefined; + canComment: boolean; + canReact: boolean; + canVote: boolean; +} + +export class PublishStore implements IPublishStore { + // observables + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TProjectPublishViewProps | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; + + constructor( + private store: CoreRootStore, + publishSettings: TProjectPublishSettings + ) { + this.anchor = publishSettings.anchor; + this.is_comments_enabled = publishSettings.is_comments_enabled; + this.created_at = publishSettings.created_at; + this.created_by = publishSettings.created_by; + this.entity_identifier = publishSettings.entity_identifier; + this.entity_name = publishSettings.entity_name; + this.id = publishSettings.id; + this.inbox = publishSettings.inbox; + this.project = publishSettings.project; + this.project_details = publishSettings.project_details; + this.is_reactions_enabled = publishSettings.is_reactions_enabled; + this.updated_at = publishSettings.updated_at; + this.updated_by = publishSettings.updated_by; + this.view_props = publishSettings.view_props; + this.is_votes_enabled = publishSettings.is_votes_enabled; + this.workspace = publishSettings.workspace; + this.workspace_detail = publishSettings.workspace_detail; + + makeObservable(this, { + // observables + anchor: observable.ref, + is_comments_enabled: observable.ref, + created_at: observable.ref, + created_by: observable.ref, + entity_identifier: observable.ref, + entity_name: observable.ref, + id: observable.ref, + inbox: observable, + project: observable.ref, + project_details: observable, + is_reactions_enabled: observable.ref, + updated_at: observable.ref, + updated_by: observable.ref, + view_props: observable, + is_votes_enabled: observable.ref, + workspace: observable.ref, + workspace_detail: observable, + // computed + workspaceSlug: computed, + canComment: computed, + canReact: computed, + canVote: computed, + }); + } + + /** + * @description returns the workspace slug from the workspace details + */ + get workspaceSlug() { + return this?.workspace_detail?.slug ?? undefined; + } + + /** + * @description returns whether commenting is enabled or not + */ + get canComment() { + return !!this.is_comments_enabled; + } + + /** + * @description returns whether reacting is enabled or not + */ + get canReact() { + return !!this.is_reactions_enabled; + } + + /** + * @description returns whether voting is enabled or not + */ + get canVote() { + return !!this.is_votes_enabled; + } +} diff --git a/space/core/store/publish/publish_list.store.ts b/space/core/store/publish/publish_list.store.ts new file mode 100644 index 000000000..d4a59f62d --- /dev/null +++ b/space/core/store/publish/publish_list.store.ts @@ -0,0 +1,48 @@ +import set from "lodash/set"; +import { makeObservable, observable, runInAction, action } from "mobx"; +// types +import { TProjectPublishSettings } from "@plane/types"; +// services +import PublishService from "@/services/publish.service"; +// store +import { PublishStore } from "@/store/publish/publish.store"; +import { CoreRootStore } from "@/store/root.store"; + +export interface IPublishListStore { + // observables + publishMap: Record; // anchor => PublishStore + // actions + fetchPublishSettings: (pageId: string) => Promise; +} + +export class PublishListStore implements IPublishListStore { + // observables + publishMap: Record = {}; // anchor => PublishStore + // service + publishService; + + constructor(private rootStore: CoreRootStore) { + makeObservable(this, { + // observables + publishMap: observable, + // actions + fetchPublishSettings: action, + }); + // services + this.publishService = new PublishService(); + } + + /** + * @description fetch publish settings + * @param {string} anchor + */ + fetchPublishSettings = async (anchor: string) => { + const response = await this.publishService.fetchPublishSettings(anchor); + runInAction(() => { + if (response.anchor) { + set(this.publishMap, [response.anchor], new PublishStore(this.rootStore, response)); + } + }); + return response; + }; +} diff --git a/space/store/root.store.ts b/space/core/store/root.store.ts similarity index 82% rename from space/store/root.store.ts rename to space/core/store/root.store.ts index 4a31840db..acd8c3b59 100644 --- a/space/store/root.store.ts +++ b/space/core/store/root.store.ts @@ -1,32 +1,32 @@ -import { enableStaticRendering } from "mobx-react-lite"; +import { enableStaticRendering } from "mobx-react"; // store imports import { IInstanceStore, InstanceStore } from "@/store/instance.store"; import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store"; import { IssueStore, IIssueStore } from "@/store/issue.store"; -import { IProjectStore, ProjectStore } from "@/store/project.store"; import { IUserStore, UserStore } from "@/store/user.store"; import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; import { IMentionsStore, MentionsStore } from "./mentions.store"; +import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; enableStaticRendering(typeof window === "undefined"); -export class RootStore { +export class CoreRootStore { instance: IInstanceStore; user: IUserStore; - project: IProjectStore; issue: IIssueStore; issueDetail: IIssueDetailStore; mentionStore: IMentionsStore; issueFilter: IIssueFilterStore; + publishList: IPublishListStore; constructor() { this.instance = new InstanceStore(this); this.user = new UserStore(this); - this.project = new ProjectStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); this.issueFilter = new IssueFilterStore(this); + this.publishList = new PublishListStore(this); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -36,14 +36,14 @@ export class RootStore { this.user.hydrate(data?.user || undefined); }; - reset = () => { + reset() { localStorage.setItem("theme", "system"); this.instance = new InstanceStore(this); this.user = new UserStore(this); - this.project = new ProjectStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); this.issueFilter = new IssueFilterStore(this); - }; + this.publishList = new PublishListStore(this); + } } diff --git a/space/store/user.store.ts b/space/core/store/user.store.ts similarity index 97% rename from space/store/user.store.ts rename to space/core/store/user.store.ts index 52cf3ca66..33b2cbe60 100644 --- a/space/store/user.store.ts +++ b/space/core/store/user.store.ts @@ -7,7 +7,8 @@ import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; // store types import { ProfileStore, IProfileStore } from "@/store/profile.store"; -import { RootStore } from "@/store/root.store"; +// store +import { CoreRootStore } from "@/store/root.store"; // types import { ActorDetail } from "@/types/issue"; @@ -46,7 +47,7 @@ export class UserStore implements IUserStore { userService: UserService; authService: AuthService; - constructor(private store: RootStore) { + constructor(private store: CoreRootStore) { // stores this.profile = new ProfileStore(store); // service diff --git a/space/types/auth.ts b/space/core/types/auth.ts similarity index 100% rename from space/types/auth.ts rename to space/core/types/auth.ts diff --git a/space/types/issue.d.ts b/space/core/types/issue.d.ts similarity index 62% rename from space/types/issue.d.ts rename to space/core/types/issue.d.ts index f2625fb76..b9676810e 100644 --- a/space/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -1,27 +1,17 @@ +import { IStateLite, IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types"; + export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; export type TIssueLayoutOptions = { [key in TIssueLayout]: boolean; }; -export type TIssueLayoutViews = { - [key in TIssueLayout]: { title: string; icon: string; className: string }; -}; -export type TIssueFilterPriority = "urgent" | "high" | "medium" | "low" | "none"; export type TIssueFilterPriorityObject = { - key: TIssueFilterPriority; + key: TIssuePriorities; title: string; className: string; icon: string; }; -export type TIssueFilterState = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; -export type TIssueFilterStateObject = { - key: TIssueFilterState; - title: string; - color: string; - className: string; -}; - export type TIssueFilterKeys = "priority" | "state" | "labels"; export type TDisplayFilters = { @@ -29,8 +19,8 @@ export type TDisplayFilters = { }; export type TFilters = { - state: TIssueFilterState[]; - priority: TIssueFilterPriority[]; + state: TStateGroups[]; + priority: TIssuePriorities[]; labels: string[]; }; @@ -43,18 +33,19 @@ export type TIssueQueryFilters = Partial; export type TIssueQueryFiltersParams = Partial>; -export interface IIssue { - id: string; +export type TIssuesResponse = { + states: IStateLite[]; + labels: IIssueLabel[]; + issues: IIssue[]; +}; + +export interface IIssue + extends Pick { comments: Comment[]; - description_html: string; label_details: any; - name: string; - priority: TIssuePriorityKey | null; project: string; project_detail: any; reactions: IIssueReaction[]; - sequence_id: number; - start_date: any; state: string; state_detail: { id: string; @@ -62,23 +53,16 @@ export interface IIssue { group: TIssueGroupKey; color: string; }; - target_date: any; votes: IVote[]; } export type IPeekMode = "side" | "modal" | "full"; -export interface IIssueState { - id: string; - name: string; - group: TIssueGroupKey; - color: string; -} - export interface IIssueLabel { id: string; name: string; color: string; + parent: string | null; } export interface IVote { @@ -114,7 +98,7 @@ export interface Comment { updated_at: Date; updated_by: string; workspace: string; - workspace_detail: WorkspaceDetail; + workspace_detail: IWorkspaceLite; } export interface IIssueReaction { @@ -175,52 +159,8 @@ export interface ProjectDetail { description: string; } -export interface WorkspaceDetail { - name: string; - slug: string; - id: string; -} - -export interface IssueDetailType { - [issueId: string]: { - issue: IIssue; - comments: Comment[]; - reactions: any[]; - votes: any[]; - }; -} - -export type TIssueGroupByOptions = "state" | "priority" | "labels" | null; - -export type TIssueParams = "priority" | "state" | "labels"; - export interface IIssueFilterOptions { state?: string[] | null; labels?: string[] | null; priority?: string[] | null; } - -// issues -export interface IGroupedIssues { - [group_id: string]: string[]; -} - -export interface ISubGroupedIssues { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; -} - -export type TUnGroupedIssues = string[]; - -export interface IIssueResponse { - [issue_id: string]: IIssue; -} - -export type TLoader = "init-loader" | "mutation" | undefined; - -export interface ViewFlags { - enableQuickAdd: boolean; - enableIssueCreation: boolean; - enableInlineEditing: boolean; -} diff --git a/space/ee/store/root.store.ts b/space/ee/store/root.store.ts new file mode 100644 index 000000000..c514c4c25 --- /dev/null +++ b/space/ee/store/root.store.ts @@ -0,0 +1 @@ +export * from "ce/store/root.store"; diff --git a/space/helpers/authentication.helper.tsx b/space/helpers/authentication.helper.tsx index 0e5ab0186..409a75150 100644 --- a/space/helpers/authentication.helper.tsx +++ b/space/helpers/authentication.helper.tsx @@ -52,10 +52,13 @@ export enum EAuthenticationErrorCodes { EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN = "5100", EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP = "5102", // Oauth + OAUTH_NOT_CONFIGURED = "5104", GOOGLE_NOT_CONFIGURED = "5105", GITHUB_NOT_CONFIGURED = "5110", + GITLAB_NOT_CONFIGURED = "5111", GOOGLE_OAUTH_PROVIDER_ERROR = "5115", GITHUB_OAUTH_PROVIDER_ERROR = "5120", + GITLAB_OAUTH_PROVIDER_ERROR = "5121", // Reset Password INVALID_PASSWORD_TOKEN = "5125", EXPIRED_PASSWORD_TOKEN = "5130", @@ -220,6 +223,10 @@ const errorCodeMessages: { }, // Oauth + [EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED]: { + title: `OAuth not configured`, + message: () => `OAuth not configured. Please contact your administrator.`, + }, [EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED]: { title: `Google not configured`, message: () => `Google not configured. Please contact your administrator.`, @@ -228,6 +235,10 @@ const errorCodeMessages: { title: `GitHub not configured`, message: () => `GitHub not configured. Please contact your administrator.`, }, + [EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED]: { + title: `GitLab not configured`, + message: () => `GitLab not configured. Please contact your administrator.`, + }, [EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR]: { title: `Google OAuth provider error`, message: () => `Google OAuth provider error. Please try again.`, @@ -236,6 +247,10 @@ const errorCodeMessages: { title: `GitHub OAuth provider error`, message: () => `GitHub OAuth provider error. Please try again.`, }, + [EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR]: { + title: `GitLab OAuth provider error`, + message: () => `GitLab OAuth provider error. Please try again.`, + }, // Reset Password [EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN]: { @@ -347,10 +362,13 @@ export const authErrorHandler = ( EAuthenticationErrorCodes.EXPIRED_MAGIC_CODE_SIGN_UP, EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN, EAuthenticationErrorCodes.EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP, + EAuthenticationErrorCodes.OAUTH_NOT_CONFIGURED, EAuthenticationErrorCodes.GOOGLE_NOT_CONFIGURED, EAuthenticationErrorCodes.GITHUB_NOT_CONFIGURED, + EAuthenticationErrorCodes.GITLAB_NOT_CONFIGURED, EAuthenticationErrorCodes.GOOGLE_OAUTH_PROVIDER_ERROR, EAuthenticationErrorCodes.GITHUB_OAUTH_PROVIDER_ERROR, + EAuthenticationErrorCodes.GITLAB_OAUTH_PROVIDER_ERROR, EAuthenticationErrorCodes.INVALID_PASSWORD_TOKEN, EAuthenticationErrorCodes.EXPIRED_PASSWORD_TOKEN, EAuthenticationErrorCodes.INCORRECT_OLD_PASSWORD, diff --git a/space/helpers/date-time.helper.ts b/space/helpers/date-time.helper.ts index f19a5358b..3930bcb83 100644 --- a/space/helpers/date-time.helper.ts +++ b/space/helpers/date-time.helper.ts @@ -1,3 +1,6 @@ +import { format, isValid } from "date-fns"; +import isNumber from "lodash/isNumber"; + export const timeAgo = (time: any) => { switch (typeof time) { case "number": @@ -14,24 +17,43 @@ export const timeAgo = (time: any) => { }; /** - * @description Returns date and month, if date is of the current year - * @description Returns date, month adn year, if date is of a different year than current - * @param {string} date - * @example renderFullDate("2023-01-01") // 1 Jan - * @example renderFullDate("2021-01-01") // 1 Jan, 2021 + * This method returns a date from string of type yyyy-mm-dd + * This method is recommended to use instead of new Date() as this does not introduce any timezone offsets + * @param date + * @returns date or undefined */ +export const getDate = (date: string | Date | undefined | null): Date | undefined => { + try { + if (!date || date === "") return; -export const renderFullDate = (date: string): string => { - if (!date) return ""; + if (typeof date !== "string" && !(date instanceof String)) return date; - const months: string[] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const [yearString, monthString, dayString] = date.substring(0, 10).split("-"); + const year = parseInt(yearString); + const month = parseInt(monthString); + const day = parseInt(dayString); + if (!isNumber(year) || !isNumber(month) || !isNumber(day)) return; - const currentDate: Date = new Date(); - const [year, month, day]: number[] = date.split("-").map(Number); - - const formattedMonth: string = months[month - 1]; - const formattedDay: string = day < 10 ? `0${day}` : day.toString(); - - if (currentDate.getFullYear() === year) return `${formattedDay} ${formattedMonth}`; - else return `${formattedDay} ${formattedMonth}, ${year}`; + return new Date(year, month - 1, day); + } catch (e) { + return undefined; + } +}; + +/** + * @returns {string | null} formatted date in the format of MMM dd, yyyy + * @description Returns date in the formatted format + * @param {Date | string} date + * @example renderFormattedDate("2024-01-01") // Jan 01, 2024 + */ +export const renderFormattedDate = (date: string | Date | undefined | null): string | null => { + // Parse the date to check if it is valid + const parsedDate = getDate(date); + // return if undefined + if (!parsedDate) return null; + // Check if the parsed date is valid before formatting + if (!isValid(parsedDate)) return null; // Return null for invalid dates + // Format the date in format (MMM dd, yyyy) + const formattedDate = format(parsedDate, "MMM dd, yyyy"); + return formattedDate; }; diff --git a/space/helpers/emoji.helper.tsx b/space/helpers/emoji.helper.tsx index 7c9f3cfcb..d5f9d1b5a 100644 --- a/space/helpers/emoji.helper.tsx +++ b/space/helpers/emoji.helper.tsx @@ -1,23 +1,3 @@ -export const getRandomEmoji = () => { - const emojis = [ - "8986", - "9200", - "128204", - "127773", - "127891", - "127947", - "128076", - "128077", - "128187", - "128188", - "128512", - "128522", - "128578", - ]; - - return emojis[Math.floor(Math.random() * emojis.length)]; -}; - export const renderEmoji = ( emoji: | string diff --git a/space/helpers/issue.helper.ts b/space/helpers/issue.helper.ts new file mode 100644 index 000000000..a5159edef --- /dev/null +++ b/space/helpers/issue.helper.ts @@ -0,0 +1,30 @@ +import { differenceInCalendarDays } from "date-fns"; +// types +import { TStateGroups } from "@plane/types"; +// constants +import { STATE_GROUPS } from "@/constants/state"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; + +/** + * @description check if the issue due date should be highlighted + * @param date + * @param stateGroup + * @returns boolean + */ +export const shouldHighlightIssueDueDate = ( + date: string | Date | null, + stateGroup: TStateGroups | undefined +): boolean => { + if (!date || !stateGroup) return false; + // if the issue is completed or cancelled, don't highlight the due date + if ([STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateGroup)) return false; + + const parsedDate = getDate(date); + if (!parsedDate) return false; + + const targetDateDistance = differenceInCalendarDays(parsedDate, new Date()); + + // if the issue is overdue, highlight the due date + return targetDateDistance <= 0; +}; diff --git a/space/helpers/password.helper.ts b/space/helpers/password.helper.ts index 8d80b3402..dfe9a5c65 100644 --- a/space/helpers/password.helper.ts +++ b/space/helpers/password.helper.ts @@ -1,16 +1,67 @@ import zxcvbn from "zxcvbn"; -export const isPasswordCriteriaMet = (password: string) => { - const criteria = [password.length >= 8, /[A-Z]/.test(password), /\d/.test(password), /[!@#$%^&*]/.test(password)]; +export enum E_PASSWORD_STRENGTH { + EMPTY = "empty", + LENGTH_NOT_VALID = "length_not_valid", + STRENGTH_NOT_VALID = "strength_not_valid", + STRENGTH_VALID = "strength_valid", +} - return criteria.every((criterion) => criterion); -}; - -export const getPasswordStrength = (password: string) => { - if (password.length === 0) return 0; - if (password.length < 8) return 1; - if (!isPasswordCriteriaMet(password)) return 2; - - const result = zxcvbn(password); - return result.score; +const PASSWORD_MIN_LENGTH = 8; +// const PASSWORD_NUMBER_REGEX = /\d/; +// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/; +// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/; + +export const PASSWORD_CRITERIA = [ + { + key: "min_8_char", + label: "Min 8 characters", + isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH, + }, + // { + // key: "min_1_upper_case", + // label: "Min 1 upper-case letter", + // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password), + // }, + // { + // key: "min_1_number", + // label: "Min 1 number", + // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password), + // }, + // { + // key: "min_1_special_char", + // label: "Min 1 special character", + // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password), + // }, +]; + +export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => { + let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY; + + if (!password || password === "" || password.length <= 0) { + return passwordStrength; + } + + if (password.length >= PASSWORD_MIN_LENGTH) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; + } else { + passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID; + return passwordStrength; + } + + const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every( + (criterion) => criterion + ); + const passwordStrengthScore = zxcvbn(password).score; + + if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID; + return passwordStrength; + } + + if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) { + passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID; + } + + return passwordStrength; }; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 525a9fc99..f6319bc75 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -3,7 +3,7 @@ import DOMPurify from "dompurify"; export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); const fallbackCopyTextToClipboard = (text: string) => { - var textArea = document.createElement("textarea"); + const textArea = document.createElement("textarea"); textArea.value = text; // Avoid scrolling to bottom @@ -18,7 +18,7 @@ const fallbackCopyTextToClipboard = (text: string) => { try { // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand - var successful = document.execCommand("copy"); + document.execCommand("copy"); } catch (err) {} document.body.removeChild(textArea); diff --git a/space/hooks/store/use-project.ts b/space/hooks/store/use-project.ts deleted file mode 100644 index cd3e28958..000000000 --- a/space/hooks/store/use-project.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -// lib -import { StoreContext } from "@/lib/store-provider"; -// store -import { IProjectStore } from "@/store/project.store"; - -export const useProject = (): IProjectStore => { - const context = useContext(StoreContext); - if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); - return context.project; -}; diff --git a/space/lib/user-provider.tsx b/space/lib/user-provider.tsx deleted file mode 100644 index 1ac1c786c..000000000 --- a/space/lib/user-provider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactNode } from "react"; -import { observer } from "mobx-react-lite"; -import useSWR from "swr"; -import { useUser } from "@/hooks/store"; - -export const UserProvider = observer(({ children }: { children: ReactNode }) => { - const { fetchCurrentUser } = useUser(); - - useSWR("CURRENT_USER", () => fetchCurrentUser()); - - return <>{children}; -}); diff --git a/space/package.json b/space/package.json index e3dadbff8..c59f35589 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.21.0", + "version": "0.22.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -17,15 +17,13 @@ "@emotion/styled": "^11.11.0", "@headlessui/react": "^1.7.13", "@mui/material": "^5.14.1", - "@plane/constants": "*", - "@plane/document-editor": "*", - "@plane/lite-text-editor": "*", - "@plane/rich-text-editor": "*", + "@plane/editor": "*", "@plane/types": "*", "@plane/ui": "*", "@sentry/nextjs": "^8", "axios": "^1.3.4", "clsx": "^2.0.0", + "date-fns": "^3.6.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", "js-cookie": "^3.0.1", @@ -33,7 +31,7 @@ "lowlight": "^2.9.0", "lucide-react": "^0.378.0", "mobx": "^6.10.0", - "mobx-react-lite": "^4.0.3", + "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", "next": "^14.2.3", "next-themes": "^0.2.1", diff --git a/space/public/logos/gitlab-logo.svg b/space/public/logos/gitlab-logo.svg new file mode 100644 index 000000000..dab4d8b74 --- /dev/null +++ b/space/public/logos/gitlab-logo.svg @@ -0,0 +1 @@ + diff --git a/space/services/issue.service.ts b/space/services/issue.service.ts deleted file mode 100644 index 1913b678e..000000000 --- a/space/services/issue.service.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; -// services -import { APIService } from "@/services/api.service"; - -class IssueService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise { - return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`, { - params, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueVotes(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createIssueVote(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteIssueVote(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.delete(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/votes/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueReactions(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createIssueReaction(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteIssueReaction( - workspaceSlug: string, - projectId: string, - issueId: string, - reactionId: string - ): Promise { - return this.delete( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/reactions/${reactionId}/` - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async getIssueComments(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createIssueComment(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async updateIssueComment( - workspaceSlug: string, - projectId: string, - issueId: string, - commentId: string, - data: any - ): Promise { - return this.patch( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteIssueComment(workspaceSlug: string, projectId: string, issueId: string, commentId: string): Promise { - return this.delete( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/issues/${issueId}/comments/${commentId}/` - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async createCommentReaction( - workspaceSlug: string, - projectId: string, - commentId: string, - data: { - reaction: string; - } - ): Promise { - return this.post( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/`, - data - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } - - async deleteCommentReaction( - workspaceSlug: string, - projectId: string, - commentId: string, - reactionHex: string - ): Promise { - return this.delete( - `/api/public/workspaces/${workspaceSlug}/project-boards/${projectId}/comments/${commentId}/reactions/${reactionHex}/` - ) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} - -export default IssueService; diff --git a/space/services/project.service.ts b/space/services/project.service.ts deleted file mode 100644 index 14ed7837b..000000000 --- a/space/services/project.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { API_BASE_URL } from "@/helpers/common.helper"; -// services -import { APIService } from "@/services/api.service"; - -class ProjectService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async getProjectSettings(workspace_slug: string, project_slug: string): Promise { - return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/settings/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response; - }); - } -} - -export default ProjectService; diff --git a/space/store/issue.store.ts b/space/store/issue.store.ts deleted file mode 100644 index 7967aafb1..000000000 --- a/space/store/issue.store.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { observable, action, makeObservable, runInAction } from "mobx"; -// services -import IssueService from "@/services/issue.service"; -// types -import { IIssue, IIssueState, IIssueLabel } from "@/types/issue"; -// store -import { RootStore } from "./root.store"; -// import { IssueDetailType, TIssueBoardKeys } from "types/issue"; - -export interface IIssueStore { - loader: boolean; - error: any; - // issue options - issues: IIssue[] | null; - states: IIssueState[] | null; - labels: IIssueLabel[] | null; - // filtering - filteredStates: string[]; - filteredLabels: string[]; - filteredPriorities: string[]; - // service - issueService: any; - // actions - fetchPublicIssues: (workspace_slug: string, project_slug: string, params: any) => Promise; - getCountOfIssuesByState: (state: string) => number; - getFilteredIssuesByState: (state: string) => IIssue[]; -} - -export class IssueStore implements IIssueStore { - loader: boolean = false; - error: any | null = null; - - states: IIssueState[] | null = []; - labels: IIssueLabel[] | null = []; - - filteredStates: string[] = []; - filteredLabels: string[] = []; - filteredPriorities: string[] = []; - - issues: IIssue[] | null = []; - issue_detail: any = {}; - - rootStore: RootStore; - issueService: any; - - constructor(_rootStore: any) { - makeObservable(this, { - // observable - loader: observable, - error: observable, - // issue options - states: observable.ref, - labels: observable.ref, - // filtering - filteredStates: observable.ref, - filteredLabels: observable.ref, - filteredPriorities: observable.ref, - // issues - issues: observable.ref, - issue_detail: observable.ref, - // actions - fetchPublicIssues: action, - getFilteredIssuesByState: action, - }); - - this.rootStore = _rootStore; - this.issueService = new IssueService(); - } - - fetchPublicIssues = async (workspaceSlug: string, projectId: string, params: any) => { - try { - this.loader = true; - this.error = null; - - const response = await this.issueService.getPublicIssues(workspaceSlug, projectId, params); - - if (response) { - const states: IIssueState[] = [...response?.states]; - const labels: IIssueLabel[] = [...response?.labels]; - const issues: IIssue[] = [...response?.issues]; - runInAction(() => { - this.states = states; - this.labels = labels; - this.issues = issues; - this.loader = false; - }); - } - } catch (error) { - this.loader = false; - this.error = error; - } - }; - - // computed - getCountOfIssuesByState(state_id: string): number { - return this.issues?.filter((issue) => issue.state == state_id).length || 0; - } - - getFilteredIssuesByState = (state_id: string): IIssue[] | [] => - this.issues?.filter((issue) => issue.state == state_id) || []; -} diff --git a/space/store/project.store.ts b/space/store/project.store.ts deleted file mode 100644 index 02f250323..000000000 --- a/space/store/project.store.ts +++ /dev/null @@ -1,96 +0,0 @@ -// mobx -import { observable, action, makeObservable, runInAction, computed } from "mobx"; -// service -import ProjectService from "@/services/project.service"; -// store types -import { RootStore } from "@/store/root.store"; -// types -import { TWorkspaceDetails, TProjectDetails, TProjectSettings } from "@/types/project"; - -export interface IProjectStore { - // observables - loader: boolean; - error: any | undefined; - settings: TProjectSettings | undefined; - workspace: TWorkspaceDetails | undefined; - project: TProjectDetails | undefined; - canReact: boolean; - canComment: boolean; - canVote: boolean; - // actions - fetchProjectSettings: (workspace_slug: string, project_slug: string) => Promise; - hydrate: (projectSettings: any) => void; -} - -export class ProjectStore implements IProjectStore { - // observables - loader: boolean = false; - error: any | undefined = undefined; - settings: TProjectSettings | undefined = undefined; - workspace: TWorkspaceDetails | undefined = undefined; - project: TProjectDetails | undefined = undefined; - // service - projectService; - - constructor(private store: RootStore) { - makeObservable(this, { - // loaders and error observables - loader: observable, - error: observable.ref, - // observable - workspace: observable, - project: observable, - settings: observable, - // computed - canReact: computed, - canComment: computed, - canVote: computed, - // actions - fetchProjectSettings: action, - hydrate: action, - }); - // services - this.projectService = new ProjectService(); - } - - // computed - get canReact() { - return this.settings?.reactions ?? false; - } - get canComment() { - return this.settings?.comments ?? false; - } - get canVote() { - return this.settings?.votes ?? false; - } - - fetchProjectSettings = async (workspace_slug: string, project_slug: string) => { - try { - this.loader = true; - this.error = null; - - const response = await this.projectService.getProjectSettings(workspace_slug, project_slug); - - if (response) { - this.store.issueFilter.updateLayoutOptions(response?.views); - runInAction(() => { - this.project = response?.project_details; - this.workspace = response?.workspace_detail; - this.settings = response; - this.loader = false; - }); - } - return response; - } catch (error) { - this.loader = false; - this.error = error; - return error; - } - }; - - hydrate = (projectSettings: TProjectSettings) => { - const { workspace_detail, project_details } = projectSettings; - this.workspace = workspace_detail; - this.project = project_details; - }; -} diff --git a/space/styles/globals.css b/space/styles/globals.css index 47804b768..0b41d8481 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -302,6 +302,23 @@ } } +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-variant-ligatures: none; + -webkit-font-variant-ligatures: none; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +body { + color: rgba(var(--color-text-100)); +} + ::-webkit-scrollbar { width: 5px; height: 5px; diff --git a/space/tsconfig.json b/space/tsconfig.json index 1305e698f..849d224ef 100644 --- a/space/tsconfig.json +++ b/space/tsconfig.json @@ -11,7 +11,11 @@ "baseUrl": ".", "jsx": "preserve", "paths": { - "@/*": ["*"] + "@/*": ["core/*"], + "@/helpers/*": ["helpers/*"], + "@/public/*": ["public/*"], + "@/styles/*": ["styles/*"], + "@/plane-web/*": ["ce/*"] }, "plugins": [ { diff --git a/space/types/app.d.ts b/space/types/app.d.ts deleted file mode 100644 index bd4af3b0c..000000000 --- a/space/types/app.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface IAppConfig { - email_password_login: boolean; - file_size_limit: number; - google_client_id: string | null; - github_app_name: string | null; - github_client_id: string | null; - magic_login: boolean; - slack_client_id: string | null; - posthog_api_key: string | null; - posthog_host: string | null; - has_openai_configured: boolean; - has_unsplash_configured: boolean; - is_self_managed: boolean; -} diff --git a/space/types/project.d.ts b/space/types/project.d.ts deleted file mode 100644 index 90c89ed80..000000000 --- a/space/types/project.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TLogoProps } from "@plane/types"; - -export type TWorkspaceDetails = { - name: string; - slug: string; - id: string; -}; - -export type TViewDetails = { - list: boolean; - gantt: boolean; - kanban: boolean; - calendar: boolean; - spreadsheet: boolean; -}; - -export type TProjectDetails = { - id: string; - identifier: string; - name: string; - cover_image: string | undefined; - logo_props: TLogoProps; - description: string; -}; - -export type TProjectSettings = { - id: string; - anchor: string; - comments: boolean; - reactions: boolean; - votes: boolean; - inbox: unknown; - workspace: string; - workspace_detail: TWorkspaceDetails; - project: string; - project_details: TProjectDetails; - views: TViewDetails; - created_by: string; - updated_by: string; - created_at: string; - updated_at: string; -}; diff --git a/space/types/theme.d.ts b/space/types/theme.d.ts deleted file mode 100644 index ca306be51..000000000 --- a/space/types/theme.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IThemeStore { - theme: string; - setTheme: (theme: "light" | "dark" | string) => void; -} diff --git a/space/types/user.d.ts b/space/types/user.d.ts deleted file mode 100644 index d58827876..000000000 --- a/space/types/user.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface IUser { - avatar: string; - cover_image: string | null; - created_at: Date; - created_location: string; - date_joined: Date; - email: string; - display_name: string; - first_name: string; - id: string; - is_email_verified: boolean; - is_onboarded: boolean; - is_tour_completed: boolean; - last_location: string; - last_login: Date; - last_name: string; - mobile_number: string; - role: string; - is_password_autoset: boolean; - onboarding_step: { - workspace_join?: boolean; - profile_complete?: boolean; - workspace_create?: boolean; - workspace_invite?: boolean; - }; - token: string; - updated_at: Date; - username: string; - user_timezone: string; -} diff --git a/turbo.json b/turbo.json index fde4ffc79..0325c3b2b 100644 --- a/turbo.json +++ b/turbo.json @@ -24,23 +24,34 @@ "NEXT_PUBLIC_SENTRY_DSN", "SENTRY_MONITORING_ENABLED" ], - "pipeline": { + "tasks": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "dist/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "dist/**" + ] }, "develop": { "cache": false, "persistent": true, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "dev": { "cache": false, "persistent": true, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "test": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [] }, "lint": { diff --git a/web/.eslintignore b/web/.eslintignore new file mode 100644 index 000000000..84f01402d --- /dev/null +++ b/web/.eslintignore @@ -0,0 +1,3 @@ +.next/* +out/* +public/* \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..7d7c7a5f2 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ + +# Sentry Config File +.env.sentry-build-plugin diff --git a/web/components/headers/workspace-active-cycles.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx similarity index 98% rename from web/components/headers/workspace-active-cycles.tsx rename to web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index 5861cba60..f6565f415 100644 --- a/web/components/headers/workspace-active-cycles.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react"; // ui import { Crown } from "lucide-react"; diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx new file mode 100644 index 000000000..cfe3ed842 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceActiveCycleHeader } from "./header"; + +export default function WorkspaceActiveCycleLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx new file mode 100644 index 000000000..f1b389383 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core"; +// hooks +import { useWorkspace } from "@/hooks/store"; +// plane web components +import { WorkspaceActiveCyclesRoot } from "@/plane-web/components/active-cycles"; + +const WorkspaceActiveCyclesPage = observer(() => { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined; + + return ( + <> + + + + ); +}); + +export default WorkspaceActiveCyclesPage; diff --git a/web/components/headers/workspace-analytics.tsx b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx similarity index 92% rename from web/components/headers/workspace-analytics.tsx rename to web/app/[workspaceSlug]/(projects)/analytics/header.tsx index 98ceccbca..dc503dd6d 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx @@ -1,17 +1,22 @@ +"use client"; + import { useEffect } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useSearchParams } from "next/navigation"; +// icons import { BarChart2, PanelRight } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import { useAppTheme } from "@/hooks/store"; export const WorkspaceAnalyticsHeader = observer(() => { - const router = useRouter(); - const { analytics_tab } = router.query; + const searchParams = useSearchParams(); + const analytics_tab = searchParams.get("analytics_tab"); // store hooks const { workspaceAnalyticsSidebarCollapsed, toggleWorkspaceAnalyticsSidebar } = useAppTheme(); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx b/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx new file mode 100644 index 000000000..8dfc8b3b0 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceAnalyticsHeader } from "./header"; + +export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx new file mode 100644 index 000000000..240993a24 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { Tab } from "@headlessui/react"; +// components +import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; +import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; +// constants +import { ANALYTICS_TABS } from "@/constants/analytics"; +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useCommandPalette, useEventTracker, useProject, useWorkspace } from "@/hooks/store"; + +const AnalyticsPage = observer(() => { + const searchParams = useSearchParams(); + const analytics_tab = searchParams.get("analytics_tab"); + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, loader } = useProject(); + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Analytics` : undefined; + + // TODO: refactor loader implementation + return ( + <> + + {workspaceProjectIds && ( + <> + {workspaceProjectIds.length > 0 || loader ? ( +
+ + + {ANALYTICS_TABS.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + +
+ ) : ( + { + setTrackElement("Analytics empty state"); + toggleCreateProjectModal(true); + }} + /> + )} + + )} + + ); +}); + +export default AnalyticsPage; diff --git a/web/components/headers/workspace-dashboard.tsx b/web/app/[workspaceSlug]/(projects)/header.tsx similarity index 99% rename from web/components/headers/workspace-dashboard.tsx rename to web/app/[workspaceSlug]/(projects)/header.tsx index 880b44406..339744d5d 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/app/[workspaceSlug]/(projects)/header.tsx @@ -1,15 +1,18 @@ +"use client"; + import Image from "next/image"; import { useTheme } from "next-themes"; import { Home, Zap } from "lucide-react"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; -// hooks -// components +// ui import { Breadcrumbs } from "@plane/ui"; +// components import { BreadcrumbLink } from "@/components/common"; // constants import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "@/constants/event-tracker"; +// hooks import { useEventTracker } from "@/hooks/store"; export const WorkspaceDashboardHeader = () => { diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/[workspaceSlug]/(projects)/layout.tsx new file mode 100644 index 000000000..f8fe0f8f9 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { CommandPalette } from "@/components/command-palette"; +import { WorkspaceAuthWrapper } from "@/layouts/auth-layout"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { AppSidebar } from "./sidebar"; + +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + return ( + + + +
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx new file mode 100644 index 000000000..49303d361 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -0,0 +1,15 @@ +"use client"; + +// components +import { NotificationsSidebar } from "@/components/workspace-notifications"; + +export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx new file mode 100644 index 000000000..0e29a84d1 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { observer } from "mobx-react"; +import useSWR from "swr"; +// components +import { LogoSpinner } from "@/components/common"; +import { PageHead } from "@/components/core"; +import { InboxContentRoot } from "@/components/inbox"; +import { IssuePeekOverview } from "@/components/issues"; +// constants +import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification"; +// hooks +import { useUser, useWorkspace, useWorkspaceNotifications } from "@/hooks/store"; + +const WorkspaceDashboardPage = observer(() => { + // hooks + const { currentWorkspace } = useWorkspace(); + const { currentSelectedNotification, notificationIdsByWorkspaceId, getNotifications } = useWorkspaceNotifications(); + const { + membership: { fetchUserProjectInfo }, + } = useUser(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Notifications` : undefined; + const { workspace_slug, project_id, issue_id, is_inbox_issue } = currentSelectedNotification; + + // fetch workspace notifications + const notificationMutation = + currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id) + ? ENotificationLoader.MUTATION_LOADER + : ENotificationLoader.INIT_LOADER; + const notificationLoader = + currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id) + ? ENotificationQueryParamType.CURRENT + : ENotificationQueryParamType.INIT; + useSWR( + currentWorkspace?.slug ? `WORKSPACE_NOTIFICATION` : null, + currentWorkspace?.slug + ? () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader) + : null + ); + + // fetching user project member info + const { isLoading: projectMemberInfoLoader } = useSWR( + workspace_slug && project_id && is_inbox_issue + ? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}` + : null, + workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null + ); + + return ( + <> + +
+ {is_inbox_issue === true && workspace_slug && project_id && issue_id ? ( + <> + {projectMemberInfoLoader ? ( +
+ +
+ ) : ( + {}} + isMobileSidebar={false} + workspaceSlug={workspace_slug} + projectId={project_id} + inboxIssueId={issue_id} + isNotificationEmbed + /> + )} + + ) : ( + + )} +
+ + ); +}); + +export default WorkspaceDashboardPage; diff --git a/web/app/[workspaceSlug]/(projects)/page.tsx b/web/app/[workspaceSlug]/(projects)/page.tsx new file mode 100644 index 000000000..d684b923e --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead, AppHeader, ContentWrapper } from "@/components/core"; +import { WorkspaceDashboardView } from "@/components/page-views"; +// hooks +import { useWorkspace } from "@/hooks/store"; +// local components +import { WorkspaceDashboardHeader } from "./header"; + +const WorkspaceDashboardPage = observer(() => { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Home` : undefined; + + return ( + <> + } /> + + + + + + ); +}); + +export default WorkspaceDashboardPage; diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 000000000..b6c1a22f0 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { ProfileIssuesPage } from "@/components/profile/profile-issues"; + +const ProfilePageHeader = { + assigned: "Profile - Assigned", + created: "Profile - Created", + subscribed: "Profile - Subscribed", +}; + +const ProfileIssuesTypePage = () => { + const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined }; + + if (!profileViewId) return null; + + const header = ProfilePageHeader[profileViewId]; + + return ( + <> + + + + ); +}; + +export default ProfileIssuesTypePage; diff --git a/web/pages/profile/activity.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx similarity index 53% rename from web/pages/profile/activity.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx index 414969445..cae273fd4 100644 --- a/web/pages/profile/activity.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -1,27 +1,33 @@ -import { ReactElement, useState } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // ui import { Button } from "@plane/ui"; // components import { PageHead } from "@/components/core"; -import { SidebarHamburgerToggle } from "@/components/core/sidebar"; -import { ProfileActivityListPage } from "@/components/profile"; -//hooks -import { useAppTheme } from "@/hooks/store"; -// layouts -import { ProfileSettingsLayout } from "@/layouts/settings-layout"; -// type -import { NextPageWithLayout } from "@/lib/types"; +import { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/profile"; +// constants +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; const PER_PAGE = 100; -const ProfileActivityPage: NextPageWithLayout = observer(() => { +const ProfileActivityPage = observer(() => { // states const [pageCount, setPageCount] = useState(1); const [totalPages, setTotalPages] = useState(0); const [resultsCount, setResultsCount] = useState(0); + // router + + const { userId } = useParams(); // store hooks - const { toggleSidebar } = useAppTheme(); + const { data: currentUser } = useUser(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); const updateTotalPages = (count: number) => setTotalPages(count); @@ -32,7 +38,7 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { const activityPages: JSX.Element[] = []; for (let i = 0; i < pageCount; i++) activityPages.push( - { /> ); - const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + const canDownloadActivity = + currentUser?.id === userId && !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; return ( <> -
-
- toggleSidebar()} /> -

Activity

+
+
+

Recent activity

+ {canDownloadActivity && }
-
+
{activityPages} - {isLoadMoreVisible && ( + {pageCount < totalPages && resultsCount !== 0 && (
)}
-
+
); }); -ProfileActivityPage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - export default ProfileActivityPage; diff --git a/web/components/headers/user-profile.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx similarity index 96% rename from web/components/headers/user-profile.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index 4f1f44659..f39ebfc44 100644 --- a/web/components/headers/user-profile.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -1,8 +1,10 @@ +"use client"; + // ui import { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { ChevronDown, PanelRight } from "lucide-react"; import { Breadcrumbs, CustomMenu } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common"; @@ -18,8 +20,7 @@ type TUserProfileHeader = { export const UserProfileHeader: FC = observer((props) => { const { type = undefined } = props; // router - const router = useRouter(); - const { workspaceSlug, userId } = router.query; + const { workspaceSlug, userId } = useParams(); // store hooks const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); const { diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx new file mode 100644 index 000000000..b37fa1ec3 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProfileSidebar } from "@/components/profile"; +// constants +import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile"; +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; +// local components +import { UserProfileHeader } from "./header"; +import { ProfileIssuesMobileHeader } from "./mobile-header"; +import { ProfileNavbar } from "./navbar"; + +type Props = { + children: React.ReactNode; +}; + +const AUTHORIZED_ROLES = [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.VIEWER]; + +const UseProfileLayout: React.FC = observer((props) => { + const { children } = props; + // router + const { workspaceSlug, userId } = useParams(); + const pathname = usePathname(); + // store hooks + const { + membership: { currentWorkspaceRole }, + } = useUser(); + // derived values + const isAuthorized = currentWorkspaceRole && AUTHORIZED_ROLES.includes(currentWorkspaceRole); + const isAuthorizedPath = + pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + const isIssuesTab = pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed"); + + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + const currentTab = tabsList.find((tab) => pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`); + + return ( + <> + {/* Passing the type prop from the current route value as we need the header as top most component. + TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */} + } + mobileHeader={isIssuesTab && } + /> + +
+
+ + {isAuthorized || !isAuthorizedPath ? ( +
{children}
+ ) : ( +
+ You do not have the permission to access this page. +
+ )} +
+ +
+
+ + ); +}); + +export default UseProfileLayout; diff --git a/web/components/profile/profile-issues-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx similarity index 90% rename from web/components/profile/profile-issues-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index 657aab0a0..f29e6ff28 100644 --- a/web/components/profile/profile-issues-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { useCallback } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // icons import { ChevronDown } from "lucide-react"; // types @@ -10,16 +12,21 @@ import { CustomMenu } from "@plane/ui"; // components import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { + EIssueFilterType, + EIssueLayoutTypes, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, + ISSUE_LAYOUTS, +} from "@/constants/issue"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel } from "@/hooks/store"; -const ProfileIssuesMobileHeader = observer(() => { +export const ProfileIssuesMobileHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, userId } = router.query; + const { workspaceSlug, userId } = useParams(); // store hook const { issuesFilter: { issueFilters, updateFilters }, @@ -41,7 +48,7 @@ const ProfileIssuesMobileHeader = observer(() => { workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_FILTERS, - { layout: layout }, + { layout: layout as EIssueLayoutTypes | undefined }, userId.toString() ); }, @@ -101,8 +108,6 @@ const ProfileIssuesMobileHeader = observer(() => { [workspaceSlug, updateFilters, userId] ); - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; - return (
{ } - isFiltersApplied={isFiltersApplied} + isFiltersApplied={isIssueFilterActive(issueFilters)} > { } filters={issueFilters?.filters ?? {}} handleFiltersUpdate={handleFiltersUpdate} + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} states={states} labels={workspaceLabels} memberIds={members} @@ -178,5 +185,3 @@ const ProfileIssuesMobileHeader = observer(() => {
); }); - -export default ProfileIssuesMobileHeader; diff --git a/web/components/profile/navbar.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx similarity index 85% rename from web/components/profile/navbar.tsx rename to web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx index 6aace8e35..c54cdfd56 100644 --- a/web/components/profile/navbar.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx @@ -1,7 +1,7 @@ import React from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, usePathname } from "next/navigation"; // components import { ProfileIssuesFilter } from "@/components/profile"; @@ -16,8 +16,8 @@ type Props = { export const ProfileNavbar: React.FC = (props) => { const { isAuthorized, showProfileIssuesFilter } = props; - const router = useRouter(); - const { workspaceSlug, userId } = router.query; + const { workspaceSlug, userId } = useParams(); +const pathname = usePathname(); const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; @@ -28,7 +28,7 @@ export const ProfileNavbar: React.FC = (props) => { { - const router = useRouter(); - const { workspaceSlug, userId } = router.query; +export default function ProfileOverviewPage() { + const { workspaceSlug, userId } = useParams(); const { data: userProfile } = useSWR( workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null, @@ -56,14 +51,4 @@ const ProfileOverviewPage: NextPageWithLayout = () => {
); -}; - -ProfileOverviewPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ProfileOverviewPage; +} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx new file mode 100644 index 000000000..5da3018b3 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveCyclesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx similarity index 52% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx index bcc406fa0..7bc67af5b 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/cycles/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx @@ -1,21 +1,16 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles"; -import { ProjectArchivesHeader } from "@/components/headers"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => { +const ProjectArchivedCyclesPage = observer(() => { // router - const router = useRouter(); - const { projectId } = router.query; + const { projectId } = useParams(); // store hooks const { getProjectById } = useProject(); // derived values @@ -33,12 +28,4 @@ const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => { ); }); -ProjectArchivedCyclesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectArchivedCyclesPage; +export default ProjectArchivedCyclesPage; \ No newline at end of file diff --git a/web/components/headers/project-archives.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx similarity index 82% rename from web/components/headers/project-archives.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 502241461..5f6db17a8 100644 --- a/web/components/headers/project-archives.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // ui import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui"; // components @@ -10,18 +12,23 @@ import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives"; import { EIssuesStoreType } from "@/constants/issue"; // hooks import { useIssues, useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; -export const ProjectArchivesHeader: FC = observer(() => { +type TProps = { + activeTab: 'issues' | 'cycles' | 'modules'; +} + +export const ProjectArchivesHeader: FC = observer((props: TProps) => { + const { activeTab } = props; // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - const activeTab = router.pathname.split("/").pop(); + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); // store hooks const { issuesFilter: { issueFilters }, } = useIssues(EIssuesStoreType.ARCHIVED); - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); // hooks const { isMobile } = usePlatformOS(); @@ -35,10 +42,10 @@ export const ProjectArchivesHeader: FC = observer(() => { PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST]; return ( -
+
- + { + // router + const { workspaceSlug, projectId, archivedIssueId } = useParams(); + // states + // hooks + const { + fetchIssue, + issue: { getIssueById }, + } = useIssueDetail(); + + const { getProjectById } = useProject(); + + const { isLoading, data: swrArchivedIssueDetails } = useSWR( + workspaceSlug && projectId && archivedIssueId + ? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}` + : null, + workspaceSlug && projectId && archivedIssueId + ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString(), "ARCHIVED") + : null + ); + + // derived values + const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined; + const project = issue ? getProjectById(issue?.project_id ?? "") : undefined; + const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; + + if (!issue) return <>; + + const issueLoader = !issue || isLoading ? true : false; + + return ( + <> + + {issueLoader ? ( + +
+ + + + +
+
+ + + + +
+
+ ) : ( +
+
+ {workspaceSlug && projectId && archivedIssueId && ( + + )} +
+
+ )} + + ); +}); + +export default ArchivedIssueDetailsPage; diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx similarity index 80% rename from web/components/headers/project-archived-issue-details.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx index c874745a4..e09438587 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx @@ -1,29 +1,27 @@ -import { FC } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// hooks -import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui"; -import { BreadcrumbLink, Logo } from "@/components/common"; -import { ISSUE_DETAILS } from "@/constants/fetch-keys"; -import { useProject } from "@/hooks/store"; -// components // ui -// types -import { IssueArchiveService } from "@/services/issue"; -// constants -// services -// helpers +import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui"; // components +import { BreadcrumbLink, Logo } from "@/components/common"; +import { IssueDetailQuickActions } from "@/components/issues"; +// constants +import { ISSUE_DETAILS } from "@/constants/fetch-keys"; +// hooks +import { useProject } from "@/hooks/store"; +// services +import { IssueArchiveService } from "@/services/issue"; const issueArchiveService = new IssueArchiveService(); -export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { +export const ProjectArchivedIssueDetailsHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, archivedIssueId } = router.query; + const { workspaceSlug, projectId, archivedIssueId } = useParams(); // store hooks - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); const { data: issueDetails } = useSWR( workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId as string) : null, @@ -38,10 +36,10 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { ); return ( -
+
- + {
+
); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx new file mode 100644 index 000000000..10e6cc582 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivedIssueDetailsHeader } from "./header"; + +export default function ProjectArchivedIssueDetailLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx new file mode 100644 index 000000000..eb2df313d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivesHeader } from "../../header"; + +export default function ProjectArchiveIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx similarity index 52% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx index b2c08e0a3..b2298d540 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/issues/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx @@ -1,21 +1,16 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; -import { ProjectArchivesHeader } from "@/components/headers"; import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { +const ProjectArchivedIssuesPage = observer(() => { // router - const router = useRouter(); - const { projectId } = router.query; + const { projectId } = useParams(); // store hooks const { getProjectById } = useProject(); // derived values @@ -33,12 +28,4 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => { ); }); -ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectArchivedIssuesPage; +export default ProjectArchivedIssuesPage; \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx new file mode 100644 index 000000000..c1e48db7c --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectArchivesHeader } from "../header"; + +export default function ProjectArchiveModulesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx similarity index 56% rename from web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx index 1e346098e..5c1d55d3e 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archives/modules/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx @@ -1,21 +1,16 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; -import { ProjectArchivesHeader } from "@/components/headers"; import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => { +const ProjectArchivedModulesPage = observer(() => { // router - const router = useRouter(); - const { projectId } = router.query; + const { projectId } = useParams(); // store hooks const { getProjectById } = useProject(); // derived values @@ -33,12 +28,4 @@ const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => { ); }); -ProjectArchivedModulesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectArchivedModulesPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx similarity index 72% rename from web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index 6bde8da41..5563debee 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -1,32 +1,32 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// hooks +// components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; import { CycleDetailsSidebar } from "@/components/cycles"; -import { CycleMobileHeader } from "@/components/cycles/cycle-mobile-header"; -import { CycleIssuesHeader } from "@/components/headers"; import { CycleLayoutRoot } from "@/components/issues/issue-layouts"; +// constants +// import { EIssuesStoreType } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks import { useCycle, useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// components -// ui // assets -import { NextPageWithLayout } from "@/lib/types"; -import emptyCycle from "public/empty-state/cycle.svg"; -// types +import emptyCycle from "@/public/empty-state/cycle.svg"; -const CycleDetailPage: NextPageWithLayout = observer(() => { +const CycleDetailPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, cycleId } = router.query; + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams(); // store hooks const { fetchCycleDetails, getCycleById } = useCycle(); const { getProjectById } = useProject(); + // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); // hooks const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); // fetching cycle details @@ -47,6 +47,8 @@ const CycleDetailPage: NextPageWithLayout = observer(() => { */ const toggleSidebar = () => setValue(`${!isSidebarCollapsed}`); + // const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + return ( <> @@ -68,7 +70,9 @@ const CycleDetailPage: NextPageWithLayout = observer(() => {
{cycleId && !isSidebarCollapsed && (
{ ); }); -CycleDetailPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - export default CycleDetailPage; diff --git a/web/components/headers/cycle-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx similarity index 89% rename from web/components/headers/cycle-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index c26be9606..2b7f541fb 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -1,11 +1,13 @@ +"use client"; + import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; // components @@ -13,11 +15,16 @@ import { ProjectAnalyticsModal } from "@/components/analytics"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { + EIssueFilterType, + EIssueLayoutTypes, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, +} from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; -import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { isIssueFilterActive } from "@/helpers/filter.helper"; import { truncateText } from "@/helpers/string.helper"; // hooks import { @@ -31,13 +38,13 @@ import { useIssues, useCommandPalette, } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; import { usePlatformOS } from "@/hooks/use-platform-os"; const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { getCycleById } = useCycle(); // derived values @@ -60,8 +67,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { // states const [analyticsModal, setAnalyticsModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, projectId, cycleId } = router.query as { + const router = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useParams() as { workspaceSlug: string; projectId: string; cycleId: string; @@ -69,7 +76,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { // store hooks const { issuesFilter: { issueFilters, updateFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.CYCLE); const { currentProjectCycleIds, getCycleById } = useCycle(); const { toggleCreateIssueModal } = useCommandPalette(); @@ -77,7 +84,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { const { membership: { currentProjectRole }, } = useUser(); - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); const { @@ -95,7 +102,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { }; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); }, @@ -145,7 +152,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -157,7 +164,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
- + {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/cycles/cycle-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx similarity index 91% rename from web/components/cycles/cycle-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 3e37b586f..27e33e2c2 100644 --- a/web/components/cycles/cycle-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -1,22 +1,30 @@ +"use client"; + import { useCallback, useState } from "react"; -import router from "next/router"; +import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { + EIssueFilterType, + EIssueLayoutTypes, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, + ISSUE_LAYOUTS, +} from "@/constants/issue"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useCycle, useProjectState, useLabel, useMember, useProject } from "@/hooks/store"; -export const CycleMobileHeader = () => { +export const CycleIssuesMobileHeader = () => { const [analyticsModal, setAnalyticsModal] = useState(false); const { getCycleById } = useCycle(); const layouts = [ @@ -25,7 +33,7 @@ export const CycleMobileHeader = () => { { key: "calendar", title: "Calendar", icon: Calendar }, ]; - const { workspaceSlug, projectId, cycleId } = router.query; + const { workspaceSlug, projectId, cycleId } = useParams(); const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; // store hooks const { currentProjectDetails } = useProject(); @@ -35,7 +43,7 @@ export const CycleMobileHeader = () => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId || !cycleId) return; updateFilters( workspaceSlug.toString(), @@ -107,8 +115,6 @@ export const CycleMobileHeader = () => { [workspaceSlug, projectId, cycleId, updateFilters] ); - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; - return ( <> { } - isFiltersApplied={isFiltersApplied} + isFiltersApplied={isIssueFilterActive(issueFilters)} > { layoutDisplayFiltersOptions={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined } + displayFilters={issueFilters?.displayFilters ?? {}} + handleDisplayFiltersUpdate={handleDisplayFilters} labels={projectLabels} memberIds={projectMemberIds ?? undefined} states={projectStates} diff --git a/web/components/headers/cycles.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx similarity index 85% rename from web/components/headers/cycles.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index 76493bd51..f38fe94f2 100644 --- a/web/components/headers/cycles.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { FC } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // ui import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; // components @@ -10,18 +12,19 @@ import { CyclesViewHeader } from "@/components/cycles"; import { EUserProjectRoles } from "@/constants/project"; // hooks import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; -export const CyclesHeader: FC = observer(() => { +export const CyclesListHeader: FC = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const router = useAppRouter(); + const { workspaceSlug } = useParams(); // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -30,7 +33,7 @@ export const CyclesHeader: FC = observer(() => {
- + + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/cycles/cycles-list-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx similarity index 95% rename from web/components/cycles/cycles-list-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx index 590cb794f..ecb1d5b90 100644 --- a/web/components/cycles/cycles-list-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx @@ -1,3 +1,5 @@ +"use client"; + import { observer } from "mobx-react"; // ui import { List } from "lucide-react"; @@ -8,7 +10,7 @@ import { CYCLE_VIEW_LAYOUTS } from "@/constants/cycle"; // hooks import { useCycleFilter, useProject } from "@/hooks/store"; -const CyclesListMobileHeader = observer(() => { +export const CyclesListMobileHeader = observer(() => { const { currentProjectDetails } = useProject(); // hooks const { updateDisplayFilters } = useCycleFilter(); @@ -48,5 +50,3 @@ const CyclesListMobileHeader = observer(() => {
); }); - -export default CyclesListMobileHeader; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx similarity index 82% rename from web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5b7f313e7..5c046d95c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -1,14 +1,14 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types import { TCycleFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles"; -import CyclesListMobileHeader from "@/components/cycles/cycles-list-mobile-header"; import { EmptyState } from "@/components/empty-state"; -import { CyclesHeader } from "@/components/headers"; import { CycleModuleListLayout } from "@/components/ui"; // constants import { EmptyStateType } from "@/constants/empty-state"; @@ -16,12 +16,8 @@ import { EmptyStateType } from "@/constants/empty-state"; import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks import { useEventTracker, useCycle, useProject, useCycleFilter } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectCyclesPage: NextPageWithLayout = observer(() => { +const ProjectCyclesPage = observer(() => { // states const [createModal, setCreateModal] = useState(false); // store hooks @@ -29,8 +25,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { const { currentProjectCycleIds, loader } = useCycle(); const { getProjectById, currentProjectDetails } = useProject(); // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // cycle filters hook const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter(); // derived values @@ -103,12 +98,4 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { ); }); -ProjectCyclesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } mobileHeader={} withProjectWrapper> - {page} - - ); -}; - export default ProjectCyclesPage; diff --git a/web/components/headers/project-draft-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx similarity index 89% rename from web/components/headers/project-draft-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx index 8c8a25c9e..49816bad7 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx @@ -1,30 +1,36 @@ +"use client"; + import { FC, useCallback } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { + EIssueFilterType, + EIssuesStoreType, + EIssueLayoutTypes, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, +} from "@/constants/issue"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; export const ProjectDraftIssueHeader: FC = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.DRAFT); - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); const { @@ -55,7 +61,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { ); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, @@ -84,13 +90,11 @@ export const ProjectDraftIssueHeader: FC = observer(() => { : currentProjectDetails.draft_issues : undefined; - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; - return (
- + {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx similarity index 65% rename from web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx index f6e0658d9..6268823d0 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/draft-issues/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx @@ -1,21 +1,18 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { X, PenSquare } from "lucide-react"; -// layouts // components import { PageHead } from "@/components/core"; -import { ProjectDraftIssueHeader } from "@/components/headers"; import { DraftIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/draft-issue-layout-root"; -// types // hooks import { useProject } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { NextPageWithLayout } from "@/lib/types"; +import { useAppRouter } from "@/hooks/use-app-router"; -const ProjectDraftIssuesPage: NextPageWithLayout = observer(() => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; +const ProjectDraftIssuesPage = observer(() => { + const router = useAppRouter(); + const { workspaceSlug, projectId } = useParams(); // store const { getProjectById } = useProject(); // derived values @@ -43,12 +40,4 @@ const ProjectDraftIssuesPage: NextPageWithLayout = observer(() => { ); }); -ProjectDraftIssuesPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - -export default ProjectDraftIssuesPage; +export default ProjectDraftIssuesPage; \ No newline at end of file diff --git a/web/components/headers/project-inbox.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx similarity index 86% rename from web/components/headers/project-inbox.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx index ce76f3e40..aecedb8ad 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { FC, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; // ui import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; @@ -14,17 +16,16 @@ export const ProjectInboxHeader: FC = observer(() => { // states const [createIssueModal, setCreateIssueModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader: currentProjectDetailsLoader } = useProject(); const { loader } = useProjectInbox(); return (
- + { } /> - } + link={} />} /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx new file mode 100644 index 000000000..167823fc2 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +import { ProjectInboxHeader } from "./header"; + +export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx similarity index 59% rename from web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx index f32adaf5d..0a2ecd17d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/inbox/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx @@ -1,34 +1,28 @@ -import { ReactElement, useEffect } from "react"; +"use client"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; // components +import { useParams, useSearchParams } from "next/navigation"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { ProjectInboxHeader } from "@/components/headers"; import { InboxIssueRoot } from "@/components/inbox"; // constants import { EmptyStateType } from "@/constants/empty-state"; // helpers import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper"; // hooks -import { useProject, useProjectInbox } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; +import { useProject } from "@/hooks/store"; -const ProjectInboxPage: NextPageWithLayout = observer(() => { +const ProjectInboxPage = observer(() => { /// router - const router = useRouter(); - const { workspaceSlug, projectId, currentTab: navigationTab, inboxIssueId } = router.query; + const { workspaceSlug, projectId } = useParams(); + + const searchParams = useSearchParams(); + + const navigationTab = searchParams.get("currentTab"); + const inboxIssueId = searchParams.get("inboxIssueId"); + // hooks const { currentProjectDetails } = useProject(); - const { currentTab, handleCurrentTab } = useProjectInbox(); - - useEffect(() => { - if (navigationTab && currentTab != navigationTab) - handleCurrentTab(navigationTab === "open" ? EInboxIssueCurrentTab.OPEN : EInboxIssueCurrentTab.CLOSED); - }, [currentTab, navigationTab, handleCurrentTab]); // No access to inbox if (currentProjectDetails?.inbox_view === false) @@ -44,6 +38,12 @@ const ProjectInboxPage: NextPageWithLayout = observer(() => { // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox"; + const currentNavigationTab = navigationTab + ? navigationTab === "open" + ? EInboxIssueCurrentTab.OPEN + : EInboxIssueCurrentTab.CLOSED + : undefined; + if (!workspaceSlug || !projectId) return <>; return ( @@ -55,18 +55,11 @@ const ProjectInboxPage: NextPageWithLayout = observer(() => { projectId={projectId.toString()} inboxIssueId={inboxIssueId?.toString() || undefined} inboxAccessible={currentProjectDetails?.inbox_view || false} + navigationTab={currentNavigationTab} />
); }); -ProjectInboxPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectInboxPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx similarity index 85% rename from web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index cdd40a38e..27b875090 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -1,6 +1,8 @@ -import React, { ReactElement, useEffect } from "react"; +"use client"; + +import React, { useEffect } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; // ui @@ -8,22 +10,18 @@ import { Loader } from "@plane/ui"; // components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; -import { ProjectIssueDetailsHeader } from "@/components/headers"; import { IssueDetailRoot } from "@/components/issues"; // hooks import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; // assets +import { useAppRouter } from "@/hooks/use-app-router"; import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"; import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"; -const IssueDetailsPage: NextPageWithLayout = observer(() => { +const IssueDetailsPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const router = useAppRouter(); + const { workspaceSlug, projectId, issueId } = useParams(); // hooks const { resolvedTheme } = useTheme(); // store hooks @@ -108,12 +106,4 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => { ); }); -IssueDetailsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default IssueDetailsPage; diff --git a/web/components/headers/project-issue-details.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx similarity index 81% rename from web/components/headers/project-issue-details.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx index 890bd59e5..9f0747cce 100644 --- a/web/components/headers/project-issue-details.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/header.tsx @@ -1,24 +1,25 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// hooks -import { PanelRight } from "lucide-react"; -import { Breadcrumbs, LayersIcon } from "@plane/ui"; -import { BreadcrumbLink, Logo } from "@/components/common"; -import { cn } from "@/helpers/common.helper"; -import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; -// ui -// helpers -// services -// constants -// components +"use client"; -export const ProjectIssueDetailsHeader: FC = observer(() => { +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { PanelRight } from "lucide-react"; +// ui +import { Breadcrumbs, LayersIcon } from "@plane/ui"; +// components +import { BreadcrumbLink, Logo } from "@/components/common"; +import { IssueDetailQuickActions } from "@/components/issues"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; + +export const ProjectIssueDetailsHeader = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const router = useAppRouter(); + const { workspaceSlug, projectId, issueId } = useParams(); // store hooks - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); const { issueDetailSidebarCollapsed, toggleIssueDetailSidebar } = useAppTheme(); const { issue: { getIssueById }, @@ -31,7 +32,7 @@ export const ProjectIssueDetailsHeader: FC = observer(() => {
- + {
+ + + } + /> + ); + } + + return ( + <> + } /> + +
+
+ +
+
+ {children} +
+
+
+ + ); +}); + +export default ProjectSettingLayout; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx similarity index 54% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx index 6d60c0e76..af1c82e12 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/members.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx @@ -1,18 +1,13 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -// layouts // components import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; -// types // hooks import { useProject } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -import { NextPageWithLayout } from "@/lib/types"; -const MembersSettingsPage: NextPageWithLayout = observer(() => { +const MembersSettingsPage = observer(() => { // store const { currentProjectDetails } = useProject(); // derived values @@ -29,12 +24,4 @@ const MembersSettingsPage: NextPageWithLayout = observer(() => { ); }); -MembersSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default MembersSettingsPage; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx similarity index 79% rename from web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx index b07be48a5..cc5cfc855 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx @@ -1,10 +1,11 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { PageHead } from "@/components/core"; -import { ProjectSettingHeader } from "@/components/headers"; import { ArchiveRestoreProjectModal, ArchiveProjectSelection, @@ -15,19 +16,13 @@ import { } from "@/components/project"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const GeneralSettingsPage: NextPageWithLayout = observer(() => { +const GeneralSettingsPage = observer(() => { // states const [selectProject, setSelectedProject] = useState(null); const [archiveProject, setArchiveProject] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store hooks const { currentProjectDetails, fetchProjectDetails } = useProject(); // api call to fetch project details @@ -91,12 +86,4 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => { ); }); -GeneralSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default GeneralSettingsPage; diff --git a/web/layouts/settings-layout/project/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx similarity index 88% rename from web/layouts/settings-layout/project/sidebar.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx index 98fc16bb9..af5e5355a 100644 --- a/web/layouts/settings-layout/project/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx @@ -1,16 +1,18 @@ +"use client"; + import React from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams, usePathname } from "next/navigation"; // ui import { Loader } from "@plane/ui"; -// hooks -import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; -import { useUser } from "@/hooks/store"; // constants +import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; +// hooks +import { useUser } from "@/hooks/store"; export const ProjectSettingsSidebar = () => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); + const pathname = usePathname(); // mobx store const { membership: { currentProjectRole }, @@ -44,7 +46,7 @@ export const ProjectSettingsSidebar = () => {
{ +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core"; +import { ProjectSettingStateList } from "@/components/states"; +// hook +import { useProject } from "@/hooks/store"; + +const StatesSettingsPage = observer(() => { // store const { currentProjectDetails } = useProject(); // derived values @@ -30,12 +25,4 @@ const StatesSettingsPage: NextPageWithLayout = observer(() => { ); }); -StatesSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - export default StatesSettingsPage; diff --git a/web/components/headers/project-view-issues.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx similarity index 68% rename from web/components/headers/project-view-issues.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 0e8f59e6c..7647633bc 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -1,19 +1,28 @@ +"use client"; + import { useCallback } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; +import { Earth, Lock } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui -import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui"; +import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon, Tooltip } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { + EIssuesStoreType, + EIssueFilterType, + EIssueLayoutTypes, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, +} from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +import { EViewAccess } from "@/constants/views"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { isIssueFilterActive } from "@/helpers/filter.helper"; import { truncateText } from "@/helpers/string.helper"; // hooks import { @@ -30,8 +39,7 @@ import { export const ProjectViewIssuesHeader: React.FC = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId, viewId } = router.query; + const { workspaceSlug, projectId, viewId } = useParams(); // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -41,7 +49,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const { membership: { currentProjectRole }, } = useUser(); - const { currentProjectDetails } = useProject(); + const { currentProjectDetails, loader } = useProject(); const { projectViewIds, getViewById } = useProjectView(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); @@ -52,7 +60,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId || !viewId) return; updateFilters( workspaceSlug.toString(), @@ -125,12 +133,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; - return (
- + { } /> + +
+ + {viewDetails?.access === EViewAccess.PUBLIC ? : } + +
- handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> + {!viewDetails?.is_locked && ( + <> + handleLayoutChange(layout)} + selectedLayout={activeLayout} + /> - - - - - - + + + + + + + + )} {canUserCreateIssue && (
-
- )} + )} +
+ {isFiltersApplied && ( +
+ +
+ )} ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx new file mode 100644 index 000000000..69493402d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { ProjectViewsHeader } from "./header"; + +export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) { + return ( + <> + } /> + {children} + + ); +} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx similarity index 65% rename from web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index dbdc0f192..25daf594c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/views/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -1,24 +1,19 @@ -import { ReactElement } from "react"; +"use client"; + import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { ProjectViewsHeader } from "@/components/headers"; import { ProjectViewsList } from "@/components/views"; // constants import { EmptyStateType } from "@/constants/empty-state"; // hooks import { useProject } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -const ProjectViewsPage: NextPageWithLayout = observer(() => { +const ProjectViewsPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId } = useParams(); // store const { getProjectById, currentProjectDetails } = useProject(); // derived values @@ -46,12 +41,4 @@ const ProjectViewsPage: NextPageWithLayout = observer(() => { ); }); -ProjectViewsPage.getLayout = function getLayout(page: ReactElement) { - return ( - } withProjectWrapper> - {page} - - ); -}; - export default ProjectViewsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx new file mode 100644 index 000000000..fc2ec0075 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ReactNode } from "react"; +// layouts +import { ProjectAuthWrapper } from "@/layouts/auth-layout"; + +const ProjectDetailLayout = ({ children }: { children: ReactNode }) => ( + {children} +); + +export default ProjectDetailLayout; diff --git a/web/components/headers/projects.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx similarity index 90% rename from web/components/headers/projects.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx index 7126b2697..04c697781 100644 --- a/web/components/headers/projects.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx @@ -1,5 +1,8 @@ -import { useCallback, useRef, useState } from "react"; +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { Search, Briefcase, X, ListFilter } from "lucide-react"; // types import { TProjectFilters } from "@plane/types"; @@ -15,17 +18,18 @@ import { EUserWorkspaceRoles } from "@/constants/workspace"; import { cn } from "@/helpers/common.helper"; import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useAppRouter, useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store"; +import { useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -export const ProjectsHeader = observer(() => { +export const ProjectsListHeader = observer(() => { + // router + const { workspaceSlug } = useParams(); // states const [isSearchOpen, setIsSearchOpen] = useState(false); // refs const inputRef = useRef(null); // store hooks const { toggleCreateProjectModal } = useCommandPalette(); - const { workspaceSlug } = useAppRouter(); const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, @@ -66,7 +70,7 @@ export const ProjectsHeader = observer(() => { } } - updateFilters(workspaceSlug, { [key]: newValues }); + updateFilters(workspaceSlug.toString(), { [key]: newValues }); }, [filters, updateFilters, workspaceSlug] ); @@ -78,6 +82,10 @@ export const ProjectsHeader = observer(() => { } }; + useEffect(() => { + if (searchQuery.trim() !== "") setIsSearchOpen(true); + }, [searchQuery]); + const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0; return ( @@ -142,7 +150,7 @@ export const ProjectsHeader = observer(() => { value={displayFilters?.order_by} onChange={(val) => { if (!workspaceSlug || val === displayFilters?.order_by) return; - updateDisplayFilters(workspaceSlug, { + updateDisplayFilters(workspaceSlug.toString(), { order_by: val, }); }} @@ -159,7 +167,7 @@ export const ProjectsHeader = observer(() => { handleFiltersUpdate={handleFilters} handleDisplayFiltersUpdate={(val) => { if (!workspaceSlug) return; - updateDisplayFilters(workspaceSlug, val); + updateDisplayFilters(workspaceSlug.toString(), val); }} memberIds={workspaceMemberIds ?? undefined} /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx new file mode 100644 index 000000000..259c412dc --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { ProjectsListHeader } from "./header"; +import { ProjectsListMobileHeader } from "./mobile-header"; + +export default function ProjectListLayout({ children }: { children: ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/components/project/projects-mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx similarity index 86% rename from web/components/project/projects-mobile-header.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx index e5a9ebbf8..cd8eb9dfe 100644 --- a/web/components/project/projects-mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // icons import { ChevronDown, ListFilter } from "lucide-react"; // types @@ -10,9 +11,11 @@ import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/pr // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useAppRouter, useMember, useProjectFilter } from "@/hooks/store"; +import { useMember, useProjectFilter } from "@/hooks/store"; -const ProjectsMobileHeader = observer(() => { +export const ProjectsListMobileHeader = observer(() => { + // router + const { workspaceSlug } = useParams(); const { currentWorkspaceDisplayFilters: displayFilters, currentWorkspaceFilters: filters, @@ -20,7 +23,6 @@ const ProjectsMobileHeader = observer(() => { updateFilters, } = useProjectFilter(); - const { workspaceSlug } = useAppRouter(); const { workspace: { workspaceMemberIds }, @@ -39,7 +41,7 @@ const ProjectsMobileHeader = observer(() => { if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); } - updateFilters(workspaceSlug, { [key]: newValues }); + updateFilters(workspaceSlug.toString(), { [key]: newValues }); }, [filters, updateFilters, workspaceSlug] ); @@ -52,7 +54,7 @@ const ProjectsMobileHeader = observer(() => { value={displayFilters?.order_by} onChange={(val) => { if (!workspaceSlug || val === displayFilters?.order_by) return; - updateDisplayFilters(workspaceSlug, { + updateDisplayFilters(workspaceSlug.toString(), { order_by: val, }); }} @@ -78,7 +80,7 @@ const ProjectsMobileHeader = observer(() => { handleFiltersUpdate={handleFilters} handleDisplayFiltersUpdate={(val) => { if (!workspaceSlug) return; - updateDisplayFilters(workspaceSlug, val); + updateDisplayFilters(workspaceSlug.toString(), val); }} memberIds={workspaceMemberIds ?? undefined} /> @@ -87,5 +89,3 @@ const ProjectsMobileHeader = observer(() => {
); }); - -export default ProjectsMobileHeader; diff --git a/web/pages/[workspaceSlug]/projects/index.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx similarity index 80% rename from web/pages/[workspaceSlug]/projects/index.tsx rename to web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx index 5646a5c01..40e7f30a2 100644 --- a/web/pages/[workspaceSlug]/projects/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx @@ -1,22 +1,21 @@ -import { ReactElement, useCallback } from "react"; +"use client"; + +import { useCallback } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// types import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; -import { ProjectsHeader } from "@/components/headers"; import { ProjectAppliedFiltersList, ProjectCardList } from "@/components/project"; -// layouts -import ProjectsMobileHeader from "@/components/project/projects-mobile-header"; -import { calculateTotalFilters } from "@/helpers/filter.helper"; -import { useAppRouter, useProject, useProjectFilter, useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; // helpers -// types -import { NextPageWithLayout } from "@/lib/types"; +import { calculateTotalFilters } from "@/helpers/filter.helper"; +// hooks +import { useProject, useProjectFilter, useWorkspace } from "@/hooks/store"; -const ProjectsPage: NextPageWithLayout = observer(() => { +const ProjectsPage = observer(() => { // store - const { workspaceSlug } = useAppRouter(); + const { workspaceSlug } = useParams(); const { currentWorkspace } = useWorkspace(); const { totalProjectIds, filteredProjectIds } = useProject(); const { @@ -82,8 +81,4 @@ const ProjectsPage: NextPageWithLayout = observer(() => { ); }); -ProjectsPage.getLayout = function getLayout(page: ReactElement) { - return } mobileHeader={}>{page}; -}; - export default ProjectsPage; diff --git a/web/pages/[workspaceSlug]/settings/api-tokens.tsx b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx similarity index 82% rename from web/pages/[workspaceSlug]/settings/api-tokens.tsx rename to web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx index 464c5e373..906fee328 100644 --- a/web/pages/[workspaceSlug]/settings/api-tokens.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx @@ -1,37 +1,32 @@ +"use client"; + import React, { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// store hooks +// ui import { Button } from "@plane/ui"; +// component import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { APITokenSettingsLoader } from "@/components/ui"; +// constants import { EmptyStateType } from "@/constants/empty-state"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; import { EUserWorkspaceRoles } from "@/constants/workspace"; +// store hooks import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// component -// ui // services -import { NextPageWithLayout } from "@/lib/types"; import { APITokenService } from "@/services/api_token.service"; -// types -// constants const apiTokenService = new APITokenService(); -const ApiTokensPage: NextPageWithLayout = observer(() => { +const ApiTokensPage = observer(() => { // states const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false); // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { membership: { currentWorkspaceRole }, @@ -97,12 +92,4 @@ const ApiTokensPage: NextPageWithLayout = observer(() => { ); }); -ApiTokensPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ApiTokensPage; +export default ApiTokensPage; \ No newline at end of file diff --git a/web/pages/[workspaceSlug]/settings/billing.tsx b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx similarity index 76% rename from web/pages/[workspaceSlug]/settings/billing.tsx rename to web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx index bde5abebb..96cf35a53 100644 --- a/web/pages/[workspaceSlug]/settings/billing.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx @@ -1,21 +1,17 @@ +"use client"; + import { observer } from "mobx-react"; -// hooks +// ui import { Button } from "@plane/ui"; +// component import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; +// constants import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common"; import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// component -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; -// constants -const BillingSettingsPage: NextPageWithLayout = observer(() => { +const BillingSettingsPage = observer(() => { // store hooks const { membership: { currentWorkspaceRole }, @@ -58,12 +54,4 @@ const BillingSettingsPage: NextPageWithLayout = observer(() => { ); }); -BillingSettingsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - export default BillingSettingsPage; diff --git a/web/pages/[workspaceSlug]/settings/exports.tsx b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx similarity index 70% rename from web/pages/[workspaceSlug]/settings/exports.tsx rename to web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx index 730f34e49..59fd4d2c7 100644 --- a/web/pages/[workspaceSlug]/settings/exports.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx @@ -1,19 +1,15 @@ +"use client"; + import { observer } from "mobx-react"; -// hooks +// components import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; -import { WorkspaceSettingHeader } from "@/components/headers"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { useUser, useWorkspace } from "@/hooks/store"; -// layout -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// types -import { NextPageWithLayout } from "@/lib/types"; // constants +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useUser, useWorkspace } from "@/hooks/store"; -const ExportsPage: NextPageWithLayout = observer(() => { +const ExportsPage = observer(() => { // store hooks const { membership: { currentWorkspaceRole }, @@ -48,12 +44,4 @@ const ExportsPage: NextPageWithLayout = observer(() => { ); }); -ExportsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ExportsPage; +export default ExportsPage; \ No newline at end of file diff --git a/web/components/headers/workspace-settings.tsx b/web/app/[workspaceSlug]/(projects)/settings/header.tsx similarity index 88% rename from web/components/headers/workspace-settings.tsx rename to web/app/[workspaceSlug]/(projects)/settings/header.tsx index 2d3e9649e..5407f79bc 100644 --- a/web/components/headers/workspace-settings.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/header.tsx @@ -1,5 +1,7 @@ +"use client"; + import { FC } from "react"; -import { observer } from "mobx-react";; +import { observer } from "mobx-react"; import { Settings } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; @@ -9,13 +11,13 @@ import { BreadcrumbLink } from "@/components/common"; import { useWorkspace } from "@/hooks/store"; export const WorkspaceSettingHeader: FC = observer(() => { - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, loader } = useWorkspace(); return (
- + { +const ImportsPage = observer(() => { // store hooks const { membership: { currentWorkspaceRole }, @@ -47,12 +43,4 @@ const ImportsPage: NextPageWithLayout = observer(() => { ); }); -ImportsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - export default ImportsPage; diff --git a/web/pages/[workspaceSlug]/settings/integrations.tsx b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx similarity index 72% rename from web/pages/[workspaceSlug]/settings/integrations.tsx rename to web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx index 702ca5884..7db73cbe6 100644 --- a/web/pages/[workspaceSlug]/settings/integrations.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx @@ -1,10 +1,9 @@ -import { ReactElement } from "react"; +"use client" import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { SingleIntegrationCard } from "@/components/integration"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants @@ -12,20 +11,14 @@ import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; // services import { IntegrationService } from "@/services/integrations"; const integrationService = new IntegrationService(); -const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { +const WorkspaceIntegrationsPage = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { membership: { currentWorkspaceRole }, @@ -69,12 +62,4 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { ); }); -WorkspaceIntegrationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WorkspaceIntegrationsPage; +export default WorkspaceIntegrationsPage; \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx new file mode 100644 index 000000000..bd04e2d6e --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { WorkspaceSettingHeader } from "./header"; +import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; +import { WorkspaceSettingsSidebar } from "./sidebar"; + +export interface IWorkspaceSettingLayout { + children: ReactNode; +} + +export default function WorkspaceSettingLayout(props: IWorkspaceSettingLayout) { + const { children } = props; + + return ( + <> + } /> + +
+
+ +
+
+ +
+ {children} +
+
+
+
+ + ); +} diff --git a/web/pages/[workspaceSlug]/settings/members.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx similarity index 84% rename from web/pages/[workspaceSlug]/settings/members.tsx rename to web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index e01528ddc..2899c5ed5 100644 --- a/web/pages/[workspaceSlug]/settings/members.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -1,34 +1,30 @@ -import { useState, ReactElement } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import { Search } from "lucide-react"; +// types import { IWorkspaceBulkInviteFormData } from "@plane/types"; -// hooks +// ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// components import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace"; +// constants import { MEMBER_INVITED } from "@/constants/event-tracker"; import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { getUserRole } from "@/helpers/user.helper"; -import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; // helpers -// constants +import { getUserRole } from "@/helpers/user.helper"; +// hooks +import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store"; -const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { +const WorkspaceMembersSettingsPage = observer(() => { // states const [inviteModal, setInviteModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // store hooks const { captureEvent } = useEventTracker(); const { @@ -120,12 +116,4 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { ); }); -WorkspaceMembersSettingsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - export default WorkspaceMembersSettingsPage; diff --git a/web/components/workspace/settings/mobile-workspace-settings-tabs.tsx b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx similarity index 66% rename from web/components/workspace/settings/mobile-workspace-settings-tabs.tsx rename to web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx index 1cea866bd..4ffa0aff5 100644 --- a/web/components/workspace/settings/mobile-workspace-settings-tabs.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx @@ -1,17 +1,21 @@ -import router from "next/router"; +import { useParams, usePathname } from "next/navigation"; +// constants import { WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; -const MobileWorkspaceSettingsTabs = () => { - const { workspaceSlug } = router.query; +export const MobileWorkspaceSettingsTabs = () => { + const router = useAppRouter(); + const { workspaceSlug } = useParams(); + const pathname = usePathname(); return (
{WORKSPACE_SETTINGS_LINKS.map((item, index) => (
router.push(`/${workspaceSlug}${item.href}`)} > @@ -21,5 +25,3 @@ const MobileWorkspaceSettingsTabs = () => {
); }; - -export default MobileWorkspaceSettingsTabs; diff --git a/web/app/[workspaceSlug]/(projects)/settings/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/page.tsx new file mode 100644 index 000000000..c628e313c --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/page.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core"; +import { WorkspaceDetails } from "@/components/workspace"; +// hooks +import { useWorkspace } from "@/hooks/store"; + +const WorkspaceSettingsPage = observer(() => { + // store hooks + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - General Settings` : undefined; + + return ( + <> + + + + ); +}); + +export default WorkspaceSettingsPage; diff --git a/web/layouts/settings-layout/workspace/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx similarity index 73% rename from web/layouts/settings-layout/workspace/sidebar.tsx rename to web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx index f49eb84d9..8dfd0d7c3 100644 --- a/web/layouts/settings-layout/workspace/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx @@ -1,16 +1,18 @@ +"use client"; + import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; -// hooks -import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; -import { useUser } from "@/hooks/store"; +import { useParams, usePathname } from "next/navigation"; // constants +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; export const WorkspaceSettingsSidebar = observer(() => { // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); + const pathname = usePathname(); // mobx store const { membership: { currentWorkspaceRole }, @@ -29,11 +31,10 @@ export const WorkspaceSettingsSidebar = observer(() => {
{link.label}
diff --git a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx similarity index 81% rename from web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx rename to web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx index 71a16ea8b..ce3e7a5eb 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/[webhookId].tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx @@ -1,29 +1,24 @@ +"use client"; + import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; import { IWebhook } from "@plane/types"; -// hooks +// ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; +// hooks import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; -const WebhookDetailsPage: NextPageWithLayout = observer(() => { +const WebhookDetailsPage = observer(() => { // states const [deleteWebhookModal, setDeleteWebhookModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, webhookId } = router.query; + const { workspaceSlug, webhookId } = useParams(); // mobx store const { membership: { currentWorkspaceRole }, @@ -105,12 +100,4 @@ const WebhookDetailsPage: NextPageWithLayout = observer(() => { ); }); -WebhookDetailsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WebhookDetailsPage; +export default WebhookDetailsPage; \ No newline at end of file diff --git a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx similarity index 82% rename from web/pages/[workspaceSlug]/settings/webhooks/index.tsx rename to web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx index 6046df7e1..695f1f16b 100644 --- a/web/pages/[workspaceSlug]/settings/webhooks/index.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx @@ -1,31 +1,26 @@ +"use client"; + import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; +import { useParams } from "next/navigation"; import useSWR from "swr"; -// hooks +// ui import { Button } from "@plane/ui"; +// components import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; -import { WorkspaceSettingHeader } from "@/components/headers"; import { WebhookSettingsLoader } from "@/components/ui"; import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; -import { EmptyStateType } from "@/constants/empty-state"; -import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// ui -// types -import { NextPageWithLayout } from "@/lib/types"; // constants +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; -const WebhooksListPage: NextPageWithLayout = observer(() => { +const WebhooksListPage = observer(() => { // states const [showCreateWebhookModal, setShowCreateWebhookModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug } = useParams(); // mobx store const { membership: { currentWorkspaceRole }, @@ -100,12 +95,4 @@ const WebhooksListPage: NextPageWithLayout = observer(() => { ); }); -WebhooksListPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WebhooksListPage; +export default WebhooksListPage; \ No newline at end of file diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx new file mode 100644 index 000000000..35fbaff4b --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -0,0 +1,77 @@ +import { FC, useRef } from "react"; +import { observer } from "mobx-react"; +// components +import { + SidebarDropdown, + SidebarHelpSection, + SidebarProjectsList, + SidebarQuickActions, + SidebarUserMenu, + SidebarWorkspaceMenu, +} from "@/components/workspace"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppTheme } from "@/hooks/store"; +import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; +// plane web components +import { SidebarAppSwitcher } from "@/plane-web/components/sidebar"; + +export interface IAppSidebar {} + +export const AppSidebar: FC = observer(() => { + // store hooks + const { toggleSidebar, sidebarCollapsed } = useAppTheme(); + // refs + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(); + } + } + }); + + return ( +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ ); +}); diff --git a/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx similarity index 66% rename from web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx rename to web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx index 4e04fe7fa..95fb113cd 100644 --- a/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx @@ -1,24 +1,19 @@ -import { ReactElement } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -// layouts -import { PageHead } from "@/components/core"; -import { GlobalIssuesHeader } from "@/components/headers"; -import { AllIssueLayoutRoot } from "@/components/issues"; -import { GlobalViewsHeader } from "@/components/workspace"; -import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; -import { useGlobalView, useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -// hooks -// components -// types -import { NextPageWithLayout } from "@/lib/types"; -// constants +"use client"; -const GlobalViewIssuesPage: NextPageWithLayout = observer(() => { +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { AllIssueLayoutRoot, GlobalViewsAppliedFiltersRoot } from "@/components/issues"; +import { GlobalViewsHeader } from "@/components/workspace"; +// constants +import { DEFAULT_GLOBAL_VIEWS_LIST } from "@/constants/workspace"; +// hooks +import { useGlobalView, useWorkspace } from "@/hooks/store"; + +const GlobalViewIssuesPage = observer(() => { // router - const router = useRouter(); - const { globalViewId } = router.query; + const { globalViewId } = useParams(); // store hooks const { currentWorkspace } = useWorkspace(); const { getViewDetailsById } = useGlobalView(); @@ -38,15 +33,12 @@ const GlobalViewIssuesPage: NextPageWithLayout = observer(() => {
- + {globalViewId && } +
); }); -GlobalViewIssuesPage.getLayout = function getLayout(page: ReactElement) { - return }>{page}; -}; - export default GlobalViewIssuesPage; diff --git a/web/components/headers/global-issues.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx similarity index 67% rename from web/components/headers/global-issues.tsx rename to web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx index 1ec2a5d2c..72e9ed43e 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -1,6 +1,8 @@ +"use client"; + import { useCallback, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui @@ -13,20 +15,20 @@ import { CreateUpdateWorkspaceViewModal } from "@/components/workspace"; import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; +import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks -import { useLabel, useMember, useUser, useIssues } from "@/hooks/store"; +import { useLabel, useMember, useUser, useIssues, useGlobalView } from "@/hooks/store"; -export const GlobalIssuesHeader: React.FC = observer(() => { +export const GlobalIssuesHeader = observer(() => { // states const [createViewModal, setCreateViewModal] = useState(false); // router - const router = useRouter(); - const { workspaceSlug, globalViewId } = router.query; + const { workspaceSlug, globalViewId } = useParams(); // store hooks const { issuesFilter: { filters, updateFilters }, } = useIssues(EIssuesStoreType.GLOBAL); + const { getViewDetailsById } = useGlobalView(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -37,6 +39,8 @@ export const GlobalIssuesHeader: React.FC = observer(() => { const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; + const viewDetails = getViewDetailsById(globalViewId.toString()); + const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !globalViewId) return; @@ -94,7 +98,7 @@ export const GlobalIssuesHeader: React.FC = observer(() => { const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const isLocked = viewDetails?.is_locked; return ( <> @@ -111,26 +115,34 @@ export const GlobalIssuesHeader: React.FC = observer(() => {
- <> - - - - - - - + {!isLocked && ( + <> + + + + + + + + )} {isAuthorizedUser && ( + + Back to sign in + + +
+
+
+
+ + ); +} diff --git a/web/app/accounts/reset-password/layout.tsx b/web/app/accounts/reset-password/layout.tsx new file mode 100644 index 000000000..dbc0a29b4 --- /dev/null +++ b/web/app/accounts/reset-password/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Reset Password - Plane", +}; + +export default function ResetPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/accounts/reset-password/page.tsx b/web/app/accounts/reset-password/page.tsx new file mode 100644 index 000000000..43f001abe --- /dev/null +++ b/web/app/accounts/reset-password/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +// icons +import { useTheme } from "next-themes"; +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Button, Input } from "@plane/ui"; +// components +import { AuthBanner, PasswordStrengthMeter } from "@/components/account"; +// helpers +import { + EAuthenticationErrorCodes, + EErrorAlertType, + EPageTypes, + TAuthErrorInfo, + authErrorHandler, +} from "@/helpers/authentication.helper"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// services +// images +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import { AuthService } from "@/services/auth.service"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +export default function ResetPasswordPage() { + // search params + const searchParams = useSearchParams(); + const uidb64 = searchParams.get("uidb64"); + const token = searchParams.get("token"); + const email = searchParams.get("email"); + const error_code = searchParams.get("error_code"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [resetFormData, setResetFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + const [errorInfo, setErrorInfo] = useState(undefined); + + // hooks + const { resolvedTheme } = useTheme(); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setResetFormData((prev) => ({ ...prev, [key]: value })); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const isButtonDisabled = useMemo( + () => + !!resetFormData.password && + getPasswordStrength(resetFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + resetFormData.password === resetFormData.confirm_password + ? false + : true, + [resetFormData] + ); + + useEffect(() => { + if (error_code) { + const errorhandler = authErrorHandler(error_code?.toString() as EAuthenticationErrorCodes); + if (errorhandler) { + setErrorInfo(errorhandler); + } + } + }, [error_code]); + + const password = resetFormData?.password ?? ""; + const confirmPassword = resetFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+
+
+
+

+ Set new password +

+

Secure your account with a strong password

+
+ {errorInfo && errorInfo?.type === EErrorAlertType.BANNER_ALERT && ( + setErrorInfo(value)} /> + )} +
+ +
+ +
+ +
+
+
+ +
+ handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+ +
+
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
+ {!!resetFormData.confirm_password && + resetFormData.password !== resetFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ +
+
+
+
+
+
+ ); +} diff --git a/web/app/accounts/set-password/layout.tsx b/web/app/accounts/set-password/layout.tsx new file mode 100644 index 000000000..dbd32e9e8 --- /dev/null +++ b/web/app/accounts/set-password/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Set Password - Plane", +}; + +export default function SetPasswordLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/accounts/set-password/page.tsx b/web/app/accounts/set-password/page.tsx new file mode 100644 index 000000000..e74b506e6 --- /dev/null +++ b/web/app/accounts/set-password/page.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +// icons +import { useTheme } from "next-themes"; +import { Eye, EyeOff } from "lucide-react"; +// ui +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { PasswordStrengthMeter } from "@/components/account"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; +// hooks +import { useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// services +// images +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; +import { AuthService } from "@/services/auth.service"; + +type TResetPasswordFormValues = { + email: string; + password: string; + confirm_password?: string; +}; + +const defaultValues: TResetPasswordFormValues = { + email: "", + password: "", +}; + +// services +const authService = new AuthService(); + +const SetPasswordPage = observer(() => { + // router + const router = useAppRouter(); + // search params + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + // states + const [showPassword, setShowPassword] = useState({ + password: false, + retypePassword: false, + }); + const [passwordFormData, setPasswordFormData] = useState({ + ...defaultValues, + email: email ? email.toString() : "", + }); + const [csrfToken, setCsrfToken] = useState(undefined); + const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); + const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); + // hooks + const { resolvedTheme } = useTheme(); + // hooks + const { data: user, handleSetPassword } = useUser(); + + useEffect(() => { + if (csrfToken === undefined) + authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); + }, [csrfToken]); + + const handleShowPassword = (key: keyof typeof showPassword) => + setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); + + const handleFormChange = (key: keyof TResetPasswordFormValues, value: string) => + setPasswordFormData((prev) => ({ ...prev, [key]: value })); + + const isButtonDisabled = useMemo( + () => + !!passwordFormData.password && + getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID && + passwordFormData.password === passwordFormData.confirm_password + ? false + : true, + [passwordFormData] + ); + + const handleSubmit = async (e: FormEvent) => { + try { + e.preventDefault(); + if (!csrfToken) throw new Error("csrf token not found"); + await handleSetPassword(csrfToken, { password: passwordFormData.password }); + router.push("/"); + } catch (err: any) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.error ?? "Something went wrong. Please try again.", + }); + } + }; + + const password = passwordFormData?.password ?? ""; + const confirmPassword = passwordFormData?.confirm_password ?? ""; + const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+
+
+
+

+ Secure your account +

+

Setting password helps you login securely

+
+
handleSubmit(e)}> +
+ +
+ +
+
+
+ +
+ handleFormChange("password", e.target.value)} + //hasError={Boolean(errors.password)} + placeholder="Enter password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + minLength={8} + onFocus={() => setIsPasswordInputFocused(true)} + onBlur={() => setIsPasswordInputFocused(false)} + autoFocus + /> + {showPassword.password ? ( + handleShowPassword("password")} + /> + ) : ( + handleShowPassword("password")} + /> + )} +
+ +
+
+ +
+ handleFormChange("confirm_password", e.target.value)} + placeholder="Confirm password" + className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + onFocus={() => setIsRetryPasswordInputFocused(true)} + onBlur={() => setIsRetryPasswordInputFocused(false)} + /> + {showPassword.retypePassword ? ( + handleShowPassword("retypePassword")} + /> + ) : ( + handleShowPassword("retypePassword")} + /> + )} +
+ {!!passwordFormData.confirm_password && + passwordFormData.password !== passwordFormData.confirm_password && + renderPasswordMatchError && Passwords don{"'"}t match} +
+ +
+
+
+
+
+
+ ); +}); + +export default SetPasswordPage; diff --git a/web/app/create-workspace/layout.tsx b/web/app/create-workspace/layout.tsx new file mode 100644 index 000000000..32a220df7 --- /dev/null +++ b/web/app/create-workspace/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Workspace", +}; + +export default function CreateWorkspaceLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/create-workspace.tsx b/web/app/create-workspace/page.tsx similarity index 78% rename from web/pages/create-workspace.tsx rename to web/app/create-workspace/page.tsx index e5992e008..1a5625ad6 100644 --- a/web/pages/create-workspace.tsx +++ b/web/app/create-workspace/page.tsx @@ -1,28 +1,25 @@ -import { ReactElement, useState } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/router"; import { useTheme } from "next-themes"; import { IWorkspace } from "@plane/types"; -// hooks -import { PageHead } from "@/components/core"; -import { CreateWorkspaceForm } from "@/components/workspace"; -import { useUser, useUserProfile } from "@/hooks/store"; -// layouts -import DefaultLayout from "@/layouts/default-layout"; // components -// images -import { NextPageWithLayout } from "@/lib/types"; +import { CreateWorkspaceForm } from "@/components/workspace"; +// hooks +import { useUser, useUserProfile } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; -// types +// images +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; -const CreateWorkspacePage: NextPageWithLayout = observer(() => { +const CreateWorkspacePage = observer(() => { // router - const router = useRouter(); + const router = useAppRouter(); // store hooks const { data: currentUser } = useUser(); const { updateUserProfile } = useUserProfile(); @@ -42,8 +39,7 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => { const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; return ( - <> - +
@@ -72,16 +68,8 @@ const CreateWorkspacePage: NextPageWithLayout = observer(() => {
- +
); }); -CreateWorkspacePage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ); -}; - export default CreateWorkspacePage; diff --git a/web/app/error.tsx b/web/app/error.tsx new file mode 100644 index 000000000..da5d3bf20 --- /dev/null +++ b/web/app/error.tsx @@ -0,0 +1,75 @@ +"use client"; + +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useAppRouter } from "@/hooks/use-app-router"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// services +import { AuthService } from "@/services/auth.service"; + +// services +const authService = new AuthService(); + +export default function CustomErrorComponent() { + const router = useAppRouter(); + + const handleRefresh = () => { + window.location.reload(); + }; + + const handleSignOut = async () => { + await authService + .signOut(API_BASE_URL) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ) + .finally(() => router.push("/")); + }; + + return ( + +
+ + ); +} diff --git a/web/app/global-error.tsx b/web/app/global-error.tsx new file mode 100644 index 000000000..23ab664d8 --- /dev/null +++ b/web/app/global-error.tsx @@ -0,0 +1,24 @@ +"use client"; + +// import { useEffect } from "react"; +// import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; + +// export default function GlobalError({ error }: { error: Error & { digest?: string } }) { +export default function GlobalError() { + // useEffect(() => { + // Sentry.captureException(error); + // }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/web/app/installations/[provider]/layout.tsx b/web/app/installations/[provider]/layout.tsx new file mode 100644 index 000000000..51978de9e --- /dev/null +++ b/web/app/installations/[provider]/layout.tsx @@ -0,0 +1,3 @@ +export default function InstallationProviderLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/installations/[provider]/index.tsx b/web/app/installations/[provider]/page.tsx similarity index 80% rename from web/pages/installations/[provider]/index.tsx rename to web/app/installations/[provider]/page.tsx index eb2e850c7..218d7dee2 100644 --- a/web/pages/installations/[provider]/index.tsx +++ b/web/app/installations/[provider]/page.tsx @@ -1,18 +1,23 @@ -import React, { useEffect, ReactElement } from "react"; -import { useRouter } from "next/router"; +"use client"; + +import React, { useEffect } from "react"; +import { useParams, useSearchParams } from "next/navigation"; // ui import { LogoSpinner } from "@/components/common"; -// types -import { NextPageWithLayout } from "@/lib/types"; // services import { AppInstallationService } from "@/services/app_installation.service"; // services const appInstallationService = new AppInstallationService(); -const AppPostInstallation: NextPageWithLayout = () => { - const router = useRouter(); - const { installation_id, state, provider, code } = router.query; +export default function AppPostInstallation() { + // params + const { provider } = useParams(); + // query params + const searchParams = useSearchParams(); + const installation_id = searchParams.get("installation_id"); + const state = searchParams.get("state"); + const code = searchParams.get("code"); useEffect(() => { if (provider === "github" && state && installation_id) { @@ -67,10 +72,4 @@ const AppPostInstallation: NextPageWithLayout = () => {
); -}; - -AppPostInstallation.getLayout = function getLayout(page: ReactElement) { - return
{page}
; -}; - -export default AppPostInstallation; +} diff --git a/web/app/invitations/layout.tsx b/web/app/invitations/layout.tsx new file mode 100644 index 000000000..2d9a7e688 --- /dev/null +++ b/web/app/invitations/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Invitations", +}; + +export default function InvitationsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/invitations/index.tsx b/web/app/invitations/page.tsx similarity index 91% rename from web/pages/invitations/index.tsx rename to web/app/invitations/page.tsx index aac6c833b..a56ff6ad4 100644 --- a/web/pages/invitations/index.tsx +++ b/web/app/invitations/page.tsx @@ -1,8 +1,10 @@ -import React, { useState, ReactElement } from "react"; -import { observer } from "mobx-react-lite"; +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/router"; + import { useTheme } from "next-themes"; import useSWR, { mutate } from "swr"; // icons @@ -13,7 +15,6 @@ import type { IWorkspaceMemberInvitation } from "@plane/types"; import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components import { EmptyState } from "@/components/common"; -import { PageHead } from "@/components/core"; // constants import { MEMBER_ACCEPTED } from "@/constants/event-tracker"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; @@ -21,27 +22,25 @@ import { ROLE } from "@/constants/workspace"; // helpers import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; +// hooks import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/store"; -import DefaultLayout from "@/layouts/default-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; -// wrappers -import { AuthenticationWrapper } from "@/lib/wrappers"; +import { useAppRouter } from "@/hooks/use-app-router"; // services -import { WorkspaceService } from "@/services/workspace.service"; +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceService } from "@/plane-web/services"; // images -import emptyInvitation from "public/empty-state/invitation.svg"; -import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png"; -import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png"; +import emptyInvitation from "@/public/empty-state/invitation.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; const workspaceService = new WorkspaceService(); -const UserInvitationsPage: NextPageWithLayout = observer(() => { +const UserInvitationsPage = observer(() => { // states const [invitationsRespond, setInvitationsRespond] = useState([]); const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); // router - const router = useRouter(); + const router = useAppRouter(); // store hooks const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { data: currentUser } = useUser(); @@ -130,8 +129,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; return ( - <> - +
@@ -230,16 +228,8 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { ) ) : null}
- + ); }); -UserInvitationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ); -}; - export default UserInvitationsPage; diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 000000000..231f4da2f --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,60 @@ +import { Metadata } from "next"; +import Script from "next/script"; +// styles +import "@/styles/globals.css"; +import "@/styles/command-pallette.css"; +import "@/styles/emoji.css"; +import "@/styles/react-day-picker.css"; +// local +import { AppProvider } from "./provider"; + +export const metadata: Metadata = { + title: "Plane | Simple, extensible, open-source project management tool.", + description: + "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", + openGraph: { + title: "Plane | Simple, extensible, open-source project management tool.", + description: "Plane Deploy is a customer feedback management tool built on top of plane.so", + url: "https://app.plane.so/", + }, + keywords: + "software development, plan, ship, software, accelerate, code management, release management, project management, issue tracking, agile, scrum, kanban, collaboration", + twitter: { + site: "@planepowers", + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); + + return ( + + + + + + + + + + +
+ +
{children}
+
+ + {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( + + )} + + ); +} diff --git a/web/pages/404.tsx b/web/app/not-found.tsx similarity index 75% rename from web/pages/404.tsx rename to web/app/not-found.tsx index 514169dea..ecc01b500 100644 --- a/web/pages/404.tsx +++ b/web/app/not-found.tsx @@ -1,21 +1,20 @@ -import React from "react"; +"use client"; -import type { NextPage } from "next"; +import React from "react"; +import { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; -// components -import { Button } from "@plane/ui"; -import { PageHead } from "@/components/core"; -// layouts -import DefaultLayout from "@/layouts/default-layout"; // ui +import { Button } from "@plane/ui"; // images -import Image404 from "public/404.svg"; -// types +import Image404 from "@/public/404.svg"; -const PageNotFound: NextPage = () => ( - - +export const metadata: Metadata = { + title: "404 - Page Not Found", +}; + +const PageNotFound = () => ( +
@@ -37,7 +36,7 @@ const PageNotFound: NextPage = () => (
- +
); export default PageNotFound; diff --git a/web/app/onboarding/layout.tsx b/web/app/onboarding/layout.tsx new file mode 100644 index 000000000..492ebc402 --- /dev/null +++ b/web/app/onboarding/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Onboarding", +}; + +export default function OnboardingLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/pages/onboarding/index.tsx b/web/app/onboarding/page.tsx similarity index 65% rename from web/pages/onboarding/index.tsx rename to web/app/onboarding/page.tsx index 89263e57d..b8cd881ad 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/app/onboarding/page.tsx @@ -1,12 +1,14 @@ -import { ReactElement, useEffect, useState } from "react"; +"use client"; + +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; import useSWR from "swr"; // types import { TOnboardingSteps, TUserProfile } from "@plane/types"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; -import { PageHead } from "@/components/core"; import { InviteMembers, CreateOrJoinWorkspaces, ProfileSetup } from "@/components/onboarding"; // constants import { USER_ONBOARDING_COMPLETED } from "@/constants/event-tracker"; @@ -15,16 +17,12 @@ import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; import { EPageTypes } from "@/helpers/authentication.helper"; // hooks import { useUser, useWorkspace, useUserProfile, useEventTracker } from "@/hooks/store"; -// layouts -import DefaultLayout from "@/layouts/default-layout"; -// lib types -import { NextPageWithLayout } from "@/lib/types"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceService } from "@/plane-web/services"; // services -import { WorkspaceService } from "@/services/workspace.service"; -export enum EOnboardingSteps { +enum EOnboardingSteps { PROFILE_SETUP = "PROFILE_SETUP", WORKSPACE_CREATE_OR_JOIN = "WORKSPACE_CREATE_OR_JOIN", INVITE_MEMBERS = "INVITE_MEMBERS", @@ -32,28 +30,26 @@ export enum EOnboardingSteps { const workspaceService = new WorkspaceService(); -const OnboardingPage: NextPageWithLayout = observer(() => { +const OnboardingPage = observer(() => { // states const [step, setStep] = useState(null); const [totalSteps, setTotalSteps] = useState(null); - // router - const router = useRouter(); // store hooks const { captureEvent } = useEventTracker(); - const { data: user, updateCurrentUser } = useUser(); - const { data: profile, updateUserOnBoard, updateUserProfile } = useUserProfile(); + const { isLoading: userLoader, data: user, updateCurrentUser } = useUser(); + const { data: profile, updateUserProfile, finishUserOnboarding } = useUserProfile(); const { workspaces, fetchWorkspaces } = useWorkspace(); // computed values const workspacesList = Object.values(workspaces ?? {}); // fetching workspaces list - useSWR(USER_WORKSPACES_LIST, () => fetchWorkspaces(), { - shouldRetryOnError: false, + const { isLoading: workspaceListLoader } = useSWR(USER_WORKSPACES_LIST, () => { + user?.id && fetchWorkspaces(); }); // fetching user workspace invitations - const { data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS_LIST", () => - workspaceService.userWorkspaceInvitations() - ); + const { isLoading: invitationsLoader, data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS_LIST", () => { + if (user?.id) return workspaceService.userWorkspaceInvitations(); + }); // handle step change const stepChange = async (steps: Partial) => { if (!user) return; @@ -70,22 +66,9 @@ const OnboardingPage: NextPageWithLayout = observer(() => { // complete onboarding const finishOnboarding = async () => { - if (!user || !workspaces) return; + if (!user) return; - const firstWorkspace = Object.values(workspaces ?? {})?.[0]; - - await Promise.all([ - updateUserProfile({ - onboarding_step: { - profile_complete: true, - workspace_join: true, - workspace_create: true, - workspace_invite: true, - }, - last_workspace_id: firstWorkspace?.id, - }), - updateUserOnBoard(), - ]) + await finishUserOnboarding() .then(() => { captureEvent(USER_ONBOARDING_COMPLETED, { // user_role: user.role, @@ -95,25 +78,30 @@ const OnboardingPage: NextPageWithLayout = observer(() => { }); }) .catch(() => { - console.log("Failed to update onboarding status"); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Failed", + message: "Failed to finish onboarding, Please try again later.", + }); }); - - router.replace(`/${firstWorkspace?.slug}`); }; useEffect(() => { - // If user is already invited to a workspace, only show profile setup steps. - if (workspacesList && workspacesList?.length > 0) { - // If password is auto set then show two different steps for profile setup, else merge them. - if (user?.is_password_autoset) setTotalSteps(2); - else setTotalSteps(1); - } else { - // If password is auto set then total steps will increase to 4 due to extra step at profile setup stage. - if (user?.is_password_autoset) setTotalSteps(4); - else setTotalSteps(3); + // Never update the total steps if it's already set. + if (!totalSteps && userLoader === false && workspaceListLoader === false) { + // If user is already invited to a workspace, only show profile setup steps. + if (workspacesList && workspacesList?.length > 0) { + // If password is auto set then show two different steps for profile setup, else merge them. + if (user?.is_password_autoset) setTotalSteps(2); + else setTotalSteps(1); + } else { + // If password is auto set then total steps will increase to 4 due to extra step at profile setup stage. + if (user?.is_password_autoset) setTotalSteps(4); + else setTotalSteps(3); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [userLoader, workspaceListLoader]); useEffect(() => { const handleStepChange = async () => { @@ -143,9 +131,8 @@ const OnboardingPage: NextPageWithLayout = observer(() => { }, [user, step, profile.onboarding_step, updateCurrentUser, workspacesList]); return ( - <> - - {user && totalSteps && step !== null && invitations ? ( + + {user && totalSteps && step !== null && !invitationsLoader ? (
{step === EOnboardingSteps.PROFILE_SETUP ? ( { /> ) : step === EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN ? ( {
)} - +
); }); -OnboardingPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ); -}; - export default OnboardingPage; diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 000000000..4982f4082 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { useTheme } from "next-themes"; +// components +import { AuthRoot } from "@/components/account"; +import { PageHead } from "@/components/core"; +// constants +import { NAVIGATE_TO_SIGNUP } from "@/constants/event-tracker"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useEventTracker } from "@/hooks/store"; +// layouts +import DefaultLayout from "@/layouts/default-layout"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +// assets +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +const HomePage = observer(() => { + const { resolvedTheme } = useTheme(); + // hooks + const { captureEvent } = useEventTracker(); + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + + + <> +
+ +
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+ New to Plane?{" "} + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="font-semibold text-custom-primary-100 hover:underline" + > + Create an account + +
+
+
+ +
+
+
+ +
+
+ ); +}); + +export default HomePage; diff --git a/web/app/profile/activity/page.tsx b/web/app/profile/activity/page.tsx new file mode 100644 index 000000000..afc9b29bf --- /dev/null +++ b/web/app/profile/activity/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { Button } from "@plane/ui"; +// components +import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; +import { + ProfileActivityListPage, + ProfileSettingContentHeader, + ProfileSettingContentWrapper, +} from "@/components/profile"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; + +const PER_PAGE = 100; + +const ProfileActivityPage = observer(() => { + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isEmpty, setIsEmpty] = useState(false); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const activityPages: JSX.Element[] = []; + for (let i = 0; i < pageCount; i++) + activityPages.push( + + ); + + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + + if (isEmpty) { + return ; + } + + return ( + <> + + + + {activityPages} + {isLoadMoreVisible && ( +
+ +
+ )} +
+ + ); +}); + +export default ProfileActivityPage; diff --git a/web/pages/profile/preferences/theme.tsx b/web/app/profile/appearance/page.tsx similarity index 64% rename from web/pages/profile/preferences/theme.tsx rename to web/app/profile/appearance/page.tsx index f35fe72b8..ef19a3342 100644 --- a/web/pages/profile/preferences/theme.tsx +++ b/web/app/profile/appearance/page.tsx @@ -1,21 +1,22 @@ -import { useEffect, useState, ReactElement } from "react"; +"use client"; + +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; -// ui +import { IUserTheme } from "@plane/types"; import { setPromiseToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core"; +import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; // constants import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes"; +// helpers +import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks import { useUserProfile } from "@/hooks/store"; -// layouts -import { ProfilePreferenceSettingsLayout } from "@/layouts/settings-layout/profile/preferences"; -// type -import { NextPageWithLayout } from "@/lib/types"; -const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { +const ProfileAppearancePage = observer(() => { const { setTheme } = useTheme(); // states const [currentTheme, setCurrentTheme] = useState(null); @@ -32,9 +33,9 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { }, [userProfile?.theme?.theme]); const handleThemeChange = (themeOption: I_THEME_OPTION) => { - setTheme(themeOption.value); - const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); + applyThemeChange({ theme: themeOption.value }); + const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value }); setPromiseToast(updateCurrentUserThemePromise, { loading: "Updating theme...", success: { @@ -48,14 +49,25 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { }); }; + const applyThemeChange = (theme: Partial) => { + setTheme(theme?.theme || "system"); + + const customThemeElement = window.document?.querySelector("[data-theme='custom']"); + if (theme?.theme === "custom" && theme?.palette && customThemeElement) { + applyTheme( + theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", + false, + customThemeElement + ); + } else unsetCustomCssVariables(); + }; + return ( <> - + {userProfile ? ( -
-
-

Preferences

-
+ +

Theme

@@ -65,8 +77,8 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => {
- {userProfile?.theme?.theme === "custom" && } -
+ {userProfile?.theme?.theme === "custom" && } + ) : (
@@ -76,8 +88,4 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { ); }); -ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default ProfilePreferencesThemePage; +export default ProfileAppearancePage; diff --git a/web/layouts/settings-layout/profile/layout.tsx b/web/app/profile/layout.tsx similarity index 67% rename from web/layouts/settings-layout/profile/layout.tsx rename to web/app/profile/layout.tsx index e2d11155f..1f1b1dff4 100644 --- a/web/layouts/settings-layout/profile/layout.tsx +++ b/web/app/profile/layout.tsx @@ -1,18 +1,19 @@ -import { FC, ReactNode } from "react"; -// layout +"use client"; + +import { ReactNode } from "react"; +// components import { CommandPalette } from "@/components/command-palette"; -import { ProfileLayoutSidebar } from "@/layouts/settings-layout"; // wrappers import { AuthenticationWrapper } from "@/lib/wrappers"; -// components +// layout +import { ProfileLayoutSidebar } from "./sidebar"; -interface IProfileSettingsLayout { +type Props = { children: ReactNode; - header?: ReactNode; -} +}; -export const ProfileSettingsLayout: FC = (props) => { - const { children, header } = props; +export default function ProfileSettingsLayout(props: Props) { + const { children } = props; return ( <> @@ -21,11 +22,10 @@ export const ProfileSettingsLayout: FC = (props) => {
- {header}
{children}
); -}; +} diff --git a/web/app/profile/notifications/page.tsx b/web/app/profile/notifications/page.tsx new file mode 100644 index 000000000..b39563378 --- /dev/null +++ b/web/app/profile/notifications/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import useSWR from "swr"; +// components +import { PageHead } from "@/components/core"; +import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; +import { EmailNotificationForm } from "@/components/profile/notification"; +import { EmailSettingsLoader } from "@/components/ui"; +// services +import { UserService } from "@/services/user.service"; + +const userService = new UserService(); + +export default function ProfileNotificationPage() { + // fetching user email notification settings + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + userService.currentUserEmailNotificationSettings() + ); + + if (!data || isLoading) { + return ; + } + + return ( + <> + + + + + + + ); +} diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx new file mode 100644 index 000000000..b2ca0b128 --- /dev/null +++ b/web/app/profile/page.tsx @@ -0,0 +1,451 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +import { ChevronDown, CircleUserRound } from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +// services +// hooks +// layouts +// components +import type { IUser } from "@plane/types"; +import { Button, CustomSelect, CustomSearchSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; +import { DeactivateAccountModal } from "@/components/account"; +import { LogoSpinner } from "@/components/common"; +import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core"; +// ui +// icons +// components +// constants +import { ProfileSettingContentWrapper } from "@/components/profile"; +import { TIME_ZONES } from "@/constants/timezones"; +import { USER_ROLES } from "@/constants/workspace"; +// hooks +import { useUser } from "@/hooks/store"; +// import { ProfileSettingsLayout } from "@/layouts/settings-layout"; +// layouts +import { FileService } from "@/services/file.service"; +// services +// types + +const defaultValues: Partial = { + avatar: "", + cover_image: "", + first_name: "", + last_name: "", + display_name: "", + email: "", + role: "Product / Project Manager", + user_timezone: "Asia/Kolkata", +}; + +const fileService = new FileService(); + +const ProfileSettingsPage = observer(() => { + // states + const [isLoading, setIsLoading] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); + const [deactivateAccountModal, setDeactivateAccountModal] = useState(false); + // form info + const { + handleSubmit, + reset, + watch, + control, + setValue, + formState: { errors }, + } = useForm({ defaultValues }); + // store hooks + const { data: currentUser, updateCurrentUser } = useUser(); + + useEffect(() => { + reset({ ...defaultValues, ...currentUser }); + }, [currentUser, reset]); + + const onSubmit = async (formData: IUser) => { + setIsLoading(true); + const payload: Partial = { + first_name: formData.first_name, + last_name: formData.last_name, + avatar: formData.avatar, + cover_image: formData.cover_image, + role: formData.role, + display_name: formData?.display_name, + user_timezone: formData.user_timezone, + }; + + const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false)); + setPromiseToast(updateCurrentUserDetail, { + loading: "Updating...", + success: { + title: "Success!", + message: () => `Profile updated successfully.`, + }, + error: { + title: "Error!", + message: () => `There was some error in updating your profile. Please try again.`, + }, + }); + }; + + const handleDelete = (url: string | null | undefined, updateUser: boolean = false) => { + if (!url) return; + + setIsRemoving(true); + + fileService.deleteUserFile(url).then(() => { + if (updateUser) + updateCurrentUser({ avatar: "" }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Profile picture deleted successfully.", + }); + setIsRemoving(false); + setValue("avatar", ""); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "There was some error in deleting your profile picture. Please try again.", + }); + }) + .finally(() => { + setIsRemoving(false); + setIsImageUploadModalOpen(false); + }); + }); + }; + + const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ + value: timeZone.value, + query: timeZone.label + " " + timeZone.value, + content: timeZone.label, + })); + + if (!currentUser) + return ( +
+ +
+ ); + + return ( + <> + + + ( + setIsImageUploadModalOpen(false)} + isRemoving={isRemoving} + handleDelete={() => handleDelete(currentUser?.avatar, true)} + onSuccess={(url) => { + onChange(url); + handleSubmit(onSubmit)(); + setIsImageUploadModalOpen(false); + }} + value={value && value.trim() !== "" ? value : null} + /> + )} + /> + setDeactivateAccountModal(false)} /> +
+
+
+ {currentUser?.first_name +
+
+
+ +
+
+
+ +
+ ( + onChange(imageUrl)} + control={control} + value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} + isProfileCover + /> + )} + /> +
+
+ +
+
+
+ {`${watch("first_name")} ${watch("last_name")}`} +
+ {watch("email")} +
+ + {/* + + + Activity Overview + + */} +
+ +
+
+

+ First name* +

+ ( + + )} + /> + {errors.first_name && Please enter first name} +
+ +
+

Last name

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

+ Email* +

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

+ 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 && Please enter display name} +
+ +
+

+ Timezone* +

+ + ( + t.value === value)?.label ?? value : "Select a timezone"} + options={timeZoneOptions} + onChange={onChange} + optionsClassName="w-full" + 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} +
+ +
+ +
+
+
+
+ + {({ open }) => ( + <> + + Deactivate account + + + + +
+ + The danger zone of the profile page is a critical area that requires careful consideration and + attention. When deactivating an account, all of the data and resources within that account will be + permanently removed and cannot be recovered. + +
+ +
+
+
+
+ + )} +
+
+ + ); +}); + +// ProfileSettingsPage.getLayout = function getLayout(page: ReactElement) { +// return {page}; +// }; + +export default ProfileSettingsPage; diff --git a/web/pages/profile/change-password.tsx b/web/app/profile/security/page.tsx similarity index 79% rename from web/pages/profile/change-password.tsx rename to web/app/profile/security/page.tsx index 2ddc02090..594816cc1 100644 --- a/web/pages/profile/change-password.tsx +++ b/web/app/profile/security/page.tsx @@ -1,24 +1,18 @@ -import { ReactElement, useEffect, useState } from "react"; +"use client"; + +import { useState } from "react"; import { observer } from "mobx-react"; -import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import { Eye, EyeOff } from "lucide-react"; // ui import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // components import { PasswordStrengthMeter } from "@/components/account"; -import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; -import { SidebarHamburgerToggle } from "@/components/core/sidebar"; +import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile"; // helpers import { authErrorHandler } from "@/helpers/authentication.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; -// hooks -import { useAppTheme, useUser } from "@/hooks/store"; -// layout -import { ProfileSettingsLayout } from "@/layouts/settings-layout"; -// types -import { NextPageWithLayout } from "@/lib/types"; +import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper"; // services import { AuthService } from "@/services/auth.service"; import { UserService } from "@/services/user.service"; @@ -35,8 +29,8 @@ const defaultValues: FormValues = { confirm_password: "", }; -export const userService = new UserService(); -export const authService = new AuthService(); +const userService = new UserService(); +const authService = new AuthService(); const defaultShowPassword = { oldPassword: false, @@ -44,17 +38,11 @@ const defaultShowPassword = { confirmPassword: false, }; -const ChangePasswordPage: NextPageWithLayout = observer(() => { +const SecurityPage = observer(() => { // states - const [isPageLoading, setIsPageLoading] = useState(true); const [showPassword, setShowPassword] = useState(defaultShowPassword); const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); - // router - const router = useRouter(); - // store hooks - const { toggleSidebar } = useAppTheme(); - const { data: currentUser } = useUser(); // use form const { control, @@ -102,47 +90,27 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { } }; - // if the user doesn't have a password set, redirect to the profile page - useEffect(() => { - if (!currentUser) return; - - if (currentUser.is_password_autoset) router.push("/profile"); - else setIsPageLoading(false); - }, [currentUser, router]); - const isButtonDisabled = - getPasswordStrength(password) < 3 || + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID || oldPassword.trim() === "" || password.trim() === "" || confirmPassword.trim() === "" || password !== confirmPassword || password === oldPassword; - const passwordSupport = password.length > 0 && (getPasswordStrength(password) < 3 || isPasswordInputFocused) && ( - - ); - - if (isPageLoading) - return ( -
- -
+ const passwordSupport = password.length > 0 && + getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && ( + ); const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; return ( <> - -
-
- toggleSidebar()} /> -
-
-

Change password

+ + + +

Current password

@@ -266,13 +234,9 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => {
-
+
); }); -ChangePasswordPage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default ChangePasswordPage; +export default SecurityPage; diff --git a/web/layouts/settings-layout/profile/sidebar.tsx b/web/app/profile/sidebar.tsx similarity index 96% rename from web/layouts/settings-layout/profile/sidebar.tsx rename to web/app/profile/sidebar.tsx index 9e7fc95cc..8d4cebbde 100644 --- a/web/layouts/settings-layout/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -1,7 +1,9 @@ +"use client"; + import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { usePathname } from "next/navigation"; // icons import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; // ui @@ -32,7 +34,7 @@ export const ProfileLayoutSidebar = observer(() => { // states const [isSigningOut, setIsSigningOut] = useState(false); // router - const router = useRouter(); + const pathname = usePathname(); // store hooks const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { data: currentUser, signOut } = useUser(); @@ -93,10 +95,9 @@ export const ProfileLayoutSidebar = observer(() => { return (
@@ -133,7 +134,7 @@ export const ProfileLayoutSidebar = observer(() => { >
import("@/lib/wrappers/store-wrapper"), { ssr: false }); +const PostHogProvider = dynamic(() => import("@/lib/posthog-provider"), { ssr: false }); +const CrispWrapper = dynamic(() => import("@/lib/wrappers/crisp-wrapper"), { ssr: false }); + +export interface IAppProvider { + children: ReactNode; +} + +const ToastWithTheme = () => { + const { resolvedTheme } = useTheme(); + return ; +}; + +export const AppProvider: FC = (props) => { + const { children } = props; + // themes + return ( + <> + + + + + + + + + {children} + + + + + + + + ); +}; diff --git a/web/app/sign-up/layout.tsx b/web/app/sign-up/layout.tsx new file mode 100644 index 000000000..f7f405c27 --- /dev/null +++ b/web/app/sign-up/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Sign up - Plane", +}; + +export default function SignUpLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/sign-up/page.tsx b/web/app/sign-up/page.tsx new file mode 100644 index 000000000..8bd2e8799 --- /dev/null +++ b/web/app/sign-up/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +import Link from "next/link"; +// ui +import { useTheme } from "next-themes"; +// components +import { AuthRoot } from "@/components/account"; +// constants +import { NAVIGATE_TO_SIGNIN } from "@/constants/event-tracker"; +// helpers +import { EAuthModes, EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useEventTracker } from "@/hooks/store"; +// assets +import { AuthenticationWrapper } from "@/lib/wrappers"; +import PlaneBackgroundPatternDark from "@/public/auth/background-pattern-dark.svg"; +import PlaneBackgroundPattern from "@/public/auth/background-pattern.svg"; +import BlackHorizontalLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; +import WhiteHorizontalLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png"; + +export type AuthType = "sign-in" | "sign-up"; + +const SignInPage = observer(() => { + // store hooks + const { captureEvent } = useEventTracker(); + // hooks + const { resolvedTheme } = useTheme(); + + const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; + + return ( + +
+
+ Plane background pattern +
+
+
+
+ + Plane logo + +
+
+ Already have an account?{" "} + captureEvent(NAVIGATE_TO_SIGNIN, {})} + className="font-semibold text-custom-primary-100 hover:underline" + > + Log in + +
+
+
+ +
+
+
+
+ ); +}); + +export default SignInPage; diff --git a/web/app/workspace-invitations/layout.tsx b/web/app/workspace-invitations/layout.tsx new file mode 100644 index 000000000..8361dddfa --- /dev/null +++ b/web/app/workspace-invitations/layout.tsx @@ -0,0 +1,9 @@ +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Workspace Invitations", +}; + +export default function WorkspaceInvitationsLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/app/workspace-invitations/page.tsx b/web/app/workspace-invitations/page.tsx new file mode 100644 index 000000000..a68290198 --- /dev/null +++ b/web/app/workspace-invitations/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; +import { Boxes, Check, Share2, Star, User2, X } from "lucide-react"; +// components +import { LogoSpinner } from "@/components/common"; +import { EmptySpace, EmptySpaceItem } from "@/components/ui/empty-space"; +// constants +import { WORKSPACE_INVITATION } from "@/constants/fetch-keys"; +// helpers +import { EPageTypes } from "@/helpers/authentication.helper"; +// hooks +import { useUser } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +// wrappers +import { AuthenticationWrapper } from "@/lib/wrappers"; +import { WorkspaceService } from "@/plane-web/services"; +// services + +// service initialization +const workspaceService = new WorkspaceService(); + +const WorkspaceInvitationPage = observer(() => { + // router + const router = useAppRouter(); + // query params + const searchParams = useSearchParams(); + const invitation_id = searchParams.get("invitation_id"); + const email = searchParams.get("email"); + const slug = searchParams.get("slug"); + // store hooks + const { data: currentUser } = useUser(); + + const { data: invitationDetail, error } = useSWR( + invitation_id && slug && WORKSPACE_INVITATION(invitation_id.toString()), + invitation_id && slug + ? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString()) + : null + ); + + const handleAccept = () => { + if (!invitationDetail) return; + workspaceService + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + accepted: true, + email: invitationDetail.email, + }) + .then(() => { + if (email === currentUser?.email) { + router.push("/invitations"); + } else { + router.push(`/?${searchParams.toString()}`); + } + }) + .catch((err) => console.error(err)); + }; + + const handleReject = () => { + if (!invitationDetail) return; + workspaceService + .joinWorkspace(invitationDetail.workspace.slug, invitationDetail.id, { + accepted: false, + email: invitationDetail.email, + }) + .then(() => { + router.push("/"); + }) + .catch((err) => console.error(err)); + }; + + return ( + +
+ {invitationDetail && !invitationDetail.responded_at ? ( + error ? ( +
+

INVITATION NOT FOUND

+
+ ) : ( + + + + + ) + ) : error || invitationDetail?.responded_at ? ( + invitationDetail?.accepted ? ( + + + + ) : ( + + {!currentUser ? ( + + ) : ( + + )} + + + + ) + ) : ( +
+ +
+ )} +
+
+ ); +}); + +export default WorkspaceInvitationPage; diff --git a/web/ce/components/active-cycles/index.ts b/web/ce/components/active-cycles/index.ts new file mode 100644 index 000000000..87c131821 --- /dev/null +++ b/web/ce/components/active-cycles/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./workspace-active-cycles-upgrade"; diff --git a/web/ce/components/active-cycles/root.tsx b/web/ce/components/active-cycles/root.tsx new file mode 100644 index 000000000..3c073890f --- /dev/null +++ b/web/ce/components/active-cycles/root.tsx @@ -0,0 +1,3 @@ +import { WorkspaceActiveCyclesUpgrade } from "@/plane-web/components/active-cycles"; + +export const WorkspaceActiveCyclesRoot = () => ; diff --git a/web/components/workspace/workspace-active-cycles-upgrade.tsx b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx similarity index 99% rename from web/components/workspace/workspace-active-cycles-upgrade.tsx rename to web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx index 8071a2394..3049c013a 100644 --- a/web/components/workspace/workspace-active-cycles-upgrade.tsx +++ b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; diff --git a/web/ce/components/estimates/estimate-list-item-buttons.tsx b/web/ce/components/estimates/estimate-list-item-buttons.tsx new file mode 100644 index 000000000..6911754f8 --- /dev/null +++ b/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -0,0 +1,42 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Crown, Pen, Trash } from "lucide-react"; +import { Tooltip } from "@plane/ui"; + +type TEstimateListItem = { + estimateId: string; + isAdmin: boolean; + isEstimateEnabled: boolean; + isEditable: boolean; + onEditClick?: (estimateId: string) => void; + onDeleteClick?: (estimateId: string) => void; +}; + +export const EstimateListItemButtons: FC = observer((props) => { + const { estimateId, isAdmin, isEditable, onDeleteClick } = props; + + if (!isAdmin || !isEditable) return <>; + return ( +
+ +
Upgrade
+ +
+ } + position="top" + > + + + +
+ ); +}); diff --git a/web/ce/components/estimates/index.ts b/web/ce/components/estimates/index.ts new file mode 100644 index 000000000..918b72d8d --- /dev/null +++ b/web/ce/components/estimates/index.ts @@ -0,0 +1,3 @@ +export * from "./estimate-list-item-buttons"; +export * from "./update"; +export * from "./points"; diff --git a/web/ce/components/estimates/points/delete.tsx b/web/ce/components/estimates/points/delete.tsx new file mode 100644 index 000000000..08dd605e4 --- /dev/null +++ b/web/ce/components/estimates/points/delete.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { FC } from "react"; + +import { TEstimateTypeErrorObject } from "@plane/types"; + +type TEstimatePointDelete = { + workspaceSlug: string; + projectId: string; + estimateId: string; + estimatePointId: string; + callback: () => void; + estimatePointError?: TEstimateTypeErrorObject | undefined; + handleEstimatePointError?: (newValue: string, message: string | undefined, mode?: "add" | "delete") => void; +}; + +export const EstimatePointDelete: FC = () => <>; diff --git a/web/ce/components/estimates/points/index.ts b/web/ce/components/estimates/points/index.ts new file mode 100644 index 000000000..fe722bd23 --- /dev/null +++ b/web/ce/components/estimates/points/index.ts @@ -0,0 +1 @@ +export * from "./delete"; diff --git a/web/ce/components/estimates/update/index.ts b/web/ce/components/estimates/update/index.ts new file mode 100644 index 000000000..031608e25 --- /dev/null +++ b/web/ce/components/estimates/update/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ce/components/estimates/update/modal.tsx b/web/ce/components/estimates/update/modal.tsx new file mode 100644 index 000000000..12b4ea6f6 --- /dev/null +++ b/web/ce/components/estimates/update/modal.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; + +type TUpdateEstimateModal = { + workspaceSlug: string; + projectId: string; + estimateId: string | undefined; + isOpen: boolean; + handleClose: () => void; +}; + +export const UpdateEstimateModal: FC = observer(() => <>); diff --git a/web/ce/components/global/index.ts b/web/ce/components/global/index.ts new file mode 100644 index 000000000..08b85c764 --- /dev/null +++ b/web/ce/components/global/index.ts @@ -0,0 +1 @@ +export * from "./version-number"; diff --git a/web/ce/components/global/version-number.tsx b/web/ce/components/global/version-number.tsx new file mode 100644 index 000000000..47ff380d3 --- /dev/null +++ b/web/ce/components/global/version-number.tsx @@ -0,0 +1,4 @@ +// assets +import packageJson from "package.json"; + +export const PlaneVersionNumber: React.FC = () => Version: v{packageJson.version}; diff --git a/web/ce/components/issues/bulk-operations/index.ts b/web/ce/components/issues/bulk-operations/index.ts new file mode 100644 index 000000000..50a9c47c0 --- /dev/null +++ b/web/ce/components/issues/bulk-operations/index.ts @@ -0,0 +1 @@ +export * from "./root"; \ No newline at end of file diff --git a/web/ce/components/issues/bulk-operations/root.tsx b/web/ce/components/issues/bulk-operations/root.tsx new file mode 100644 index 000000000..741a341be --- /dev/null +++ b/web/ce/components/issues/bulk-operations/root.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react"; +// components +import { BulkOperationsUpgradeBanner } from "@/components/issues"; +// hooks +import { useMultipleSelectStore } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; + +type Props = { + className?: string; + selectionHelpers: TSelectionHelper; +}; + +export const IssueBulkOperationsRoot: React.FC = observer((props) => { + const { className, selectionHelpers } = props; + // store hooks + const { isSelectionActive } = useMultipleSelectStore(); + + if (!isSelectionActive || selectionHelpers.isSelectionDisabled) return null; + + return ; +}); diff --git a/web/ce/components/issues/index.ts b/web/ce/components/issues/index.ts new file mode 100644 index 000000000..7a5275abe --- /dev/null +++ b/web/ce/components/issues/index.ts @@ -0,0 +1 @@ +export * from "./bulk-operations"; \ No newline at end of file diff --git a/web/ce/components/pages/editor/embed/index.ts b/web/ce/components/pages/editor/embed/index.ts new file mode 100644 index 000000000..f30596cb0 --- /dev/null +++ b/web/ce/components/pages/editor/embed/index.ts @@ -0,0 +1 @@ +export * from "./issue-embed"; diff --git a/web/ce/components/pages/editor/embed/issue-embed.tsx b/web/ce/components/pages/editor/embed/issue-embed.tsx new file mode 100644 index 000000000..dc06f4f9d --- /dev/null +++ b/web/ce/components/pages/editor/embed/issue-embed.tsx @@ -0,0 +1,30 @@ +import { Crown } from "lucide-react"; +// ui +import { Button } from "@plane/ui"; + +export const IssueEmbedCard: React.FC = (props) => ( +
+
+ {props.node?.attrs?.project_identifier}-{props?.node?.attrs?.sequence_id} +
+
+
+
+
+ +
+
+ Embed and access issues in pages seamlessly, upgrade to plane pro now. +
+
+ + + +
+
+
+); diff --git a/web/ce/components/pages/editor/index.ts b/web/ce/components/pages/editor/index.ts new file mode 100644 index 000000000..12b3c5295 --- /dev/null +++ b/web/ce/components/pages/editor/index.ts @@ -0,0 +1 @@ +export * from "./embed"; diff --git a/web/ce/components/pages/extra-actions.tsx b/web/ce/components/pages/extra-actions.tsx new file mode 100644 index 000000000..d60d2bc66 --- /dev/null +++ b/web/ce/components/pages/extra-actions.tsx @@ -0,0 +1 @@ +export const PageDetailsHeaderExtraActions = () => null; diff --git a/web/ce/components/pages/index.ts b/web/ce/components/pages/index.ts new file mode 100644 index 000000000..6f3d30c9a --- /dev/null +++ b/web/ce/components/pages/index.ts @@ -0,0 +1,2 @@ +export * from "./editor"; +export * from "./extra-actions"; diff --git a/web/ce/components/sidebar/app-switcher.tsx b/web/ce/components/sidebar/app-switcher.tsx new file mode 100644 index 000000000..1344211b0 --- /dev/null +++ b/web/ce/components/sidebar/app-switcher.tsx @@ -0,0 +1 @@ +export const SidebarAppSwitcher = () => null; diff --git a/web/ce/components/sidebar/index.ts b/web/ce/components/sidebar/index.ts new file mode 100644 index 000000000..5cda1afb5 --- /dev/null +++ b/web/ce/components/sidebar/index.ts @@ -0,0 +1 @@ +export * from "./app-switcher"; diff --git a/web/ce/components/views/access-controller.tsx b/web/ce/components/views/access-controller.tsx new file mode 100644 index 000000000..8eefff027 --- /dev/null +++ b/web/ce/components/views/access-controller.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const AccessController = (props: any) => <>; diff --git a/web/ce/components/views/filters/access-filter.tsx b/web/ce/components/views/filters/access-filter.tsx new file mode 100644 index 000000000..8c3232d47 --- /dev/null +++ b/web/ce/components/views/filters/access-filter.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const FilterByAccess = (props: any) => <>; diff --git a/web/ce/components/workspace/edition-badge.tsx b/web/ce/components/workspace/edition-badge.tsx new file mode 100644 index 000000000..5b81bae78 --- /dev/null +++ b/web/ce/components/workspace/edition-badge.tsx @@ -0,0 +1,19 @@ +import { observer } from "mobx-react"; +// ui +import { Tooltip } from "@plane/ui"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// assets +import packageJson from "package.json"; + +export const WorkspaceEditionBadge = observer(() => { + const { isMobile } = usePlatformOS(); + + return ( + +
+ Community +
+
+ ); +}); diff --git a/web/ce/components/workspace/index.ts b/web/ce/components/workspace/index.ts new file mode 100644 index 000000000..d373164c5 --- /dev/null +++ b/web/ce/components/workspace/index.ts @@ -0,0 +1 @@ +export * from "./edition-badge"; diff --git a/web/ce/constants/estimates.ts b/web/ce/constants/estimates.ts new file mode 100644 index 000000000..7ffe761a8 --- /dev/null +++ b/web/ce/constants/estimates.ts @@ -0,0 +1,125 @@ +// types +import { TEstimateSystems } from "@plane/types"; + +export const MAX_ESTIMATE_POINT_INPUT_LENGTH = 20; + +export enum EEstimateSystem { + POINTS = "points", + CATEGORIES = "categories", + TIME = "time", +} + +export enum EEstimateUpdateStages { + CREATE = "create", + EDIT = "edit", + SWITCH = "switch", +} + +export const estimateCount = { + min: 2, + max: 6, +}; + +export const ESTIMATE_SYSTEMS: TEstimateSystems = { + points: { + name: "Points", + templates: { + fibonacci: { + title: "Fibonacci", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + { id: undefined, key: 3, value: "3" }, + { id: undefined, key: 4, value: "5" }, + { id: undefined, key: 5, value: "8" }, + { id: undefined, key: 6, value: "13" }, + ], + }, + linear: { + title: "Linear", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + { id: undefined, key: 3, value: "3" }, + { id: undefined, key: 4, value: "4" }, + { id: undefined, key: 5, value: "5" }, + { id: undefined, key: 6, value: "6" }, + ], + }, + squares: { + title: "Squares", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "4" }, + { id: undefined, key: 3, value: "9" }, + { id: undefined, key: 4, value: "16" }, + { id: undefined, key: 5, value: "25" }, + { id: undefined, key: 6, value: "36" }, + ], + }, + custom: { + title: "Custom", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + ], + hide: true, + }, + }, + is_available: true, + is_ee: false, + }, + categories: { + name: "Categories", + templates: { + t_shirt_sizes: { + title: "T-Shirt Sizes", + values: [ + { id: undefined, key: 1, value: "XS" }, + { id: undefined, key: 2, value: "S" }, + { id: undefined, key: 3, value: "M" }, + { id: undefined, key: 4, value: "L" }, + { id: undefined, key: 5, value: "XL" }, + { id: undefined, key: 6, value: "XXL" }, + ], + }, + easy_to_hard: { + title: "Easy to hard", + values: [ + { id: undefined, key: 1, value: "Easy" }, + { id: undefined, key: 2, value: "Medium" }, + { id: undefined, key: 3, value: "Hard" }, + { id: undefined, key: 4, value: "Very Hard" }, + ], + }, + custom: { + title: "Custom", + values: [ + { id: undefined, key: 1, value: "Easy" }, + { id: undefined, key: 2, value: "Hard" }, + ], + hide: true, + }, + }, + is_available: true, + is_ee: false, + }, + time: { + name: "Time", + templates: { + hours: { + title: "Hours", + values: [ + { id: undefined, key: 1, value: "1" }, + { id: undefined, key: 2, value: "2" }, + { id: undefined, key: 3, value: "3" }, + { id: undefined, key: 4, value: "4" }, + { id: undefined, key: 5, value: "5" }, + { id: undefined, key: 6, value: "6" }, + ], + }, + }, + is_available: false, + is_ee: true, + }, +}; diff --git a/web/ce/constants/issue.ts b/web/ce/constants/issue.ts new file mode 100644 index 000000000..68622c8fe --- /dev/null +++ b/web/ce/constants/issue.ts @@ -0,0 +1 @@ +export const ENABLE_BULK_OPERATIONS = false; diff --git a/web/ce/services/index.ts b/web/ce/services/index.ts new file mode 100644 index 000000000..3a7bd7005 --- /dev/null +++ b/web/ce/services/index.ts @@ -0,0 +1,2 @@ +export * from "./project"; +export * from "./workspace.service"; \ No newline at end of file diff --git a/web/ce/services/project/estimate.service.ts b/web/ce/services/project/estimate.service.ts new file mode 100644 index 000000000..1659d9966 --- /dev/null +++ b/web/ce/services/project/estimate.service.ts @@ -0,0 +1,106 @@ +/* eslint-disable no-useless-catch */ + +// types +import { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class EstimateService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchWorkspaceEstimates(workspaceSlug: string): Promise { + try { + const { data } = await this.get(`/api/workspaces/${workspaceSlug}/estimates/`); + return data || undefined; + } catch (error) { + throw error; + } + } + + async fetchProjectEstimates(workspaceSlug: string, projectId: string): Promise { + try { + const { data } = await this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`); + return data || undefined; + } catch (error) { + throw error; + } + } + + async fetchEstimateById( + workspaceSlug: string, + projectId: string, + estimateId: string + ): Promise { + try { + const { data } = await this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/` + ); + return data || undefined; + } catch (error) { + throw error; + } + } + + async createEstimate( + workspaceSlug: string, + projectId: string, + payload: IEstimateFormData + ): Promise { + try { + const { data } = await this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/`, payload); + return data || undefined; + } catch (error) { + throw error; + } + } + + async deleteEstimate(workspaceSlug: string, projectId: string, estimateId: string): Promise { + try { + await this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/`); + } catch (error) { + throw error; + } + } + + async createEstimatePoint( + workspaceSlug: string, + projectId: string, + estimateId: string, + payload: Partial + ): Promise { + try { + const { data } = await this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/`, + payload + ); + return data || undefined; + } catch (error) { + throw error; + } + } + + async updateEstimatePoint( + workspaceSlug: string, + projectId: string, + estimateId: string, + estimatePointId: string, + payload: Partial + ): Promise { + try { + const { data } = await this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/estimates/${estimateId}/estimate-points/${estimatePointId}/`, + payload + ); + return data || undefined; + } catch (error) { + throw error; + } + } +} +const estimateService = new EstimateService(); + +export default estimateService; diff --git a/web/ce/services/project/index.ts b/web/ce/services/project/index.ts new file mode 100644 index 000000000..6c0fc3df4 --- /dev/null +++ b/web/ce/services/project/index.ts @@ -0,0 +1,2 @@ +export * from "./estimate.service"; +export * from "./view.service"; \ No newline at end of file diff --git a/web/ce/services/project/view.service.ts b/web/ce/services/project/view.service.ts new file mode 100644 index 000000000..07872394a --- /dev/null +++ b/web/ce/services/project/view.service.ts @@ -0,0 +1,24 @@ +import { EViewAccess } from "@/constants/views"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { ViewService as CoreViewService } from "@/services/view.service"; + +export class ViewService extends CoreViewService { + constructor() { + super(API_BASE_URL); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async lockView(workspaceSlug: string, projectId: string, viewId: string) { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async unLockView(workspaceSlug: string, projectId: string, viewId: string) { + return Promise.resolve(); + } +} diff --git a/web/ce/services/workspace.service.ts b/web/ce/services/workspace.service.ts new file mode 100644 index 000000000..59fe39c69 --- /dev/null +++ b/web/ce/services/workspace.service.ts @@ -0,0 +1,24 @@ +import { EViewAccess } from "@/constants/views"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; + +export class WorkspaceService extends CoreWorkspaceService { + constructor() { + super(API_BASE_URL); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async lockView(workspaceSlug: string, viewId: string) { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async unLockView(workspaceSlug: string, viewId: string) { + return Promise.resolve(); + } +} diff --git a/web/ce/store/estimates/estimate.ts b/web/ce/store/estimates/estimate.ts new file mode 100644 index 000000000..a61b9ee36 --- /dev/null +++ b/web/ce/store/estimates/estimate.ts @@ -0,0 +1,158 @@ +/* eslint-disable no-useless-catch */ + +import orderBy from "lodash/orderBy"; +import set from "lodash/set"; +// import unset from "lodash/unset"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// types +import { IEstimate as IEstimateType, IEstimatePoint as IEstimatePointType, TEstimateSystemKeys } from "@plane/types"; +// plane web services +import estimateService from "@/plane-web/services/project/estimate.service"; +// store +import { IEstimatePoint, EstimatePoint } from "@/store/estimates/estimate-point"; +import { CoreRootStore } from "@/store/root.store"; + +type TErrorCodes = { + status: string; + message?: string; +}; + +export interface IEstimate extends Omit { + // observables + error: TErrorCodes | undefined; + estimatePoints: Record; + // computed + asJson: Omit; + estimatePointIds: string[] | undefined; + estimatePointById: (estimatePointId: string) => IEstimatePointType | undefined; + // actions + creteEstimatePoint: ( + workspaceSlug: string, + projectId: string, + payload: Partial + ) => Promise; +} + +export class Estimate implements IEstimate { + // data model observables + id: string | undefined = undefined; + name: string | undefined = undefined; + description: string | undefined = undefined; + type: TEstimateSystemKeys | undefined = undefined; + workspace: string | undefined = undefined; + project: string | undefined = undefined; + last_used: boolean | undefined = undefined; + created_at: Date | undefined = undefined; + updated_at: Date | undefined = undefined; + created_by: string | undefined = undefined; + updated_by: string | undefined = undefined; + // observables + error: TErrorCodes | undefined = undefined; + estimatePoints: Record = {}; + + constructor( + public store: CoreRootStore, + public data: IEstimateType + ) { + makeObservable(this, { + // data model observables + id: observable.ref, + name: observable.ref, + description: observable.ref, + type: observable.ref, + workspace: observable.ref, + project: observable.ref, + last_used: observable.ref, + created_at: observable.ref, + updated_at: observable.ref, + created_by: observable.ref, + updated_by: observable.ref, + // observables + error: observable.ref, + estimatePoints: observable, + // computed + asJson: computed, + estimatePointIds: computed, + // actions + creteEstimatePoint: action, + }); + this.id = this.data.id; + this.name = this.data.name; + this.description = this.data.description; + this.type = this.data.type; + this.workspace = this.data.workspace; + this.project = this.data.project; + this.last_used = this.data.last_used; + this.created_at = this.data.created_at; + this.updated_at = this.data.updated_at; + this.created_by = this.data.created_by; + this.updated_by = this.data.updated_by; + this.data.points?.forEach((estimationPoint) => { + if (estimationPoint.id) + set(this.estimatePoints, [estimationPoint.id], new EstimatePoint(this.store, this.data, estimationPoint)); + }); + } + + // computed + get asJson() { + return { + id: this.id, + name: this.name, + description: this.description, + type: this.type, + workspace: this.workspace, + project: this.project, + last_used: this.last_used, + created_at: this.created_at, + updated_at: this.updated_at, + created_by: this.created_by, + updated_by: this.updated_by, + }; + } + + get estimatePointIds() { + const { estimatePoints } = this; + if (!estimatePoints) return undefined; + let currentEstimatePoints = Object.values(estimatePoints).filter( + (estimatePoint) => estimatePoint?.estimate === this.id + ); + currentEstimatePoints = orderBy(currentEstimatePoints, ["key"], "asc"); + const estimatePointIds = currentEstimatePoints.map((estimatePoint) => estimatePoint.id) as string[]; + return estimatePointIds ?? undefined; + } + + estimatePointById = computedFn((estimatePointId: string) => { + if (!estimatePointId) return undefined; + return this.estimatePoints[estimatePointId] ?? undefined; + }); + + // actions + /** + * @description create an estimate point + * @param { string } workspaceSlug + * @param { string } projectId + * @param { Partial } payload + * @returns { IEstimatePointType | undefined } + */ + creteEstimatePoint = async ( + workspaceSlug: string, + projectId: string, + payload: Partial + ): Promise => { + try { + if (!this.id || !payload) return; + + const estimatePoint = await estimateService.createEstimatePoint(workspaceSlug, projectId, this.id, payload); + if (estimatePoint) { + runInAction(() => { + if (estimatePoint.id) { + set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint)); + } + }); + } + } catch (error) { + throw error; + } + }; +} diff --git a/web/ce/store/root.store.ts b/web/ce/store/root.store.ts new file mode 100644 index 000000000..710462e13 --- /dev/null +++ b/web/ce/store/root.store.ts @@ -0,0 +1,8 @@ +// store +import { CoreRootStore } from "@/store/root.store"; + +export class RootStore extends CoreRootStore { + constructor() { + super(); + } +} diff --git a/web/components/account/auth-forms/password.tsx b/web/components/account/auth-forms/password.tsx deleted file mode 100644 index 11bf29ca5..000000000 --- a/web/components/account/auth-forms/password.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -// icons -import { Eye, EyeOff, XCircle } from "lucide-react"; -// ui -import { Button, Input, Spinner } from "@plane/ui"; -// components -import { ForgotPasswordPopover, PasswordStrengthMeter } from "@/components/account"; -// constants -import { FORGOT_PASSWORD } from "@/constants/event-tracker"; -// helpers -import { EAuthModes, EAuthSteps } from "@/helpers/authentication.helper"; -import { API_BASE_URL } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; -// hooks -import { useEventTracker } from "@/hooks/store"; -// services -import { AuthService } from "@/services/auth.service"; - -type Props = { - email: string; - isSMTPConfigured: boolean; - mode: EAuthModes; - handleEmailClear: () => void; - handleAuthStep: (step: EAuthSteps) => void; -}; - -type TPasswordFormValues = { - email: string; - password: string; - confirm_password?: string; -}; - -const defaultValues: TPasswordFormValues = { - email: "", - password: "", -}; - -const authService = new AuthService(); - -export const AuthPasswordForm: React.FC = observer((props: Props) => { - const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props; - // hooks - const { captureEvent } = useEventTracker(); - // states - const [csrfToken, setCsrfToken] = useState(undefined); - const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); - const [showPassword, setShowPassword] = useState({ - password: false, - retypePassword: false, - }); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isPasswordInputFocused, setIsPasswordInputFocused] = useState(false); - const [isRetryPasswordInputFocused, setIsRetryPasswordInputFocused] = useState(false); - - const handleShowPassword = (key: keyof typeof showPassword) => - setShowPassword((prev) => ({ ...prev, [key]: !prev[key] })); - - const handleFormChange = (key: keyof TPasswordFormValues, value: string) => - setPasswordFormData((prev) => ({ ...prev, [key]: value })); - - useEffect(() => { - if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); - }, [csrfToken]); - - const redirectToUniqueCodeSignIn = async () => { - handleAuthStep(EAuthSteps.UNIQUE_CODE); - }; - - const passwordSupport = - mode === EAuthModes.SIGN_IN ? ( -
- {isSMTPConfigured ? ( - captureEvent(FORGOT_PASSWORD)} - href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`} - className="text-xs font-medium text-custom-primary-100" - > - Forgot your password? - - ) : ( - - )} -
- ) : ( - passwordFormData.password.length > 0 && - (getPasswordStrength(passwordFormData.password) < 3 || isPasswordInputFocused) && ( - - ) - ); - - const isButtonDisabled = useMemo( - () => - !isSubmitting && - !!passwordFormData.password && - (mode === EAuthModes.SIGN_UP - ? getPasswordStrength(passwordFormData.password) >= 3 && - passwordFormData.password === passwordFormData.confirm_password - : true) - ? false - : true, - [isSubmitting, mode, passwordFormData.confirm_password, passwordFormData.password] - ); - - const password = passwordFormData?.password ?? ""; - const confirmPassword = passwordFormData?.confirm_password ?? ""; - const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; - - return ( -
setIsSubmitting(true)} - onError={() => setIsSubmitting(false)} - > - - -
- -
- handleFormChange("email", e.target.value)} - placeholder="name@company.com" - className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} - disabled - /> - {passwordFormData.email.length > 0 && ( -
- -
- )} -
-
- -
- -
- handleFormChange("password", e.target.value)} - placeholder="Enter password" - className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" - onFocus={() => setIsPasswordInputFocused(true)} - onBlur={() => setIsPasswordInputFocused(false)} - autoFocus - /> - {showPassword?.password ? ( - handleShowPassword("password")} - /> - ) : ( - handleShowPassword("password")} - /> - )} -
- {passwordSupport} -
- - {mode === EAuthModes.SIGN_UP && ( -
- -
- handleFormChange("confirm_password", e.target.value)} - placeholder="Confirm password" - className="disable-autofill-style h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" - onFocus={() => setIsRetryPasswordInputFocused(true)} - onBlur={() => setIsRetryPasswordInputFocused(false)} - /> - {showPassword?.retypePassword ? ( - handleShowPassword("retypePassword")} - /> - ) : ( - handleShowPassword("retypePassword")} - /> - )} -
- {!!passwordFormData.confirm_password && - passwordFormData.password !== passwordFormData.confirm_password && - renderPasswordMatchError && Passwords don{"'"}t match} -
- )} - -
- {mode === EAuthModes.SIGN_IN ? ( - <> - - {isSMTPConfigured && ( - - )} - - ) : ( - - )} -
-
- ); -}); diff --git a/web/components/account/password-strength-meter.tsx b/web/components/account/password-strength-meter.tsx deleted file mode 100644 index 7383b1e11..000000000 --- a/web/components/account/password-strength-meter.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// icons -import { CircleCheck } from "lucide-react"; -// helpers -import { cn } from "@/helpers/common.helper"; -import { getPasswordStrength } from "@/helpers/password.helper"; - -type Props = { - password: string; -}; - -export const PasswordStrengthMeter: React.FC = (props: Props) => { - const { password } = props; - - const strength = getPasswordStrength(password); - let bars = []; - let text = ""; - let textColor = ""; - - if (password.length === 0) { - bars = [`bg-[#F0F0F3]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password requirements"; - } else if (password.length < 8) { - bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password is too short"; - textColor = `text-[#DC3E42]`; - } else if (strength < 3) { - bars = [`bg-[#DC3E42]`, `bg-[#F0F0F3]`, `bg-[#F0F0F3]`]; - text = "Password is weak"; - textColor = `text-[#DC3E42]`; - } else { - bars = [`bg-[#3E9B4F]`, `bg-[#3E9B4F]`, `bg-[#3E9B4F]`]; - text = "Password is strong"; - textColor = `text-[#3E9B4F]`; - } - - const criteria = [ - { label: "Min 8 characters", isValid: password.length >= 8 }, - { label: "Min 1 upper-case letter", isValid: /[A-Z]/.test(password) }, - { label: "Min 1 number", isValid: /\d/.test(password) }, - { label: "Min 1 special character", isValid: /[!@#$%^&*]/.test(password) }, - ]; - - return ( -
-
- {bars.map((color, index) => ( -
- ))} -
-

{text}

-
- {criteria.map((criterion, index) => ( -
- - {criterion.label} -
- ))} -
-
- ); -}; diff --git a/web/components/analytics/custom-analytics/select/y-axis.tsx b/web/components/analytics/custom-analytics/select/y-axis.tsx deleted file mode 100644 index a33feb967..000000000 --- a/web/components/analytics/custom-analytics/select/y-axis.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// ui -import { TYAxisValues } from "@plane/types"; -import { CustomSelect } from "@plane/ui"; -// types -import { ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics"; -// constants - -type Props = { - value: TYAxisValues; - onChange: () => void; -}; - -export const SelectYAxis: React.FC = ({ value, onChange }) => ( - {ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}} - onChange={onChange} - maxHeight="lg" - > - {ANALYTICS_Y_AXIS_VALUES.map((item) => ( - - {item.label} - - ))} - -); diff --git a/web/components/analytics/custom-analytics/table.tsx b/web/components/analytics/custom-analytics/table.tsx deleted file mode 100644 index efb8293fb..000000000 --- a/web/components/analytics/custom-analytics/table.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { BarDatum } from "@nivo/bar"; - -// icons -import { IAnalyticsParams, IAnalyticsResponse, TIssuePriorities } from "@plane/types"; -import { PriorityIcon } from "@plane/ui"; -// helpers -import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@/constants/analytics"; -import { generateBarColor, generateDisplayName } from "@/helpers/analytics.helper"; -// types -// constants - -type Props = { - analytics: IAnalyticsResponse; - barGraphData: { - data: BarDatum[]; - xAxisKeys: string[]; - }; - params: IAnalyticsParams; - yAxisKey: "count" | "estimate"; -}; - -export const AnalyticsTable: React.FC = ({ analytics, barGraphData, params, yAxisKey }) => ( -
-
-
- - - - - {params.segment ? ( - barGraphData.xAxisKeys.map((key) => ( - - )) - ) : ( - - )} - - - - {barGraphData.data.map((item, index) => ( - - - {params.segment ? ( - barGraphData.xAxisKeys.map((key, index) => ( - - )) - ) : ( - - )} - - ))} - -
- {ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label} - -
- {params.segment === "priority" ? ( - - ) : ( - - )} - {generateDisplayName(key, analytics, params, "segment")} -
-
- {ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label} -
- {params.x_axis === "priority" ? ( - - ) : ( - - )} - {generateDisplayName(`${item.name}`, analytics, params, "x_axis")} - - {item[key] ?? 0} - {item[yAxisKey]}
-
-
-
-); diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx deleted file mode 100644 index 0194ba01f..000000000 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -// headless ui -import { Tab } from "@headlessui/react"; -import { - IIssueFilterOptions, - IIssueFilters, - IModule, - TAssigneesDistribution, - TCompletionChartDistribution, - TLabelsDistribution, - TStateGroups, -} from "@plane/types"; -// hooks -import { Avatar, StateGroupIcon } from "@plane/ui"; -import { SingleProgressStats } from "@/components/core"; -import { useProjectState } from "@/hooks/store"; -import useLocalStorage from "@/hooks/use-local-storage"; -// images -import emptyLabel from "public/empty-state/empty_label.svg"; -import emptyMembers from "public/empty-state/empty_members.svg"; -// components -// ui -// types - -type Props = { - distribution: - | { - assignees: TAssigneesDistribution[]; - completion_chart: TCompletionChartDistribution; - labels: TLabelsDistribution[]; - } - | undefined; - groupedIssues: { - [key: string]: number; - }; - totalIssues: number; - module?: IModule; - roundedTab?: boolean; - noBackground?: boolean; - isPeekView?: boolean; - isCompleted?: boolean; - filters?: IIssueFilters | undefined; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; -}; - -export const SidebarProgressStats: React.FC = observer((props) => { - const { - distribution, - groupedIssues, - totalIssues, - module, - roundedTab, - noBackground, - isPeekView = false, - isCompleted = false, - filters, - handleFiltersUpdate, - } = props; - const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); - - const { groupedProjectStates } = useProjectState(); - - const currentValue = (tab: string | null) => { - switch (tab) { - case "Assignees": - return 0; - case "Labels": - return 1; - case "States": - return 2; - default: - return 0; - } - }; - - const getStateGroupState = (stateGroup: string) => { - const stateGroupStates = groupedProjectStates?.[stateGroup]; - const stateGroupStatesId = stateGroupStates?.map((state) => state.id); - return stateGroupStatesId; - }; - - return ( - { - switch (i) { - case 0: - return setTab("Assignees"); - case 1: - return setTab("Labels"); - case 2: - return setTab("States"); - default: - return setTab("Assignees"); - } - }} - > - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - Assignees - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - Labels - - - `w-full ${ - roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" - } px-3 py-1 text-custom-text-100 ${ - selected - ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" - : "text-custom-text-400 hover:text-custom-text-300" - }` - } - > - States - - - - - {distribution && distribution?.assignees.length > 0 ? ( - distribution.assignees.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - {assignee?.display_name ?? ""} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""), - selected: filters?.filters?.assignees?.includes(assignee.assignee_id ?? ""), - })} - /> - ); - else - return ( - -
- User -
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - }) - ) : ( -
-
- empty members -
-
No assignees yet
-
- )} - - - {distribution && distribution?.labels.length > 0 ? ( - distribution.labels.map((label, index) => { - if (label.label_id) { - return ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""), - selected: filters?.filters?.labels?.includes(label.label_id ?? `no-label-${index}`), - })} - /> - ); - } else { - return ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - /> - ); - } - }) - ) : ( -
-
- empty label -
-
No labels yet
-
- )} - - - {Object.keys(groupedIssues).map((group, index) => ( - - - {group} -
- } - completed={groupedIssues[group]} - total={totalIssues} - {...(!isPeekView && - !isCompleted && { - onClick: () => handleFiltersUpdate("state", getStateGroupState(group) ?? []), - })} - /> - ))} - - - - ); -}); diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx deleted file mode 100644 index 32d17df75..000000000 --- a/web/components/cycles/active-cycle/productivity.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { FC } from "react"; -import Link from "next/link"; -// types -import { ICycle } from "@plane/types"; -// components -import ProgressChart from "@/components/core/sidebar/progress-chart"; -import { EmptyState } from "@/components/empty-state"; -// constants -import { EmptyStateType } from "@/constants/empty-state"; - -export type ActiveCycleProductivityProps = { - workspaceSlug: string; - projectId: string; - cycle: ICycle; -}; - -export const ActiveCycleProductivity: FC = (props) => { - const { workspaceSlug, projectId, cycle } = props; - - return ( - -
-

Issue burndown

-
- {cycle.total_issues > 0 ? ( - <> -
-
-
-
- - Ideal -
-
- - Current -
-
- {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} -
-
- -
-
- - ) : ( - <> -
- -
- - )} - - ); -}; diff --git a/web/components/estimates/create-update-estimate-modal.tsx b/web/components/estimates/create-update-estimate-modal.tsx deleted file mode 100644 index c8b44cc32..000000000 --- a/web/components/estimates/create-update-estimate-modal.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import React, { useEffect } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import { Controller, useForm } from "react-hook-form"; -// types -import { IEstimate, IEstimateFormData } from "@plane/types"; -// ui -import { Button, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { EModalPosition, EModalWidth, ModalCore } from "@/components/core"; -// helpers -import { checkDuplicates } from "@/helpers/array.helper"; -// hooks -import { useEstimate } from "@/hooks/store"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data?: IEstimate; -}; - -const defaultValues = { - name: "", - description: "", - value1: "", - value2: "", - value3: "", - value4: "", - value5: "", - value6: "", -}; - -type FormValues = typeof defaultValues; - -export const CreateUpdateEstimateModal: React.FC = observer((props) => { - const { handleClose, data, isOpen } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // store hooks - const { createEstimate, updateEstimate } = useEstimate(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - control, - reset, - } = useForm({ - defaultValues, - }); - - const onClose = () => { - handleClose(); - reset(); - }; - - const handleCreateEstimate = async (payload: IEstimateFormData) => { - if (!workspaceSlug || !projectId) return; - - await createEstimate(workspaceSlug.toString(), projectId.toString(), payload) - .then(() => { - onClose(); - }) - .catch((err) => { - const error = err?.error; - const errorString = Array.isArray(error) ? error[0] : error; - - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - errorString ?? err.status === 400 - ? "Estimate with that name already exists. Please try again with another name." - : "Estimate could not be created. Please try again.", - }); - }); - }; - - const handleUpdateEstimate = async (payload: IEstimateFormData) => { - if (!workspaceSlug || !projectId || !data) return; - - await updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload) - .then(() => { - onClose(); - }) - .catch((err) => { - const error = err?.error; - const errorString = Array.isArray(error) ? error[0] : error; - - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: errorString ?? "Estimate could not be updated. Please try again.", - }); - }); - }; - - const onSubmit = async (formData: FormValues) => { - if (!formData.name || formData.name === "") { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate title cannot be empty.", - }); - return; - } - - if ( - formData.value1 === "" || - formData.value2 === "" || - formData.value3 === "" || - formData.value4 === "" || - formData.value5 === "" || - formData.value6 === "" - ) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate point cannot be empty.", - }); - return; - } - - if ( - formData.value1.length > 20 || - formData.value2.length > 20 || - formData.value3.length > 20 || - formData.value4.length > 20 || - formData.value5.length > 20 || - formData.value6.length > 20 - ) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate point cannot have more than 20 characters.", - }); - return; - } - - if ( - checkDuplicates([ - formData.value1, - formData.value2, - formData.value3, - formData.value4, - formData.value5, - formData.value6, - ]) - ) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Estimate points cannot have duplicate values.", - }); - return; - } - - const payload: IEstimateFormData = { - estimate: { - name: formData.name, - description: formData.description, - }, - estimate_points: [], - }; - - for (let i = 0; i < 6; i++) { - const point = { - key: i, - value: formData[`value${i + 1}` as keyof FormValues], - }; - - if (data) - payload.estimate_points.push({ - id: data.points[i].id, - ...point, - }); - else payload.estimate_points.push({ ...point }); - } - - if (data) await handleUpdateEstimate(payload); - else await handleCreateEstimate(payload); - }; - - useEffect(() => { - if (data) - reset({ - ...defaultValues, - ...data, - value1: data.points[0]?.value, - value2: data.points[1]?.value, - value3: data.points[2]?.value, - value4: data.points[3]?.value, - value5: data.points[4]?.value, - value6: data.points[5]?.value, - }); - else reset({ ...defaultValues }); - }, [data, reset]); - - return ( - -
-
-
{data ? "Update" : "Create"} Estimate
-
-
- ( - - )} - /> -
-
- ( -