diff --git a/.env.example b/.env.example index 71a9074a63a..8b11217b5d4 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ PGDATA="/var/lib/postgresql/data" REDIS_HOST="plane-redis" REDIS_PORT="6379" +# RabbitMQ Settings +RABBITMQ_HOST="plane-mq" +RABBITMQ_PORT="5672" +RABBITMQ_USER="plane" +RABBITMQ_PASSWORD="plane" +RABBITMQ_VHOST="plane" + # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" diff --git a/.eslintrc-staged.js b/.eslintrc-staged.js deleted file mode 100644 index be20772a75f..00000000000 --- a/.eslintrc-staged.js +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Adds three new lint plugins over the existing configuration: - * This is used to lint staged files only. - * We should remove this file once the entire codebase follows these rules. - */ -module.exports = { - root: true, - extends: [ - "custom", - ], - parser: "@typescript-eslint/parser", - settings: { - "import/resolver": { - typescript: {}, - node: { - moduleDirectory: ["node_modules", "."], - }, - }, - }, - 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: "@headlessui/**", - 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/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index b1a019e351a..00000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - root: true, - // This tells ESLint to load the config from the package `eslint-config-custom` - extends: ["custom"], - settings: { - next: { - rootDir: ["web/", "space/", "admin/"], - }, - }, -}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..526c8a38d4a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index d1d7fa009f3..ec03769295d 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -2,7 +2,7 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " labels: [🐛bug] -assignees: [srinivaspendem, pushya22] +assignees: [vihar, pushya22] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index ff9cdd23839..390c95aaac6 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -2,7 +2,7 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " labels: [✨feature] -assignees: [srinivaspendem, pushya22] +assignees: [vihar, pushya22] body: - type: markdown attributes: diff --git a/.github/workflows/build-aio-branch.yml b/.github/workflows/build-aio-branch.yml index de68d4b96df..8e28fe0d44e 100644 --- a/.github/workflows/build-aio-branch.yml +++ b/.github/workflows/build-aio-branch.yml @@ -31,6 +31,7 @@ jobs: runs-on: ubuntu-latest outputs: gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} @@ -56,7 +57,7 @@ jobs: echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT - if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then + if [ "${{ github.event_name}}" == "workflow_dispatch" ] && [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT @@ -78,6 +79,9 @@ jobs: echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT fi + FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g') + echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT + - id: checkout_files name: Checkout Files uses: actions/checkout@v4 @@ -89,7 +93,7 @@ jobs: 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 }} + AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -132,7 +136,7 @@ jobs: tags: ${{ env.AIO_IMAGE_TAGS }} push: true build-args: | - BUILD_TAG=${{ env.AIO_BASE_TAG }} + BASE_TAG=${{ env.AIO_BASE_TAG }} BUILD_TYPE=${{env.BUILD_TYPE}} cache-from: type=gha cache-to: type=gha,mode=max @@ -149,7 +153,7 @@ jobs: 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 }} + AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -192,7 +196,7 @@ jobs: tags: ${{ env.AIO_IMAGE_TAGS }} push: true build-args: | - BUILD_TAG=${{ env.AIO_BASE_TAG }} + BASE_TAG=${{ env.AIO_BASE_TAG }} BUILD_TYPE=${{env.BUILD_TYPE}} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index f46d8b5ad35..7c5dbf46666 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -2,6 +2,12 @@ name: Branch Build on: workflow_dispatch: + inputs: + arm64: + description: "Build for ARM64 architecture" + required: false + default: false + type: boolean push: branches: - master @@ -11,6 +17,8 @@ on: env: TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} + ARM64_BUILD: ${{ github.event.inputs.arm64 }} + IS_PRERELEASE: ${{ github.event.release.prerelease }} jobs: branch_build_setup: @@ -27,12 +35,14 @@ jobs: build_admin: ${{ steps.changed_files.outputs.admin_any_changed }} build_space: ${{ steps.changed_files.outputs.space_any_changed }} build_web: ${{ steps.changed_files.outputs.web_any_changed }} + build_live: ${{ steps.changed_files.outputs.live_any_changed }} + flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }} steps: - id: set_env_variables name: Set Environment Variables run: | - if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.ARM64_BUILD }}" == "true" ] || ([ "${{ github.event_name }}" == "release" ] && [ "${{ env.IS_PRERELEASE }}" != "true" ]); then echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT @@ -44,6 +54,8 @@ jobs: echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT fi echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + flat_branch_name=$(echo ${{ env.TARGET_BRANCH }} | sed 's/[^a-zA-Z0-9\._]/-/g') + echo "FLAT_BRANCH_NAME=${flat_branch_name}" >> $GITHUB_OUTPUT - id: checkout_files name: Checkout Files @@ -79,13 +91,21 @@ jobs: - 'yarn.lock' - 'tsconfig.json' - 'turbo.json' + live: + - live/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' branch_build_push_web: if: ${{ (needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master') && !contains(needs.branch_build_setup.outputs.gh_buildx_platforms, 'linux/arm64') }} + name: Build-Push Web Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - FRONTEND_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + FRONTEND_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -95,7 +115,10 @@ jobs: - name: Set Frontend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend:latest else @@ -148,7 +171,10 @@ jobs: - name: Set Frontend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-amd64:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-amd64:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-amd64:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-amd64:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-amd64:latest else @@ -201,7 +227,10 @@ jobs: - name: Set Frontend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-arm64:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-arm64:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-arm64:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-arm64:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-frontend-arm64:latest else @@ -292,10 +321,10 @@ jobs: registry: ${{ secrets.DOCKER_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Push Frontend to Docker Container Registry + + - name: Push Frontend to Docker Container Registry (Release) uses: int128/docker-manifest-create-action@v2 - if: ${{ github.event_name == 'release' || env.TARGET_BRANCH == 'master' }} + if: ${{ github.event_name == 'release' && env.IS_PRERELEASE != 'true' }} with: tags: | ${{ env.FRONTEND_TAG }}:stable @@ -303,6 +332,26 @@ jobs: sources: | ${{ env.FRONTEND_TAG_AMD64 }} ${{ env.FRONTEND_TAG_ARM64 }} + + - name: Push Frontend to Docker Container Registry (Pre-Release) + uses: int128/docker-manifest-create-action@v2 + if: ${{ github.event_name == 'release' && env.IS_PRERELEASE == 'true' }} + with: + tags: | + ${{ env.FRONTEND_TAG }}:${{ github.event.release.tag_name }} + sources: | + ${{ env.FRONTEND_TAG_AMD64 }} + ${{ env.FRONTEND_TAG_ARM64 }} + + - name: Push Frontend to Docker Container Registry (Master) + uses: int128/docker-manifest-create-action@v2 + if: ${{ env.TARGET_BRANCH == 'master' }} + with: + tags: | + ${{ env.FRONTEND_TAG }} + sources: | + ${{ env.FRONTEND_TAG_AMD64 }} + ${{ env.FRONTEND_TAG_ARM64 }} - name: Push Frontend to Docker Container Registry uses: int128/docker-manifest-create-action@v2 @@ -316,10 +365,11 @@ jobs: branch_build_push_admin: if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Admin Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - ADMIN_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:${{ needs.branch_build_setup.outputs.gh_branch_name }} + ADMIN_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -329,7 +379,10 @@ jobs: - name: Set Admin Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-admin:latest else @@ -369,10 +422,11 @@ jobs: branch_build_push_space: if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Space Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - SPACE_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} + SPACE_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -382,7 +436,10 @@ jobs: - name: Set Space Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-space:latest else @@ -422,10 +479,11 @@ jobs: branch_build_push_apiserver: if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push API Server Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - BACKEND_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + BACKEND_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -435,7 +493,10 @@ jobs: - name: Set Backend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-backend:latest else @@ -473,12 +534,70 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + branch_build_push_live: + if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Live Collaboration Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + env: + LIVE_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-live:${{ needs.branch_build_setup.outputs.flat_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + steps: + - name: Set Live Docker Tag + run: | + if [ "${{ github.event_name }}" == "release" ]; then + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-live:${{ github.event.release.tag_name }} + if [ "${{ github.event.release.prerelease }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-live:stable + fi + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-live:latest + else + TAG=${{ env.LIVE_TAG }} + fi + echo "LIVE_TAG=${TAG}" >> $GITHUB_ENV + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ secrets.DOCKER_REGISTRY }} + 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 Live Server to Docker Hub + uses: docker/build-push-action@v5.1.0 + with: + context: . + file: ./live/Dockerfile.live + platforms: ${{ env.BUILDX_PLATFORMS }} + tags: ${{ env.LIVE_TAG }} + push: true + env: + DOCKER_BUILDKIT: 1 + DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + branch_build_push_proxy: if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Proxy Docker Image runs-on: ubuntu-20.04 needs: [branch_build_setup] env: - PROXY_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} + PROXY_TAG: ${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:${{ needs.branch_build_setup.outputs.flat_branch_name }} TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} @@ -488,7 +607,10 @@ jobs: - name: Set Proxy Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:stable,${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:${{ github.event.release.tag_name }} + if [ "${{ env.IS_PRERELEASE }}" != "true" ]; then + TAG=${TAG},${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:stable + fi elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKER_REGISTRY }}/${{ secrets.DOCKER_REPO }}/plane-proxy:latest else diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 24c4fb9954f..46f6365fd97 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -8,7 +8,6 @@ on: env: CURRENT_BRANCH: ${{ github.ref_name }} - SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce" TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows REVIEWER: ${{ vars.SYNC_PR_REVIEWER }} @@ -16,22 +15,7 @@ env: ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }} jobs: - Check_Branch: - runs-on: ubuntu-latest - outputs: - BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }} - steps: - - name: Check if current branch matches the secret - id: check-branch - run: | - if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then - echo "MATCH=true" >> $GITHUB_OUTPUT - else - echo "MATCH=false" >> $GITHUB_OUTPUT - fi Create_PR: - if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} - needs: [Check_Branch] runs-on: ubuntu-latest permissions: pull-requests: write @@ -59,11 +43,11 @@ jobs: - name: Create PR to Target Branch run: | # get all pull requests and check if there is already a PR - PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number') + PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number') if [ -n "$PR_EXISTS" ]; then echo "Pull Request already exists: $PR_EXISTS" else echo "Creating new pull request" - PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: community changes" --body "") + PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "") echo "Pull Request created: $PR_URL" fi diff --git a/.github/workflows/repo-sync.yml b/.github/workflows/repo-sync.yml index 9ac4771ef6e..2c211cf318f 100644 --- a/.github/workflows/repo-sync.yml +++ b/.github/workflows/repo-sync.yml @@ -35,8 +35,9 @@ jobs: env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | + RUN_ID="${{ github.run_id }}" TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}" - TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}" + TARGET_BRANCH="sync/${RUN_ID}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/.idx/dev.nix b/.idx/dev.nix new file mode 100644 index 00000000000..f150f679a1d --- /dev/null +++ b/.idx/dev.nix @@ -0,0 +1,16 @@ +{ pkgs, ... }: { + + # Which nixpkgs channel to use. + channel = "stable-23.11"; # or "unstable" + + # Use https://search.nixos.org/packages to find packages + packages = [ + pkgs.nodejs_20 + pkgs.python3 + ]; + + services.docker.enable = true; + services.postgres.enable = true; + services.redis.enable = true; + +} \ No newline at end of file diff --git a/.lintstagedrc.json b/.lintstagedrc.json deleted file mode 100644 index 22825d7711f..00000000000 --- a/.lintstagedrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f40c1a244bb..9d352cbab12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thank you for showing an interest in contributing to Plane! All kinds of contrib ## Submitting an issue -Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new informplaneation. +Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new information. While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like: diff --git a/ENV_SETUP.md b/ENV_SETUP.md index df05683efd9..cdcf6be3701 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -1,6 +1,5 @@ # Environment Variables -​ Environment variables are distributed in various files. Please refer them carefully. ## {PROJECT_FOLDER}/.env @@ -9,17 +8,13 @@ File is available in the project root folder​ ``` # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} -​ +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_DB="plane" +PGDATA="/var/lib/postgresql/data" # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" -REDIS_URL="redis://${REDIS_HOST}:6379/" -​ # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -29,63 +24,39 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000" AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit FILE_SIZE_LIMIT=5242880 -​ # GPT settings OPENAI_API_BASE="https://api.openai.com/v1" # deprecated OPENAI_API_KEY="sk-" # deprecated GPT_ENGINE="gpt-3.5-turbo" # deprecated -​ +# Settings related to Docker +DOCKERIZED=1 # deprecated # set to 1 If using the pre-configured minio setup USE_MINIO=1 -​ # Nginx Configuration NGINX_PORT=80 ``` -​ - -## {PROJECT_FOLDER}/web/.env.example - -​ - -``` -# Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" -``` - ## {PROJECT_FOLDER}/apiserver/.env -​ - ``` # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -​ +CORS_ALLOWED_ORIGINS="http://localhost" # Error logs SENTRY_DSN="" -​ +SENTRY_ENVIRONMENT="development" # Database Settings -PGUSER="plane" -PGPASSWORD="plane" -PGHOST="plane-db" -PGDATABASE="plane" -DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} -​ +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="plane" +POSTGRES_PORT=5432 +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" REDIS_URL="redis://${REDIS_HOST}:6379/" -​ -# Email Settings -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" -EMAIL_PORT=587 -EMAIL_FROM="Team Plane " -EMAIL_USE_TLS="1" -EMAIL_USE_SSL="0" -​ # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -95,35 +66,25 @@ AWS_S3_ENDPOINT_URL="http://plane-minio:9000" AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit FILE_SIZE_LIMIT=5242880 -​ -# GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # deprecated -OPENAI_API_KEY="sk-" # deprecated -GPT_ENGINE="gpt-3.5-turbo" # deprecated -​ # Settings related to Docker -DOCKERIZED=1 # Deprecated - -# Github -GITHUB_CLIENT_SECRET="" # For fetching release notes -​ +DOCKERIZED=1 # deprecated # set to 1 If using the pre-configured minio setup USE_MINIO=1 -​ # Nginx Configuration NGINX_PORT=80 -​ -​ -# SignUps -ENABLE_SIGNUP="1" -​ -# Email Redirection URL +# Email redirections and minio domain settings WEB_URL="http://localhost" +# Gunicorn Workers +GUNICORN_WORKERS=2 +# Base URLs +ADMIN_BASE_URL= +SPACE_BASE_URL= +APP_BASE_URL= +SECRET_KEY="gxoytl7dmnc1y37zahah820z5iq3iozu38cnfjtu3yaau9cd9z" ``` ## Updates​ -- The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects. - The naming convention for containers and images has been updated. - The plane-worker image will no longer be maintained, as it has been merged with plane-backend. - The Tiptap pro-extension dependency has been removed, eliminating the need for Tiptap API keys. diff --git a/SECURITY.md b/SECURITY.md index 36cdb982c3e..0e11bbb5570 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,44 +1,39 @@ -# Security Policy +# Security policy +This document outlines the security protocols and vulnerability reporting guidelines for the Plane project. Ensuring the security of our systems is a top priority, and while we work diligently to maintain robust protection, vulnerabilities may still occur. We highly value the community’s role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users. -This document outlines security procedures and vulnerabilities reporting for the Plane project. +## Reporting a vulnerability +If you have identified a security vulnerability, submit your findings to [security@plane.so](mailto:security@plane.so). +Ensure your report includes all relevant information needed for us to reproduce and assess the issue. Include the IP address or URL of the affected system. -At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients. +To ensure a responsible and effective disclosure process, please adhere to the following: -To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it. +- Maintain confidentiality and refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address the issue. +- Refrain from running automated vulnerability scans on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary. +- Do not exploit any discovered vulnerabilities for malicious purposes, such as accessing or altering user data. +- Do not engage in physical security attacks, social engineering, distributed denial of service (DDoS) attacks, spam campaigns, or attacks on third-party applications as part of your vulnerability testing. -## Out of Scope Vulnerabilities +## Out of scope +While we appreciate all efforts to assist in improving our security, please note that the following types of vulnerabilities are considered out of scope: -We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope: +- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a user’s device. +- Content spoofing or text injection issues without a clear attack vector or the ability to modify HTML/CSS. +- Issues related to email spoofing. +- Missing DNSSEC, CAA, or CSP headers. +- Absence of secure or HTTP-only flags on non-sensitive cookies. -- Attacks requiring MITM or physical access to a user's device. -- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS. -- Email spoofing. -- Missing DNSSEC, CAA, CSP headers. -- Lack of Secure or HTTP only flag on non-sensitive cookies. +## Our commitment -## Reporting Process +At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us: -If you discover a vulnerability, please adhere to the following reporting process: +- **Response Time**
+We will acknowledge receipt of your vulnerability report within three business days and provide an estimated timeline for resolution. +- **Legal Protection**
+We will not initiate legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines. +- **Confidentiality**
+Your report will be treated with confidentiality. We will not disclose your personal information to third parties without your consent. +- **Recognition**
+With your permission, we are happy to publicly acknowledge your contribution to improving our security once the issue is resolved. +- **Timely Resolution**
+We are committed to working closely with you throughout the resolution process, providing timely updates as necessary. Our goal is to address all reported vulnerabilities swiftly, and we will actively engage with you to coordinate a responsible disclosure once the issue is fully resolved. -1. Email your findings to security@plane.so. -2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary. -3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data. -4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved. -5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications. - -When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability. - -## Our Commitment - -We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us: - -- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date. -- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines. -- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent. -- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability. -- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability. -- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved. - -We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts. - -reference: https://supabase.com/.well-known/security.txt +We appreciate your help in ensuring the security of our platform. Your contributions are crucial to protecting our users and maintaining a secure environment. Thank you for working with us to keep Plane safe. \ No newline at end of file diff --git a/admin/.eslintrc.js b/admin/.eslintrc.js index a82c768a0a0..d8355bae80c 100644 --- a/admin/.eslintrc.js +++ b/admin/.eslintrc.js @@ -1,52 +1,8 @@ module.exports = { root: true, - extends: ["custom"], + extends: ["@plane/eslint-config/next.js"], parser: "@typescript-eslint/parser", - settings: { - "import/resolver": { - typescript: {}, - node: { - moduleDirectory: ["node_modules", "."], - }, - }, + parserOptions: { + project: true, }, - 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: "@headlessui/**", - group: "external", - position: "after", - }, - { - pattern: "@plane/**", - group: "external", - position: "after", - }, - { - pattern: "@/**", - group: "internal", - } - ], - pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], - alphabetize: { - order: "asc", - caseInsensitive: true, - }, - }, - ], - }, -} \ No newline at end of file +}; diff --git a/admin/app/general/form.tsx b/admin/app/general/form.tsx index 3101537844c..4422ee91ffe 100644 --- a/admin/app/general/form.tsx +++ b/admin/app/general/form.tsx @@ -9,8 +9,9 @@ import { IInstance, IInstanceAdmin } from "@plane/types"; import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; // components import { ControllerInput } from "@/components/common"; -// hooks import { useInstance } from "@/hooks/store"; +import { IntercomConfig } from "./intercom"; +// hooks export interface IGeneralConfigurationForm { instance: IInstance; @@ -20,11 +21,13 @@ export interface IGeneralConfigurationForm { export const GeneralConfigurationForm: FC = observer((props) => { const { instance, instanceAdmins } = props; // hooks - const { updateInstanceInfo } = useInstance(); + const { instanceConfigurations, updateInstanceInfo, updateInstanceConfigurations } = useInstance(); + // form data const { handleSubmit, control, + watch, formState: { errors, isSubmitting }, } = useForm>({ defaultValues: { @@ -36,7 +39,16 @@ export const GeneralConfigurationForm: FC = observer( const onSubmit = async (formData: Partial) => { const payload: Partial = { ...formData }; - console.log("payload", payload); + // update the intercom configuration + const isIntercomEnabled = + instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1"; + if (!payload.is_telemetry_enabled && isIntercomEnabled) { + try { + await updateInstanceConfigurations({ IS_INTERCOM_ENABLED: "0" }); + } catch (error) { + console.error(error); + } + } await updateInstanceInfo(payload) .then(() => @@ -74,6 +86,7 @@ export const GeneralConfigurationForm: FC = observer( value={instanceAdmins[0]?.user_detail?.email ?? ""} placeholder="Admin email" className="w-full cursor-not-allowed !text-custom-text-400" + autoComplete="on" disabled /> @@ -93,7 +106,8 @@ export const GeneralConfigurationForm: FC = observer(
-
Telemetry
+
Chat + telemetry
+
diff --git a/admin/app/general/intercom.tsx b/admin/app/general/intercom.tsx new file mode 100644 index 00000000000..aaeacfc0fe0 --- /dev/null +++ b/admin/app/general/intercom.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { MessageSquare } from "lucide-react"; +import { IFormattedInstanceConfiguration } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +// hooks +import { useInstance } from "@/hooks/store"; + +type TIntercomConfig = { + isTelemetryEnabled: boolean; +}; + +export const IntercomConfig: FC = observer((props) => { + const { isTelemetryEnabled } = props; + // hooks + const { instanceConfigurations, updateInstanceConfigurations, fetchInstanceConfigurations } = useInstance(); + // states + const [isSubmitting, setIsSubmitting] = useState(false); + + // derived values + const isIntercomEnabled = isTelemetryEnabled + ? instanceConfigurations + ? instanceConfigurations?.find((config) => config.key === "IS_INTERCOM_ENABLED")?.value === "1" + ? true + : false + : undefined + : false; + + const { isLoading } = useSWR(isTelemetryEnabled ? "INSTANCE_CONFIGURATIONS" : null, () => + isTelemetryEnabled ? fetchInstanceConfigurations() : null + ); + + const initialLoader = isLoading && isIntercomEnabled === undefined; + + const submitInstanceConfigurations = async (payload: Partial) => { + try { + await updateInstanceConfigurations(payload); + } catch (error) { + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + const enableIntercomConfig = () => { + submitInstanceConfigurations({ IS_INTERCOM_ENABLED: isIntercomEnabled ? "0" : "1" }); + }; + + return ( + <> +
+
+
+
+ +
+
+ +
+
Talk to Plane
+
+ Let your members chat with us via Intercom or another service. Toggling Telemetry off turns this off + automatically. +
+
+ +
+ +
+
+
+ + ); +}); diff --git a/admin/app/general/page.tsx b/admin/app/general/page.tsx index ba048f9f73c..f0d32f26187 100644 --- a/admin/app/general/page.tsx +++ b/admin/app/general/page.tsx @@ -7,7 +7,7 @@ import { GeneralConfigurationForm } from "./form"; function GeneralPage() { const { instance, instanceAdmins } = useInstance(); - console.log("instance", instance); + return ( <>
diff --git a/admin/ce/components/authentication/authentication-modes.tsx b/admin/ce/components/authentication/authentication-modes.tsx index d5037255c93..c80c4a65c22 100644 --- a/admin/ce/components/authentication/authentication-modes.tsx +++ b/admin/ce/components/authentication/authentication-modes.tsx @@ -11,8 +11,9 @@ import { import { AuthenticationMethodCard } from "@/components/authentication"; import { OpenIDConnectConfiguration } from "@/components/authentication/oidc-config"; // helpers -import { UpgradeButton } from "@/components/common/upgrade-button"; import { getBaseAuthenticationModes } from "@/helpers/authentication.helper"; +// plane admin components +import { UpgradeButton } from "@/plane-admin/components/common"; // images import OIDCLogo from "@/public/logos/oidc-logo.svg"; import SAMLLogo from "@/public/logos/saml-logo.svg"; diff --git a/admin/ce/components/common/index.ts b/admin/ce/components/common/index.ts new file mode 100644 index 00000000000..c6a1da8b627 --- /dev/null +++ b/admin/ce/components/common/index.ts @@ -0,0 +1 @@ +export * from "./upgrade-button"; diff --git a/admin/core/components/common/upgrade-button.tsx b/admin/ce/components/common/upgrade-button.tsx similarity index 100% rename from admin/core/components/common/upgrade-button.tsx rename to admin/ce/components/common/upgrade-button.tsx diff --git a/admin/ce/store/root.store.ts b/admin/ce/store/root.store.ts new file mode 100644 index 00000000000..1be816f70a6 --- /dev/null +++ b/admin/ce/store/root.store.ts @@ -0,0 +1,19 @@ +import { enableStaticRendering } from "mobx-react"; +// stores +import { CoreRootStore } from "@/store/root.store"; + +enableStaticRendering(typeof window === "undefined"); + +export class RootStore extends CoreRootStore { + constructor() { + super(); + } + + hydrate(initialData: any) { + super.hydrate(initialData); + } + + resetOnSignOut() { + super.resetOnSignOut(); + } +} diff --git a/admin/core/components/admin-sidebar/help-section.tsx b/admin/core/components/admin-sidebar/help-section.tsx index 4b516dff0bb..abba68e3eae 100644 --- a/admin/core/components/admin-sidebar/help-section.tsx +++ b/admin/core/components/admin-sidebar/help-section.tsx @@ -96,7 +96,7 @@ export const HelpSection: FC = observer(() => { leaveTo="transform opacity-0 scale-95" >
= observer(() => { +export const InstanceSidebar: FC = observer(() => { // store const { isSidebarCollapsed, toggleSidebar } = useTheme(); diff --git a/admin/core/components/authentication/auth-banner.tsx b/admin/core/components/authentication/auth-banner.tsx new file mode 100644 index 00000000000..191d7a0a772 --- /dev/null +++ b/admin/core/components/authentication/auth-banner.tsx @@ -0,0 +1,29 @@ +import { FC } from "react"; +import { Info, X } from "lucide-react"; +// helpers +import { TAuthErrorInfo } from "@/helpers/authentication.helper"; + +type TAuthBanner = { + bannerData: TAuthErrorInfo | undefined; + handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void; +}; + +export const AuthBanner: FC = (props) => { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
+
+ +
+
{bannerData?.message}
+
handleBannerData && handleBannerData(undefined)} + > + +
+
+ ); +}; diff --git a/admin/core/components/authentication/index.ts b/admin/core/components/authentication/index.ts index 2c13b772894..d189a727ba6 100644 --- a/admin/core/components/authentication/index.ts +++ b/admin/core/components/authentication/index.ts @@ -1,3 +1,4 @@ +export * from "./auth-banner"; export * from "./email-config-switch"; export * from "./password-config-switch"; export * from "./authentication-method-card"; diff --git a/admin/core/components/common/index.ts b/admin/core/components/common/index.ts index 2043926acd9..4d664b0a4aa 100644 --- a/admin/core/components/common/index.ts +++ b/admin/core/components/common/index.ts @@ -8,4 +8,3 @@ export * from "./empty-state"; export * from "./logo-spinner"; export * from "./page-header"; export * from "./code-block"; -export * from "./upgrade-button"; diff --git a/admin/core/components/instance/instance-failure-view.tsx b/admin/core/components/instance/instance-failure-view.tsx index 8722929b5d4..735a74c8dbc 100644 --- a/admin/core/components/instance/instance-failure-view.tsx +++ b/admin/core/components/instance/instance-failure-view.tsx @@ -7,11 +7,7 @@ import { Button } from "@plane/ui"; import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; import InstanceFailureImage from "@/public/instance/instance-failure.svg"; -type InstanceFailureViewProps = { - // mutate: () => void; -}; - -export const InstanceFailureView: FC = () => { +export const InstanceFailureView: FC = () => { const { resolvedTheme } = useTheme(); const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage; diff --git a/admin/core/components/instance/setup-form.tsx b/admin/core/components/instance/setup-form.tsx index ec3919896d8..7e987dbdf1a 100644 --- a/admin/core/components/instance/setup-form.tsx +++ b/admin/core/components/instance/setup-form.tsx @@ -174,6 +174,7 @@ export const InstanceSetupForm: FC = (props) => { placeholder="Wilber" value={formData.first_name} onChange={(e) => handleFormChange("first_name", e.target.value)} + autoComplete="on" autoFocus />
@@ -190,6 +191,7 @@ export const InstanceSetupForm: FC = (props) => { placeholder="Wright" value={formData.last_name} onChange={(e) => handleFormChange("last_name", e.target.value)} + autoComplete="on" />
@@ -208,6 +210,7 @@ export const InstanceSetupForm: FC = (props) => { value={formData.email} onChange={(e) => handleFormChange("email", e.target.value)} hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false} + autoComplete="on" /> {errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (

{errorData.message}

@@ -247,6 +250,7 @@ export const InstanceSetupForm: FC = (props) => { hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false} onFocus={() => setIsPasswordInputFocused(true)} onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" /> {showPassword.password ? ( -
-        
+      
+        
       
); diff --git a/packages/editor/src/core/extensions/code/lowlight-plugin.ts b/packages/editor/src/core/extensions/code/lowlight-plugin.ts index 54aa431c56a..5ac30c27ea7 100644 --- a/packages/editor/src/core/extensions/code/lowlight-plugin.ts +++ b/packages/editor/src/core/extensions/code/lowlight-plugin.ts @@ -117,14 +117,18 @@ export function LowlightPlugin({ // Such transactions can happen during collab syncing via y-prosemirror, for example. transaction.steps.some( (step) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore step.from !== undefined && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore step.to !== undefined && oldNodes.some( (node) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore node.pos >= step.from && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore node.pos + node.node.nodeSize <= step.to ) diff --git a/packages/editor/src/core/extensions/code/without-props.tsx b/packages/editor/src/core/extensions/code/without-props.tsx new file mode 100644 index 00000000000..c68c79382ab --- /dev/null +++ b/packages/editor/src/core/extensions/code/without-props.tsx @@ -0,0 +1,115 @@ +import { Selection } from "@tiptap/pm/state"; +import ts from "highlight.js/lib/languages/typescript"; +import { common, createLowlight } from "lowlight"; +// components +import { CodeBlockLowlight } from "./code-block-lowlight"; + +const lowlight = createLowlight(common); +lowlight.register("ts", ts); + +export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.extend({ + addKeyboardShortcuts() { + return { + Tab: ({ editor }) => { + try { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + // Use ProseMirror's insertText transaction to insert the tab character + const tr = state.tr.insertText("\t", $from.pos, $from.pos); + editor.view.dispatch(tr); + + return true; + } catch (error) { + console.error("Error handling Tab in CustomCodeBlockExtension:", error); + return false; + } + }, + ArrowUp: ({ editor }) => { + try { + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtStart = $from.parentOffset === 0; + + if (!isAtStart) { + return false; + } + + // Check if codeBlock is the first node + const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0; + + if (isFirstNode) { + // Insert a new paragraph at the start of the document and move the cursor to it + return editor.commands.command(({ tr }) => { + const node = editor.schema.nodes.paragraph.create(); + tr.insert(0, node); + tr.setSelection(Selection.near(tr.doc.resolve(1))); + return true; + }); + } + + return false; + } catch (error) { + console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error); + return false; + } + }, + ArrowDown: ({ editor }) => { + try { + if (!this.options.exitOnArrowDown) { + return false; + } + + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + + if (!isAtEnd) { + return false; + } + + const after = $from.after(); + + if (after === undefined) { + return false; + } + + const nodeAfter = doc.nodeAt(after); + + if (nodeAfter) { + return editor.commands.command(({ tr }) => { + tr.setSelection(Selection.near(doc.resolve(after))); + return true; + }); + } + + return editor.commands.exitCode(); + } catch (error) { + console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error); + return false; + } + }, + }; + }, +}).configure({ + lowlight, + defaultLanguage: "plaintext", + exitOnTripleEnter: false, +}); diff --git a/packages/editor/src/core/extensions/core-without-props.tsx b/packages/editor/src/core/extensions/core-without-props.ts similarity index 66% rename from packages/editor/src/core/extensions/core-without-props.tsx rename to packages/editor/src/core/extensions/core-without-props.ts index 101511ce0c8..1cedd513966 100644 --- a/packages/editor/src/core/extensions/core-without-props.tsx +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -3,28 +3,21 @@ 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"; // extensions -import { - CustomCodeBlockExtension, - CustomCodeInlineExtension, - CustomCodeMarkPlugin, - CustomHorizontalRule, - CustomKeymap, - CustomLinkExtension, - CustomMentionWithoutProps, - CustomQuoteExtension, - CustomTypographyExtension, - ImageExtensionWithoutProps, - Table, - TableCell, - TableHeader, - TableRow, -} from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; +import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props"; +import { CustomCodeInlineExtension } from "./code-inline"; +import { CustomLinkExtension } from "./custom-link"; +import { CustomHorizontalRule } from "./horizontal-rule"; +import { ImageExtensionWithoutProps } from "./image"; +import { CustomImageComponentWithoutProps } from "./image/image-component-without-props"; +import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props"; +import { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; +import { CustomQuoteExtension } from "./quote"; +import { TableHeader, TableCell, TableRow, Table } from "./table"; -export const CoreEditorExtensionsWithoutProps = () => [ +export const CoreEditorExtensionsWithoutProps = [ StarterKit.configure({ bulletList: { HTMLAttributes: { @@ -53,7 +46,6 @@ export const CoreEditorExtensionsWithoutProps = () => [ class: "my-4 border-custom-border-400", }, }), - CustomKeymap, CustomLinkExtension.configure({ openOnClick: true, autolink: true, @@ -65,12 +57,12 @@ export const CoreEditorExtensionsWithoutProps = () => [ "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), - CustomTypographyExtension, ImageExtensionWithoutProps().configure({ HTMLAttributes: { class: "rounded-md", }, }), + CustomImageComponentWithoutProps(), TiptapUnderline, TextStyle, TaskList.configure({ @@ -84,20 +76,13 @@ export const CoreEditorExtensionsWithoutProps = () => [ }, nested: true, }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeMarkPlugin, CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformPastedText: true, - }), + CustomCodeBlockExtensionWithoutProps, Table, TableHeader, TableCell, TableRow, CustomMentionWithoutProps(), ]; + +export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx new file mode 100644 index 00000000000..ed60f3dab08 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -0,0 +1,289 @@ +import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react"; +import { NodeSelection } from "@tiptap/pm/state"; +// extensions +import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image"; +// helpers +import { cn } from "@/helpers/common"; + +const MIN_SIZE = 100; + +type Pixel = `${number}px`; + +type PixelAttribute = Pixel | TDefault; + +export type ImageAttributes = { + src: string | null; + width: PixelAttribute<"35%" | number>; + height: PixelAttribute<"auto" | number>; + aspectRatio: number | null; + id: string | null; +}; + +type Size = { + width: PixelAttribute<"35%">; + height: PixelAttribute<"auto">; + aspectRatio: number | null; +}; + +const ensurePixelString = (value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => { + if (!value || value === defaultValue) { + return defaultValue; + } + + if (typeof value === "number") { + return `${value}px` satisfies Pixel; + } + + return value; +}; + +type CustomImageBlockProps = CustomImageNodeViewProps & { + imageFromFileSystem: string; + setFailedToLoadImage: (isError: boolean) => void; + editorContainer: HTMLDivElement | null; + setEditorContainer: (editorContainer: HTMLDivElement | null) => void; +}; + +export const CustomImageBlock: React.FC = (props) => { + // props + const { + node, + updateAttributes, + setFailedToLoadImage, + imageFromFileSystem, + selected, + getPos, + editor, + editorContainer, + setEditorContainer, + } = props; + const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs; + // states + const [size, setSize] = useState({ + width: ensurePixelString(width, "35%"), + height: ensurePixelString(height, "auto"), + aspectRatio: aspectRatio || 1, + }); + const [isResizing, setIsResizing] = useState(false); + const [initialResizeComplete, setInitialResizeComplete] = useState(false); + // refs + const containerRef = useRef(null); + const containerRect = useRef(null); + const imageRef = useRef(null); + + const updateAttributesSafely = useCallback( + (attributes: Partial, errorMessage: string) => { + try { + updateAttributes(attributes); + } catch (error) { + console.error(`${errorMessage}:`, error); + } + }, + [updateAttributes] + ); + + const handleImageLoad = useCallback(() => { + const img = imageRef.current; + if (!img) return; + let closestEditorContainer: HTMLDivElement | null = null; + + if (editorContainer) { + closestEditorContainer = editorContainer; + } else { + closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null; + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; + } + } + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; + } + + setEditorContainer(closestEditorContainer); + const aspectRatio = img.naturalWidth / img.naturalHeight; + + if (width === "35%") { + const editorWidth = closestEditorContainer.clientWidth; + const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE); + const initialHeight = initialWidth / aspectRatio; + + const initialComputedSize = { + width: `${Math.round(initialWidth)}px` satisfies Pixel, + height: `${Math.round(initialHeight)}px` satisfies Pixel, + aspectRatio: aspectRatio, + }; + + setSize(initialComputedSize); + updateAttributesSafely( + initialComputedSize, + "Failed to update attributes while initializing an image for the first time:" + ); + } else { + // as the aspect ratio in not stored for old images, we need to update the attrs + if (!aspectRatio) { + setSize((prevSize) => { + const newSize = { ...prevSize, aspectRatio }; + updateAttributesSafely( + newSize, + "Failed to update attributes while initializing images with width but no aspect ratio:" + ); + return newSize; + }); + } + } + setInitialResizeComplete(true); + }, [width, updateAttributes, editorContainer, aspectRatio]); + + // for real time resizing + useLayoutEffect(() => { + setSize((prevSize) => ({ + ...prevSize, + width: ensurePixelString(width), + height: ensurePixelString(height), + })); + }, [width, height]); + + const handleResize = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!containerRef.current || !containerRect.current || !size.aspectRatio) return; + + const clientX = "touches" in e ? e.touches[0].clientX : e.clientX; + + const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE); + const newHeight = newWidth / size.aspectRatio; + + setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` })); + }, + [size] + ); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + updateAttributesSafely(size, "Failed to update attributes at the end of resizing:"); + }, [size, updateAttributes]); + + const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + + if (containerRef.current) { + containerRect.current = containerRef.current.getBoundingClientRect(); + } + }, []); + + useEffect(() => { + if (isResizing) { + window.addEventListener("mousemove", handleResize); + window.addEventListener("mouseup", handleResizeEnd); + window.addEventListener("mouseleave", handleResizeEnd); + + return () => { + window.removeEventListener("mousemove", handleResize); + window.removeEventListener("mouseup", handleResizeEnd); + window.removeEventListener("mouseleave", handleResizeEnd); + }; + } + }, [isResizing, handleResize, handleResizeEnd]); + + const handleImageMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + const pos = getPos(); + const nodeSelection = NodeSelection.create(editor.state.doc, pos); + editor.view.dispatch(editor.state.tr.setSelection(nodeSelection)); + }, + [editor, getPos] + ); + + // show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or) + // if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete + const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete; + // show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageUtils = remoteImageSrc && initialResizeComplete; + // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) + const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete; + // show the preview image from the file system if the remote image's src is not set + const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem; + + return ( +
+ {showImageLoader && ( +
+ )} + { + console.error("Error loading image", e); + setFailedToLoadImage(true); + }} + width={size.width} + className={cn("image-component block rounded-md", { + // hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then + hidden: showImageLoader, + "read-only-image": !editor.isEditable, + "blur-sm opacity-80 loading-image": !remoteImageSrc, + })} + style={{ + width: size.width, + aspectRatio: size.aspectRatio, + }} + /> + {showImageUtils && ( + + )} + {selected && displayedImageSrc === remoteImageSrc && ( +
+ )} + {showImageResizer && ( + <> +
+
+ + )} +
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-node.tsx b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx new file mode 100644 index 00000000000..c37bcd29cde --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/image-node.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; +import { Node as ProsemirrorNode } from "@tiptap/pm/model"; +import { Editor, NodeViewWrapper } from "@tiptap/react"; +// extensions +import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image"; + +export type CustomImageNodeViewProps = { + getPos: () => number; + editor: Editor; + node: ProsemirrorNode & { + attrs: ImageAttributes; + }; + updateAttributes: (attrs: Record) => void; + selected: boolean; +}; + +export const CustomImageNode = (props: CustomImageNodeViewProps) => { + const { getPos, editor, node, updateAttributes, selected } = props; + + const [isUploaded, setIsUploaded] = useState(false); + const [imageFromFileSystem, setImageFromFileSystem] = useState(undefined); + const [failedToLoadImage, setFailedToLoadImage] = useState(false); + + const [editorContainer, setEditorContainer] = useState(null); + const imageComponentRef = useRef(null); + + useEffect(() => { + const closestEditorContainer = imageComponentRef.current?.closest(".editor-container"); + if (!closestEditorContainer) { + console.error("Editor container not found"); + return; + } + + setEditorContainer(closestEditorContainer as HTMLDivElement); + }, []); + + // the image is already uploaded if the image-component node has src attribute + // and we need to remove the blob from our file system + useEffect(() => { + const remoteImageSrc = node.attrs.src; + if (remoteImageSrc) { + setIsUploaded(true); + setImageFromFileSystem(undefined); + } else { + setIsUploaded(false); + } + }, [node.attrs.src]); + + return ( + +
+ {(isUploaded || imageFromFileSystem) && !failedToLoadImage ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx new file mode 100644 index 00000000000..b5c52db66c3 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -0,0 +1,167 @@ +import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react"; +import { Node as ProsemirrorNode } from "@tiptap/pm/model"; +import { Editor } from "@tiptap/core"; +import { ImageIcon } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common"; +// hooks +import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload"; +// extensions +import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; + +export const CustomImageUploader = (props: { + failedToLoadImage: boolean; + editor: Editor; + selected: boolean; + loadImageFromFileSystem: (file: string) => void; + setIsUploaded: (isUploaded: boolean) => void; + node: ProsemirrorNode & { + attrs: ImageAttributes; + }; + updateAttributes: (attrs: Record) => void; + getPos: () => number; +}) => { + const { + selected, + failedToLoadImage, + editor, + loadImageFromFileSystem, + node, + setIsUploaded, + updateAttributes, + getPos, + } = props; + // ref + const fileInputRef = useRef(null); + + const hasTriggeredFilePickerRef = useRef(false); + const imageEntityId = node.attrs.id; + + const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]); + + const onUpload = useCallback( + (url: string) => { + if (url) { + setIsUploaded(true); + // Update the node view's src attribute post upload + updateAttributes({ src: url }); + imageComponentImageFileMap?.delete(imageEntityId); + + const pos = getPos(); + // get current node + const getCurrentSelection = editor.state.selection; + const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from); + + // only if the cursor is at the current image component, manipulate + // the cursor position + if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) { + // control cursor position after upload + const nextNode = editor.state.doc.nodeAt(pos + 1); + + if (nextNode && nextNode.type.name === "paragraph") { + // If there is a paragraph node after the image component, move the focus to the next node + editor.commands.setTextSelection(pos + 1); + } else { + // create a new paragraph after the image component post upload + editor.commands.createParagraphNear(); + } + } + } + }, + [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] + ); + // hooks + const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + uploader: uploadFile, + editor, + pos: getPos(), + }); + + // the meta data of the image component + const meta = useMemo( + () => imageComponentImageFileMap?.get(imageEntityId), + [imageComponentImageFileMap, imageEntityId] + ); + + // after the image component is mounted we start the upload process based on + // it's uploaded + useEffect(() => { + if (meta) { + if (meta.event === "drop" && "file" in meta) { + uploadFile(meta.file); + } else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) { + if (meta.hasOpenedFileInputOnce) return; + fileInputRef.current.click(); + hasTriggeredFilePickerRef.current = true; + imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true }); + } + } + }, [meta, uploadFile, imageComponentImageFileMap]); + + const onFileChange = useCallback( + async (e: ChangeEvent) => { + e.preventDefault(); + const fileList = e.target.files; + if (!fileList) { + return; + } + await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile); + }, + [uploadFile, editor, getPos] + ); + + const getDisplayMessage = useCallback(() => { + const isUploading = isImageBeingUploaded; + if (failedToLoadImage) { + return "Error loading image"; + } + + if (isUploading) { + return "Uploading..."; + } + + if (draggedInside) { + return "Drop image here"; + } + + return "Add an image"; + }, [draggedInside, failedToLoadImage, isImageBeingUploaded]); + + return ( +
{ + if (!failedToLoadImage && editor.isEditable) { + fileInputRef.current?.click(); + } + }} + > + +
{getDisplayMessage()}
+ +
+ ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/index.ts b/packages/editor/src/core/extensions/custom-image/components/index.ts new file mode 100644 index 00000000000..9d12c3ecf19 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/index.ts @@ -0,0 +1,4 @@ +export * from "./toolbar"; +export * from "./image-block"; +export * from "./image-node"; +export * from "./image-uploader"; diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx new file mode 100644 index 00000000000..38ea23c9925 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/full-screen.tsx @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common"; + +type Props = { + image: { + src: string; + height: string; + width: string; + aspectRatio: number; + }; + isOpen: boolean; + toggleFullScreenMode: (val: boolean) => void; +}; + +const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2]; + +export const ImageFullScreenAction: React.FC = (props) => { + const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props; + const { src, width, aspectRatio } = image; + // states + const [magnification, setMagnification] = useState(1); + // refs + const modalRef = useRef(null); + // derived values + const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]); + // close handler + const handleClose = useCallback(() => { + toggleFullScreenMode(false); + setTimeout(() => { + setMagnification(1); + }, 200); + }, [toggleFullScreenMode]); + // download handler + const handleOpenInNewTab = () => { + const link = document.createElement("a"); + link.href = src; + link.target = "_blank"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + // magnification decrease handler + const handleDecreaseMagnification = useCallback(() => { + const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification); + if (currentIndex === 0) return; + setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]); + }, [magnification]); + // magnification increase handler + const handleIncreaseMagnification = useCallback(() => { + const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification); + if (currentIndex === MAGNIFICATION_VALUES.length - 1) return; + setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]); + }, [magnification]); + // keydown handler + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") handleClose(); + if (e.key === "+" || e.key === "=") handleIncreaseMagnification(); + if (e.key === "-") handleDecreaseMagnification(); + } + }, + [handleClose, handleDecreaseMagnification, handleIncreaseMagnification] + ); + // click outside handler + const handleClickOutside = useCallback( + (e: React.MouseEvent) => { + if (modalRef.current && e.target === modalRef.current) { + handleClose(); + } + }, + [handleClose] + ); + // register keydown listener + useEffect(() => { + if (isFullScreenEnabled) { + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + } + }, [handleKeyDown, isFullScreenEnabled]); + + return ( + <> +
+
+ + +
+
+
+ + {(100 * magnification).toFixed(0)}% + +
+ +
+
+ + + ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/index.ts b/packages/editor/src/core/extensions/custom-image/components/toolbar/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx b/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx new file mode 100644 index 00000000000..d42d875cc93 --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/components/toolbar/root.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +// helpers +import { cn } from "@/helpers/common"; +// components +import { ImageFullScreenAction } from "./full-screen"; + +type Props = { + containerClassName?: string; + image: { + src: string; + height: string; + width: string; + aspectRatio: number; + }; +}; + +export const ImageToolbarRoot: React.FC = (props) => { + const { containerClassName, image } = props; + // state + const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false); + + return ( + <> +
+ setIsFullScreenEnabled(val)} + /> +
+ + ); +}; diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts new file mode 100644 index 00000000000..939d97668fe --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -0,0 +1,176 @@ +import { Editor, mergeAttributes } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// extensions +import { CustomImageNode } from "@/extensions/custom-image"; +// plugins +import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image"; +// types +import { TFileHandler } from "@/types"; +// helpers +import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; + +export type InsertImageComponentProps = { + file?: File; + pos?: number; + event: "insert" | "drop"; +}; + +declare module "@tiptap/core" { + interface Commands { + imageComponent: { + insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; + uploadImage: (file: File) => () => Promise | undefined; + }; + } +} + +export const getImageComponentImageFileMap = (editor: Editor) => + (editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap; + +export interface UploadImageExtensionStorage { + fileMap: Map; +} + +export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; + +export const CustomImageExtension = (props: TFileHandler) => { + const { upload, delete: deleteImage, restore: restoreImage } = props; + + return Image.extend, UploadImageExtensionStorage>({ + name: "imageComponent", + selectable: true, + group: "block", + atom: true, + draggable: true, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + src: { + default: null, + }, + height: { + default: "auto", + }, + ["id"]: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + + onCreate(this) { + const imageSources = new Set(); + this.editor.state.doc.descendants((node) => { + if (node.type.name === this.name) { + imageSources.add(node.attrs.src); + } + }); + imageSources.forEach(async (src) => { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + } + }); + }, + + addKeyboardShortcuts() { + return { + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), + }; + }, + + addProseMirrorPlugins() { + return [ + TrackImageDeletionPlugin(this.editor, deleteImage, this.name), + TrackImageRestorationPlugin(this.editor, restoreImage, this.name), + ]; + }, + + addStorage() { + return { + fileMap: new Map(), + deletedImageSet: new Map(), + uploadInProgress: false, + }; + }, + + addCommands() { + return { + insertImageComponent: + (props: { file?: File; pos?: number; event: "insert" | "drop" }) => + ({ commands }) => { + // Early return if there's an invalid file being dropped + if (props?.file && !isFileValid(props.file)) { + return false; + } + + // generate a unique id for the image to keep track of dropped + // files' file data + const fileId = uuidv4(); + + const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor); + + if (imageComponentImageFileMap) { + if (props?.event === "drop" && props.file) { + imageComponentImageFileMap.set(fileId, { + file: props.file, + event: props.event, + }); + } else if (props.event === "insert") { + imageComponentImageFileMap.set(fileId, { + event: props.event, + hasOpenedFileInputOnce: false, + }); + } + } + + const attributes = { + id: fileId, + }; + + if (props.pos) { + return commands.insertContentAt(props.pos, { + type: this.name, + attrs: attributes, + }); + } + return commands.insertContent({ + type: this.name, + attrs: attributes, + }); + }, + uploadImage: (file: File) => async () => { + const fileUrl = await upload(file); + return fileUrl; + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/custom-image/index.ts b/packages/editor/src/core/extensions/custom-image/index.ts new file mode 100644 index 00000000000..de2bb38789d --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/index.ts @@ -0,0 +1,3 @@ +export * from "./components"; +export * from "./custom-image"; +export * from "./read-only-custom-image"; diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts new file mode 100644 index 00000000000..f7db8d6b0ca --- /dev/null +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -0,0 +1,57 @@ +import { mergeAttributes } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// components +import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; + +export const CustomReadOnlyImageExtension = () => + Image.extend, UploadImageExtensionStorage>({ + name: "imageComponent", + selectable: false, + group: "block", + atom: true, + draggable: false, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + src: { + default: null, + }, + height: { + default: "auto", + }, + ["id"]: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + + addStorage() { + return { + fileMap: new Map(), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); diff --git a/packages/editor/src/core/extensions/custom-link/extension.tsx b/packages/editor/src/core/extensions/custom-link/extension.tsx index e74916a8fcd..ee065f512b9 100644 --- a/packages/editor/src/core/extensions/custom-link/extension.tsx +++ b/packages/editor/src/core/extensions/custom-link/extension.tsx @@ -136,7 +136,7 @@ export const CustomLinkExtension = Mark.create({ { tag: "a[href]", getAttrs: (node) => { - if (typeof node === "string" || !(node instanceof HTMLElement)) { + if (typeof node === "string") { return null; } const href = node.getAttribute("href")?.toLowerCase() || ""; diff --git a/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts index ec6c540dacc..1b084d1ac52 100644 --- a/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts +++ b/packages/editor/src/core/extensions/custom-link/helpers/clickHandler.ts @@ -18,9 +18,9 @@ export function clickHandler(options: ClickHandlerOptions): Plugin { let a = event.target as HTMLElement; const els = []; - while (a.nodeName !== "DIV") { + while (a?.nodeName !== "DIV") { els.push(a); - a = a.parentNode as HTMLElement; + a = a?.parentNode as HTMLElement; } if (!els.find((value) => value.nodeName === "A")) { diff --git a/packages/editor/src/core/extensions/document-without-props.tsx b/packages/editor/src/core/extensions/document-without-props.tsx deleted file mode 100644 index 2202510ecff..00000000000 --- a/packages/editor/src/core/extensions/document-without-props.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { IssueWidgetWithoutProps } from "@/extensions/issue-embed"; - -export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/drag-drop.tsx b/packages/editor/src/core/extensions/drag-drop.tsx deleted file mode 100644 index 06a74427feb..00000000000 --- a/packages/editor/src/core/extensions/drag-drop.tsx +++ /dev/null @@ -1,414 +0,0 @@ -import { Extension } from "@tiptap/core"; -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"; - -export interface DragHandleOptions { - dragHandleWidth: number; - setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; - scrollThreshold: { - up: number; - down: number; - }; -} - -export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) => - Extension.create({ - name: "dragAndDrop", - - addProseMirrorPlugins() { - return [ - DragHandle({ - dragHandleWidth: 24, - scrollThreshold: { up: 300, down: 100 }, - setHideDragHandle, - }), - ]; - }, - }); - -function createDragHandleElement(): HTMLElement { - const dragHandleElement = document.createElement("div"); - dragHandleElement.draggable = true; - dragHandleElement.dataset.dragHandle = ""; - dragHandleElement.classList.add("drag-handle"); - - const dragHandleContainer = document.createElement("div"); - dragHandleContainer.classList.add("drag-handle-container"); - dragHandleElement.appendChild(dragHandleContainer); - - const dotsContainer = document.createElement("div"); - dotsContainer.classList.add("drag-handle-dots"); - - for (let i = 0; i < 6; i++) { - const spanElement = document.createElement("span"); - spanElement.classList.add("drag-handle-dot"); - dotsContainer.appendChild(spanElement); - } - - dragHandleContainer.appendChild(dotsContainer); - - return dragHandleElement; -} - -function absoluteRect(node: Element) { - const data = node.getBoundingClientRect(); - - return { - top: data.top, - left: data.left, - width: data.width, - }; -} - -function nodeDOMAtCoords(coords: { x: number; y: number }) { - const elements = document.elementsFromPoint(coords.x, coords.y); - const generalSelectors = [ - "li", - "p:not(:first-child)", - ".code-block", - "blockquote", - "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 in td or th - } - - // apply general selector - if (elem.matches(generalSelectors)) { - return elem; - } - } - return null; -} - -function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) { - const boundingRect = node.getBoundingClientRect(); - - return view.posAtCoords({ - left: boundingRect.left + 50 + options.dragHandleWidth, - top: boundingRect.top + 1, - })?.inside; -} - -function nodePosAtDOMForBlockquotes(node: Element, view: EditorView) { - const boundingRect = node.getBoundingClientRect(); - - return view.posAtCoords({ - left: boundingRect.left + 1, - top: boundingRect.top + 1, - })?.inside; -} - -function calcNodePos(pos: number, view: EditorView, node: Element) { - const maxPos = view.state.doc.content.size; - const safePos = Math.max(0, Math.min(pos, maxPos)); - const $pos = view.state.doc.resolve(safePos); - - if ($pos.depth > 1) { - 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)); - } - } - - return safePos; -} - -function DragHandle(options: DragHandleOptions) { - let listType = ""; - function handleDragStart(event: DragEvent, view: EditorView) { - view.focus(); - - if (!event.dataTransfer) return; - - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); - - if (!(node instanceof Element)) return; - - let draggedNodePos = nodePosAtDOM(node, view, options); - if (draggedNodePos == null || draggedNodePos < 0) return; - draggedNodePos = calcNodePos(draggedNodePos, view, node); - - const { from, to } = view.state.selection; - const diff = from - to; - - const fromSelectionPos = calcNodePos(from, view, node); - let differentNodeSelected = false; - - const nodePos = view.state.doc.resolve(fromSelectionPos); - - // Check if nodePos points to the top level node - if (nodePos.node().type.name === "doc") differentNodeSelected = true; - else { - const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before()); - // Check if the node where the drag event started is part of the current selection - differentNodeSelected = !( - draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos - ); - } - - if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) { - const endSelection = NodeSelection.create(view.state.doc, to - 1); - const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos); - view.dispatch(view.state.tr.setSelection(multiNodeSelection)); - } else { - const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - - // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL - if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { - listType = node.parentElement!.tagName; - } - - if (node.matches("blockquote")) { - let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view); - if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return; - - const docSize = view.state.doc.content.size; - nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize)); - - if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) { - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - } - - const slice = view.state.selection.content(); - const { dom, text } = __serializeForClipboard(view, slice); - - event.dataTransfer.clearData(); - event.dataTransfer.setData("text/html", dom.innerHTML); - event.dataTransfer.setData("text/plain", text); - event.dataTransfer.effectAllowed = "copyMove"; - - event.dataTransfer.setDragImage(node, 0, 0); - - view.dragging = { slice, move: event.ctrlKey }; - } - - function handleClick(event: MouseEvent, view: EditorView) { - view.focus(); - - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); - - if (!(node instanceof Element)) return; - - if (node.matches("blockquote")) { - let nodePosForBlockquotes = nodePosAtDOMForBlockquotes(node, view); - if (nodePosForBlockquotes === null || nodePosForBlockquotes === undefined) return; - - const docSize = view.state.doc.content.size; - nodePosForBlockquotes = Math.max(0, Math.min(nodePosForBlockquotes, docSize)); - - if (nodePosForBlockquotes >= 0 && nodePosForBlockquotes <= docSize) { - const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockquotes); - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - return; - } - - let nodePos = nodePosAtDOM(node, view, options); - - if (nodePos === null || nodePos === undefined) return; - - // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied - nodePos = calcNodePos(nodePos, view, node); - - // Use NodeSelection to select the node at the calculated position - const nodeSelection = NodeSelection.create(view.state.doc, nodePos); - - // Dispatch the transaction to update the selection - view.dispatch(view.state.tr.setSelection(nodeSelection)); - } - - let dragHandleElement: HTMLElement | null = null; - - function hideDragHandle() { - if (dragHandleElement) { - dragHandleElement.classList.add("hidden"); - } - } - - function showDragHandle() { - if (dragHandleElement) { - dragHandleElement.classList.remove("hidden"); - } - } - - options.setHideDragHandle?.(hideDragHandle); - - return new Plugin({ - key: new PluginKey("dragHandle"), - view: (view) => { - dragHandleElement = createDragHandleElement(); - dragHandleElement.addEventListener("dragstart", (e) => { - handleDragStart(e, view); - }); - dragHandleElement.addEventListener("click", (e) => { - handleClick(e, view); - }); - dragHandleElement.addEventListener("contextmenu", (e) => { - handleClick(e, view); - }); - - dragHandleElement.addEventListener("drag", (e) => { - hideDragHandle(); - const a = document.querySelector(".frame-renderer"); - if (!a) return; - if (e.clientY < options.scrollThreshold.up) { - a.scrollBy({ top: -70, behavior: "smooth" }); - } else if (window.innerHeight - e.clientY < options.scrollThreshold.down) { - a.scrollBy({ top: 70, behavior: "smooth" }); - } - }); - - hideDragHandle(); - - view?.dom?.parentElement?.appendChild(dragHandleElement); - - return { - destroy: () => { - dragHandleElement?.remove?.(); - dragHandleElement = null; - }, - }; - }, - props: { - handleDOMEvents: { - mousemove: (view, event) => { - if (!view.editable) { - return; - } - - const node = nodeDOMAtCoords({ - x: event.clientX + 50 + options.dragHandleWidth, - y: event.clientY, - }); - - if (!(node instanceof Element) || node.matches("ul, ol")) { - hideDragHandle(); - return; - } - - const compStyle = window.getComputedStyle(node); - const lineHeight = parseInt(compStyle.lineHeight, 10); - const paddingTop = parseInt(compStyle.paddingTop, 10); - - const rect = absoluteRect(node); - - rect.top += (lineHeight - 20) / 2; - rect.top += paddingTop; - - 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; - - if (!dragHandleElement) return; - - dragHandleElement.style.left = `${rect.left - rect.width}px`; - dragHandleElement.style.top = `${rect.top}px`; - showDragHandle(); - }, - keydown: () => { - hideDragHandle(); - }, - mousewheel: () => { - hideDragHandle(); - }, - dragenter: (view) => { - view.dom.classList.add("dragging"); - hideDragHandle(); - }, - drop: (view, event) => { - view.dom.classList.remove("dragging"); - hideDragHandle(); - let droppedNode: Node | null = null; - const dropPos = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - - if (!dropPos) return; - - if (view.state.selection instanceof NodeSelection) { - droppedNode = view.state.selection.node; - } - - if (!droppedNode) return; - - const resolvedPos = view.state.doc.resolve(dropPos.pos); - let isDroppedInsideList = false; - - // Traverse up the document tree to find if we're inside a list item - for (let i = resolvedPos.depth; i > 0; i--) { - if (resolvedPos.node(i).type.name === "listItem") { - isDroppedInsideList = true; - break; - } - } - - // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside

    tag otherwise ol list items will be transformed into ul list item when dropped - if ( - view.state.selection instanceof NodeSelection && - view.state.selection.node.type.name === "listItem" && - !isDroppedInsideList && - listType == "OL" - ) { - const text = droppedNode.textContent; - if (!text) return; - const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text)); - const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph); - - const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); - const slice = new Slice(Fragment.from(newList), 0, 0); - view.dragging = { slice, move: event.ctrlKey }; - } - }, - dragend: (view) => { - view.dom.classList.remove("dragging"); - }, - }, - }, - }); -} diff --git a/packages/editor/src/core/extensions/drop.tsx b/packages/editor/src/core/extensions/drop.tsx index d56f802d97a..2044f03bf5d 100644 --- a/packages/editor/src/core/extensions/drop.tsx +++ b/packages/editor/src/core/extensions/drop.tsx @@ -1,42 +1,50 @@ -import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "prosemirror-state"; -// plugins -import { startImageUpload } from "@/plugins/image"; -// types -import { UploadImage } from "@/types"; - -export const DropHandlerExtension = (uploadFile: UploadImage) => +import { Extension, Editor } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; + +export const DropHandlerExtension = () => Extension.create({ name: "dropHandler", priority: 1000, addProseMirrorPlugins() { + const editor = this.editor; return [ new Plugin({ key: new PluginKey("drop-handler-plugin"), props: { - handlePaste: (view, event) => { - if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) { + handlePaste: (view: EditorView, event: ClipboardEvent) => { + if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) { event.preventDefault(); - const file = event.clipboardData.files[0]; - const pos = view.state.selection.from; - startImageUpload(this.editor, file, view, pos, uploadFile); + const files = Array.from(event.clipboardData.files); + const imageFiles = files.filter((file) => file.type.startsWith("image")); + + if (imageFiles.length > 0) { + const pos = view.state.selection.from; + insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); + } return true; } return false; }, - handleDrop: (view, event, _slice, moved) => { - if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { + handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => { + if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { event.preventDefault(); - const file = event.dataTransfer.files[0]; - const coordinates = view.posAtCoords({ - left: event.clientX, - top: event.clientY, - }); - if (coordinates) { - startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile); + const files = Array.from(event.dataTransfer.files); + const imageFiles = files.filter((file) => file.type.startsWith("image")); + + if (imageFiles.length > 0) { + const coordinates = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (coordinates) { + const pos = coordinates.pos; + insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" }); + } + return true; } - return true; } return false; }, @@ -45,3 +53,33 @@ export const DropHandlerExtension = (uploadFile: UploadImage) => ]; }, }); + +export const insertImagesSafely = async ({ + editor, + files, + initialPos, + event, +}: { + editor: Editor; + files: File[]; + initialPos: number; + event: "insert" | "drop"; +}) => { + let pos = initialPos; + + for (const file of files) { + // safe insertion + const docSize = editor.state.doc.content.size; + pos = Math.min(pos, docSize); + + try { + // Insert the image at the current position + editor.commands.insertImageComponent({ file, pos, event }); + } catch (error) { + console.error(`Error while ${event}ing image:`, error); + } + + // Move to the next position + pos += 1; + } +}; diff --git a/packages/editor/src/core/extensions/enter-key-extension.tsx b/packages/editor/src/core/extensions/enter-key-extension.tsx index a01b58e59ba..d67ceb78b8e 100644 --- a/packages/editor/src/core/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?: (descriptionHTML: string) => void) => +export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ name: "enterKey", @@ -8,7 +8,9 @@ export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => return { Enter: () => { if (!this.editor.storage.mentionsOpen) { - onEnterKeyPress?.(this.editor.getHTML()); + if (onEnterKeyPress) { + onEnterKeyPress(); + } return true; } return false; @@ -17,6 +19,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: (descriptionHTML: string) => editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), () => commands.splitListItem("listItem"), + () => commands.splitListItem("taskItem"), () => commands.createParagraphNear(), () => commands.liftEmptyBlock(), () => commands.splitBlock(), diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index d1dfb4370a5..c6d29b31b27 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -1,3 +1,4 @@ +import CharacterCount from "@tiptap/extension-character-count"; import Placeholder from "@tiptap/extension-placeholder"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; @@ -11,6 +12,7 @@ import { CustomCodeInlineExtension, CustomCodeMarkPlugin, CustomHorizontalRule, + CustomImageExtension, CustomKeymap, CustomLinkExtension, CustomMention, @@ -30,23 +32,25 @@ import { isValidHttpUrl } from "@/helpers/common"; import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; type TArguments = { - mentionConfig: { - mentionSuggestions?: () => Promise; - mentionHighlights?: () => Promise; - }; + enableHistory: boolean; fileConfig: { deleteFile: DeleteImage; restoreFile: RestoreImage; cancelUploadImage?: () => void; uploadFile: UploadImage; }; + mentionConfig: { + mentionSuggestions?: () => Promise; + mentionHighlights?: () => Promise; + }; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; export const CoreEditorExtensions = ({ - mentionConfig, + enableHistory, fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile }, + mentionConfig, placeholder, tabIndex, }: TArguments) => [ @@ -70,14 +74,13 @@ export const CoreEditorExtensions = ({ codeBlock: false, horizontalRule: false, blockquote: false, - history: false, dropcursor: { - color: "rgba(var(--color-text-100))", - width: 1, + class: "text-custom-text-300", }, + ...(enableHistory ? {} : { history: false }), }), CustomQuoteExtension, - DropHandlerExtension(uploadFile), + DropHandlerExtension(), CustomHorizontalRule.configure({ HTMLAttributes: { class: "my-4 border-custom-border-400", @@ -102,6 +105,12 @@ export const CoreEditorExtensions = ({ class: "rounded-md", }, }), + CustomImageExtension({ + delete: deleteFile, + restore: restoreFile, + upload: uploadFile, + cancel: cancelUploadImage ?? (() => {}), + }), TiptapUnderline, TextStyle, TaskList.configure({ @@ -140,7 +149,7 @@ export const CoreEditorExtensions = ({ placeholder: ({ editor, node }) => { if (node.type.name === "heading") return `Heading ${node.attrs.level}`; - if (editor.storage.image.uploadInProgress) return ""; + if (editor.storage.imageComponent.uploadInProgress) return ""; const shouldHidePlaceholder = editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); @@ -156,4 +165,5 @@ export const CoreEditorExtensions = ({ }, includeChildren: true, }), + CharacterCount, ]; diff --git a/packages/editor/src/core/extensions/headers.ts b/packages/editor/src/core/extensions/headers.ts new file mode 100644 index 00000000000..3960d5f039c --- /dev/null +++ b/packages/editor/src/core/extensions/headers.ts @@ -0,0 +1,57 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +export interface IMarking { + type: "heading"; + level: number; + text: string; + sequence: number; +} + +export const HeadingListExtension = Extension.create({ + name: "headingList", + + addStorage() { + return { + headings: [] as IMarking[], + }; + }, + + addProseMirrorPlugins() { + const plugin = new Plugin({ + key: new PluginKey("heading-list"), + appendTransaction: (_, __, newState) => { + const headings: IMarking[] = []; + let h1Sequence = 0; + let h2Sequence = 0; + let h3Sequence = 0; + + newState.doc.descendants((node) => { + if (node.type.name === "heading") { + const level = node.attrs.level; + const text = node.textContent; + + headings.push({ + type: "heading", + level: level, + text: text, + sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence, + }); + } + }); + + this.storage.headings = headings; + + this.editor.emit("update", { editor: this.editor, transaction: newState.tr }); + + return null; + }, + }); + + return [plugin]; + }, + + getHeadings() { + return this.storage.headings; + }, +}); diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 98961b7f0f1..1f15846a1a1 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -1,37 +1,33 @@ import ImageExt from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; // helpers import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary"; // plugins -import { - IMAGE_NODE_TYPE, - ImageExtensionStorage, - TrackImageDeletionPlugin, - TrackImageRestorationPlugin, - UploadImagesPlugin, -} from "@/plugins/image"; +import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types import { DeleteImage, RestoreImage } from "@/types"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => ImageExt.extend({ addKeyboardShortcuts() { return { - ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"), - ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"), + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name), }; }, addProseMirrorPlugins() { return [ - UploadImagesPlugin(this.editor, cancelUploadImage), - TrackImageDeletionPlugin(this.editor, deleteImage), - TrackImageRestorationPlugin(this.editor, restoreImage), + TrackImageDeletionPlugin(this.editor, deleteImage, this.name), + TrackImageRestorationPlugin(this.editor, restoreImage, this.name), ]; }, onCreate(this) { const imageSources = new Set(); this.editor.state.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === this.name) { imageSources.add(node.attrs.src); } }); @@ -64,4 +60,9 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm }, }; }, + + // render custom image node + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/image/image-component-without-props.tsx b/packages/editor/src/core/extensions/image/image-component-without-props.tsx new file mode 100644 index 00000000000..b6dbd4c308d --- /dev/null +++ b/packages/editor/src/core/extensions/image/image-component-without-props.tsx @@ -0,0 +1,55 @@ +import { mergeAttributes } from "@tiptap/core"; +import { Image } from "@tiptap/extension-image"; +// extensions +import { UploadImageExtensionStorage } from "@/extensions"; + +export const CustomImageComponentWithoutProps = () => + Image.extend, UploadImageExtensionStorage>({ + name: "imageComponent", + selectable: true, + group: "block", + atom: true, + draggable: true, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + src: { + default: null, + }, + height: { + default: "auto", + }, + ["id"]: { + default: null, + }, + aspectRatio: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "image-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["image-component", mergeAttributes(HTMLAttributes)]; + }, + + addStorage() { + return { + fileMap: new Map(), + deletedImageSet: new Map(), + }; + }, + }); + +export default CustomImageComponentWithoutProps; diff --git a/packages/editor/src/core/extensions/image/image-resize.tsx b/packages/editor/src/core/extensions/image/image-resize.tsx deleted file mode 100644 index 6be8214d72e..00000000000 --- a/packages/editor/src/core/extensions/image/image-resize.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useState } from "react"; -import { Editor } from "@tiptap/react"; -import Moveable from "react-moveable"; - -export const ImageResizer = ({ editor }: { editor: Editor }) => { - const updateMediaSize = () => { - const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; - if (imageInfo) { - const selection = editor.state.selection; - - // Use the style width/height if available, otherwise fall back to the element's natural width/height - const width = imageInfo.style.width - ? Number(imageInfo.style.width.replace("px", "")) - : imageInfo.getAttribute("width"); - const height = imageInfo.style.height - ? Number(imageInfo.style.height.replace("px", "")) - : imageInfo.getAttribute("height"); - - editor.commands.setImage({ - src: imageInfo.src, - width: width, - height: height, - } as any); - editor.commands.setNodeSelection(selection.from); - } - }; - - const [aspectRatio, setAspectRatio] = useState(1); - - return ( - <> - { - const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement; - if (imageInfo) { - const originalWidth = Number(imageInfo.width); - const originalHeight = Number(imageInfo.height); - setAspectRatio(originalWidth / originalHeight); - } - }} - onResize={({ target, width, height, delta }) => { - if (delta[0] || delta[1]) { - let newWidth, newHeight; - if (delta[0]) { - // Width change detected - newWidth = Math.max(width, 100); - newHeight = newWidth / aspectRatio; - } else if (delta[1]) { - // Height change detected - newHeight = Math.max(height, 100); - newWidth = newHeight * aspectRatio; - } - target.style.width = `${newWidth}px`; - target.style.height = `${newHeight}px`; - } - }} - onResizeEnd={() => { - updateMediaSize(); - }} - scalable - renderDirections={["se"]} - onScale={({ target, transform }) => { - target.style.transform = transform; - }} - /> - - ); -}; diff --git a/packages/editor/src/core/extensions/image/index.ts b/packages/editor/src/core/extensions/image/index.ts index 3e2f7518dd3..9c7dc65d783 100644 --- a/packages/editor/src/core/extensions/image/index.ts +++ b/packages/editor/src/core/extensions/image/index.ts @@ -1,4 +1,3 @@ export * from "./extension"; export * from "./image-extension-without-props"; -export * from "./image-resize"; export * from "./read-only-image"; diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 8112eba4ec5..1605174b325 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -1,4 +1,7 @@ import Image from "@tiptap/extension-image"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +// extensions +import { CustomImageNode } from "@/extensions"; export const ReadOnlyImageExtension = Image.extend({ addAttributes() { @@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({ }, }; }, + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, }); diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 220a11757ce..9209f9480ff 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -1,5 +1,6 @@ export * from "./code"; export * from "./code-inline"; +export * from "./custom-image"; export * from "./custom-link"; export * from "./custom-list-keymap"; export * from "./image"; @@ -8,9 +9,7 @@ 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"; @@ -18,4 +17,6 @@ export * from "./horizontal-rule"; export * from "./keymap"; export * from "./quote"; export * from "./read-only-extensions"; +export * from "./side-menu"; export * from "./slash-commands"; +export * from "./headers"; diff --git a/packages/editor/src/core/extensions/mentions/extension.tsx b/packages/editor/src/core/extensions/mentions/extension.tsx index e5a447c7fe3..5653a3540de 100644 --- a/packages/editor/src/core/extensions/mentions/extension.tsx +++ b/packages/editor/src/core/extensions/mentions/extension.tsx @@ -7,7 +7,7 @@ import { MentionList, MentionNodeView } from "@/extensions"; // types import { IMentionHighlight, IMentionSuggestion } from "@/types"; -export interface CustomMentionOptions extends MentionOptions { +interface CustomMentionOptions extends MentionOptions { mentionHighlights: () => Promise; readonly?: boolean; } @@ -91,7 +91,8 @@ export const CustomMention = ({ // @ts-expect-error - Tippy types are incorrect popup = tippy("body", { getReferenceClientRect: props.clientRect, - appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"), + appendTo: () => + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'), content: component.element, showOnCreate: true, interactive: true, diff --git a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx index 59cd2b8114a..ca2f39b8ce5 100644 --- a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx +++ b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx @@ -1,6 +1,7 @@ // TODO: fix all warnings /* eslint-disable react/display-name */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck import { useEffect, useState } from "react"; import { NodeViewWrapper } from "@tiptap/react"; diff --git a/packages/editor/src/core/extensions/mentions/mentions-list.tsx b/packages/editor/src/core/extensions/mentions/mentions-list.tsx index 6ca6ba8eb1d..279567a203a 100644 --- a/packages/editor/src/core/extensions/mentions/mentions-list.tsx +++ b/packages/editor/src/core/extensions/mentions/mentions-list.tsx @@ -146,10 +146,11 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
    Loading...
    ) : items.length ? ( items.map((item, index) => ( -
    { > {item.title} -
    + )) ) : (
    No results
    diff --git a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx b/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx index b03736ada5e..8fa8ef695ad 100644 --- a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx +++ b/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx @@ -1,8 +1,12 @@ import { mergeAttributes } from "@tiptap/core"; -import Mention from "@tiptap/extension-mention"; -import { ReactNodeViewRenderer } from "@tiptap/react"; -// extensions -import { CustomMentionOptions, MentionNodeView } from "@/extensions"; +import Mention, { MentionOptions } from "@tiptap/extension-mention"; +// types +import { IMentionHighlight } from "@/types"; + +interface CustomMentionOptions extends MentionOptions { + mentionHighlights: () => Promise; + readonly?: boolean; +} export const CustomMentionWithoutProps = () => Mention.extend({ @@ -31,9 +35,6 @@ export const CustomMentionWithoutProps = () => }, }; }, - addNodeView() { - return ReactNodeViewRenderer(MentionNodeView); - }, parseHTML() { return [ { diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index e646ed56e1f..1c0a9add7a2 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -1,3 +1,4 @@ +import CharacterCount from "@tiptap/extension-character-count"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import TextStyle from "@tiptap/extension-text-style"; @@ -18,6 +19,8 @@ import { TableRow, Table, CustomMention, + HeadingListExtension, + CustomReadOnlyImageExtension, } from "@/extensions"; // helpers import { isValidHttpUrl } from "@/helpers/common"; @@ -73,6 +76,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { class: "rounded-md", }, }), + CustomReadOnlyImageExtension(), TiptapUnderline, TextStyle, TaskList.configure({ @@ -104,4 +108,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionHighlights: mentionConfig.mentionHighlights, readonly: true, }), + CharacterCount, + HeadingListExtension, ]; diff --git a/packages/editor/src/core/extensions/side-menu.tsx b/packages/editor/src/core/extensions/side-menu.tsx new file mode 100644 index 00000000000..5ab6fbdf5b3 --- /dev/null +++ b/packages/editor/src/core/extensions/side-menu.tsx @@ -0,0 +1,175 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +// plugins +import { AIHandlePlugin } from "@/plugins/ai-handle"; +import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle"; + +type Props = { + aiEnabled: boolean; + dragDropEnabled: boolean; +}; + +export type SideMenuPluginProps = { + dragHandleWidth: number; + handlesConfig: { + ai: boolean; + dragDrop: boolean; + }; + scrollThreshold: { + up: number; + down: number; + }; +}; + +export type SideMenuHandleOptions = { + view: (view: EditorView, sideMenu: HTMLDivElement | null) => void; + domEvents?: { + [key: string]: (...args: any) => void; + }; +}; + +export const SideMenuExtension = (props: Props) => { + const { aiEnabled, dragDropEnabled } = props; + + return Extension.create({ + name: "editorSideMenu", + addProseMirrorPlugins() { + return [ + SideMenu({ + dragHandleWidth: 24, + handlesConfig: { + ai: aiEnabled, + dragDrop: dragDropEnabled, + }, + scrollThreshold: { up: 200, down: 100 }, + }), + ]; + }, + }); +}; + +const absoluteRect = (node: Element) => { + const data = node.getBoundingClientRect(); + + return { + top: data.top, + left: data.left, + width: data.width, + }; +}; + +const SideMenu = (options: SideMenuPluginProps) => { + const { handlesConfig } = options; + const editorSideMenu: HTMLDivElement | null = document.createElement("div"); + editorSideMenu.id = "editor-side-menu"; + // side menu view actions + const hideSideMenu = () => { + if (!editorSideMenu?.classList.contains("side-menu-hidden")) editorSideMenu?.classList.add("side-menu-hidden"); + }; + const showSideMenu = () => editorSideMenu?.classList.remove("side-menu-hidden"); + // side menu elements + const { view: dragHandleView, domEvents: dragHandleDOMEvents } = DragHandlePlugin(options); + const { view: aiHandleView, domEvents: aiHandleDOMEvents } = AIHandlePlugin(options); + + return new Plugin({ + key: new PluginKey("sideMenu"), + view: (view) => { + hideSideMenu(); + view?.dom.parentElement?.appendChild(editorSideMenu); + // side menu elements' initialization + if (handlesConfig.ai && !editorSideMenu.querySelector("#ai-handle")) { + aiHandleView(view, editorSideMenu); + } + + if (handlesConfig.dragDrop && !editorSideMenu.querySelector("#drag-handle")) { + dragHandleView(view, editorSideMenu); + } + + return { + destroy: () => hideSideMenu(), + }; + }, + props: { + handleDOMEvents: { + mousemove: (view, event) => { + if (!view.editable) return; + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element) || node.matches("ul, ol")) { + hideSideMenu(); + return; + } + + const compStyle = window.getComputedStyle(node); + const lineHeight = parseInt(compStyle.lineHeight, 10); + const paddingTop = parseInt(compStyle.paddingTop, 10); + + const rect = absoluteRect(node); + + rect.top += (lineHeight - 20) / 2; + rect.top += paddingTop; + + if (handlesConfig.ai) { + rect.left -= 20; + } + + 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; + + if (!editorSideMenu) return; + + editorSideMenu.style.left = `${rect.left - rect.width}px`; + editorSideMenu.style.top = `${rect.top}px`; + showSideMenu(); + if (handlesConfig.dragDrop) { + dragHandleDOMEvents?.mousemove(); + } + if (handlesConfig.ai) { + aiHandleDOMEvents?.mousemove?.(); + } + }, + // keydown: () => hideSideMenu(), + mousewheel: () => hideSideMenu(), + dragenter: (view) => { + if (handlesConfig.dragDrop) { + dragHandleDOMEvents?.dragenter?.(view); + } + }, + drop: (view, event) => { + if (handlesConfig.dragDrop) { + dragHandleDOMEvents?.drop?.(view, event); + } + }, + dragend: (view) => { + if (handlesConfig.dragDrop) { + dragHandleDOMEvents?.dragend?.(view); + } + }, + }, + }, + }); +}; diff --git a/packages/editor/src/core/extensions/slash-commands.tsx b/packages/editor/src/core/extensions/slash-commands.tsx index 38fc0231b07..2be8d89d96b 100644 --- a/packages/editor/src/core/extensions/slash-commands.tsx +++ b/packages/editor/src/core/extensions/slash-commands.tsx @@ -9,6 +9,9 @@ import { Heading1, Heading2, Heading3, + Heading4, + Heading5, + Heading6, ImageIcon, List, ListOrdered, @@ -25,13 +28,16 @@ import { toggleBulletList, toggleOrderedList, toggleTaskList, - insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree, + toggleHeadingFour, + toggleHeadingFive, + toggleHeadingSix, + insertImage, } from "@/helpers/editor-commands"; // types -import { CommandProps, ISlashCommandItem, UploadImage } from "@/types"; +import { CommandProps, ISlashCommandItem } from "@/types"; interface CommandItemProps { key: string; @@ -83,7 +89,7 @@ const Command = Extension.create({ }); const getSuggestionItems = - (uploadFile: UploadImage, additionalOptions?: Array) => + (additionalOptions?: Array) => ({ query }: { query: string }) => { let slashCommands: ISlashCommandItem[] = [ { @@ -91,7 +97,7 @@ const getSuggestionItems = title: "Text", description: "Just start typing with plain text.", searchTerms: ["p", "paragraph"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { if (range) { editor.chain().focus().deleteRange(range).clearNodes().run(); @@ -100,61 +106,91 @@ const getSuggestionItems = }, }, { - key: "heading_1", + key: "h1", title: "Heading 1", description: "Big section heading.", searchTerms: ["title", "big", "large"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { toggleHeadingOne(editor, range); }, }, { - key: "heading_2", + key: "h2", title: "Heading 2", description: "Medium section heading.", searchTerms: ["subtitle", "medium"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { toggleHeadingTwo(editor, range); }, }, { - key: "heading_3", + key: "h3", title: "Heading 3", description: "Small section heading.", searchTerms: ["subtitle", "small"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { toggleHeadingThree(editor, range); }, }, { - key: "todo_list", + key: "h4", + title: "Heading 4", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingFour(editor, range); + }, + }, + { + key: "h5", + title: "Heading 5", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingFive(editor, range); + }, + }, + { + key: "h6", + title: "Heading 6", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + command: ({ editor, range }: CommandProps) => { + toggleHeadingSix(editor, range); + }, + }, + { + key: "to-do-list", title: "To do", description: "Track tasks with a to-do list.", searchTerms: ["todo", "task", "list", "check", "checkbox"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { toggleTaskList(editor, range); }, }, { - key: "bullet_list", + key: "bulleted-list", title: "Bullet list", description: "Create a simple bullet list.", searchTerms: ["unordered", "point"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { toggleBulletList(editor, range); }, }, { - key: "numbered_list", + key: "numbered-list", title: "Numbered list", description: "Create a list with numbering.", searchTerms: ["ordered"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { toggleOrderedList(editor, range); }, @@ -164,43 +200,41 @@ const getSuggestionItems = title: "Table", description: "Create a table", searchTerms: ["table", "cell", "db", "data", "tabular"], - icon: , + icon:
    , command: ({ editor, range }: CommandProps) => { insertTableCommand(editor, range); }, }, { - key: "quote_block", + key: "quote", title: "Quote", description: "Capture a quote.", searchTerms: ["blockquote"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range), }, { - key: "code_block", + key: "code", title: "Code", description: "Capture a code snippet.", searchTerms: ["codeblock"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, { key: "image", title: "Image", - description: "Upload an image from your computer.", - searchTerms: ["img", "photo", "picture", "media"], - icon: , - command: ({ editor, range }: CommandProps) => { - insertImageCommand(editor, uploadFile, null, range); - }, + icon: , + description: "Insert an image", + searchTerms: ["img", "photo", "picture", "media", "upload"], + command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }), }, { key: "divider", title: "Divider", description: "Visually divide blocks.", searchTerms: ["line", "divider", "horizontal", "rule", "separate"], - icon: , + icon: , command: ({ editor, range }: CommandProps) => { editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, @@ -338,7 +372,8 @@ const renderItems = () => { editor: props.editor, }); - const tippyContainer = document.querySelector(".active-editor") ?? document.querySelector("#editor-container"); + const tippyContainer = + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'); // @ts-expect-error Tippy overloads are messed up popup = tippy("body", { @@ -378,10 +413,10 @@ const renderItems = () => { }; }; -export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array) => +export const SlashCommand = (additionalOptions?: Array) => Command.configure({ suggestion: { - items: getSuggestionItems(uploadFile, additionalOptions), + items: getSuggestionItems(additionalOptions), render: renderItems, }, }); diff --git a/packages/editor/src/core/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx index 347e82171c9..2a480212673 100644 --- a/packages/editor/src/core/extensions/table/table/table-view.tsx +++ b/packages/editor/src/core/extensions/table/table/table-view.tsx @@ -198,6 +198,7 @@ function createToolbox({ onSelectColor: (color: { backgroundColor: string; textColor: string }) => void; colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } }; }): Instance { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error const toolbox = tippy(triggerButton, { content: h( diff --git a/packages/editor/src/core/extensions/table/table/table.ts b/packages/editor/src/core/extensions/table/table/table.ts index 9d788e84b60..981788f5486 100644 --- a/packages/editor/src/core/extensions/table/table/table.ts +++ b/packages/editor/src/core/extensions/table/table/table.ts @@ -204,11 +204,8 @@ export const Table = Node.create({ ({ tr, dispatch }) => { if (dispatch) { const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell); - - // @ts-ignore tr.setSelection(selection); } - return true; }, }; @@ -247,7 +244,7 @@ export const Table = Node.create({ return ({ editor, getPos, node, decorations }) => { const { cellMinWidth } = this.options; - return new TableView(node, cellMinWidth, decorations, editor, getPos as () => number); + return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number); }; }, @@ -267,8 +264,6 @@ export const Table = Node.create({ handleWidth: this.options.handleWidth, cellMinWidth: this.options.cellMinWidth, // View: TableView, - - // @ts-ignore lastColumnResizable: this.options.lastColumnResizable, }) ); diff --git a/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts index 5fc2b146d06..684a6d344d3 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/create-cell.ts @@ -1,4 +1,4 @@ -import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"; +import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model"; export function createCell( cellType: NodeType, diff --git a/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts b/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts index 28c322a1f1f..5722c4cae2c 100644 --- a/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts +++ b/packages/editor/src/core/extensions/table/table/utilities/get-table-node-types.ts @@ -1,4 +1,4 @@ -import { NodeType, Schema } from "prosemirror-model"; +import { NodeType, Schema } from "@tiptap/pm/model"; export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } { if (schema.cached.tableNodeTypes) { diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 98930d94f1e..0fb32310d6a 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -1,7 +1,5 @@ -import { Extensions, generateJSON, getSchema } from "@tiptap/core"; -import { Selection } from "@tiptap/pm/state"; +import { EditorState, Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; -import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props"; import { twMerge } from "tailwind-merge"; interface EditorClassNames { @@ -61,3 +59,12 @@ export const isValidHttpUrl = (string: string): boolean => { return url.protocol === "http:" || url.protocol === "https:"; }; + +export const getParagraphCount = (editorState: EditorState | undefined) => { + if (!editorState) return 0; + let paragraphCount = 0; + editorState.doc.descendants((node) => { + if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++; + }); + return paragraphCount; +}; diff --git a/packages/editor/src/core/helpers/editor-commands.ts b/packages/editor/src/core/helpers/editor-commands.ts index db3b4d66d0e..66be05bb261 100644 --- a/packages/editor/src/core/helpers/editor-commands.ts +++ b/packages/editor/src/core/helpers/editor-commands.ts @@ -1,13 +1,10 @@ import { Editor, Range } from "@tiptap/core"; -import { Selection } from "@tiptap/pm/state"; // 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"; +import { InsertImageComponentProps } from "@/extensions"; export const setText = (editor: Editor, range?: Range) => { if (range) editor.chain().focus().deleteRange(range).clearNodes().run(); @@ -129,6 +126,27 @@ export const insertTableCommand = (editor: Editor, range?: Range) => { else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run(); }; +export const insertImage = ({ + editor, + event, + pos, + file, + range, +}: { + editor: Editor; + event: "insert" | "drop"; + pos?: number | null; + file?: File; + range?: Range; +}) => { + if (range) editor.chain().focus().deleteRange(range).run(); + + const imageOptions: InsertImageComponentProps = { event }; + if (pos) imageOptions.pos = pos; + if (file) imageOptions.file = file; + return editor?.chain().focus().insertImageComponent(imageOptions).run(); +}; + export const unsetLinkEditor = (editor: Editor) => { editor.chain().focus().unsetLink().run(); }; @@ -136,23 +154,3 @@ export const unsetLinkEditor = (editor: Editor) => { export const setLinkEditor = (editor: Editor, url: string) => { editor.chain().focus().setLink({ href: url }).run(); }; - -export const insertImageCommand = ( - editor: Editor, - uploadFile: UploadImage, - savedSelection?: Selection | null, - range?: Range -) => { - if (range) editor.chain().focus().deleteRange(range).run(); - const input = document.createElement("input"); - input.type = "file"; - input.accept = ".jpeg, .jpg, .png, .webp, .svg"; - input.onchange = async () => { - if (input.files?.length) { - const file = input.files[0]; - const pos = savedSelection?.anchor ?? editor.view.state.selection.from; - startImageUpload(editor, file, editor.view, pos, uploadFile); - } - }; - input.click(); -}; diff --git a/packages/editor/src/core/helpers/yjs.ts b/packages/editor/src/core/helpers/yjs.ts index 71a945d3cc5..ffd9367107d 100644 --- a/packages/editor/src/core/helpers/yjs.ts +++ b/packages/editor/src/core/helpers/yjs.ts @@ -1,76 +1,16 @@ -import { Schema } from "@tiptap/pm/model"; -import { prosemirrorJSONToYDoc } from "y-prosemirror"; import * as Y from "yjs"; -const defaultSchema: Schema = new Schema({ - nodes: { - text: {}, - doc: { content: "text*" }, - }, -}); - -/** - * @description converts ProseMirror JSON to Yjs document - * @param document prosemirror JSON - * @param fieldName - * @param schema - * @returns {Y.Doc} Yjs document - */ -export const proseMirrorJSONToBinaryString = ( - document: any, - fieldName: string | Array = "default", - schema?: Schema -): string => { - if (!document) { - throw new Error( - `You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}` - ); - } - - // allow a single field name - if (typeof fieldName === "string") { - const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName); - const docAsUint8Array = Y.encodeStateAsUpdate(yDoc); - const base64Doc = Buffer.from(docAsUint8Array).toString("base64"); - return base64Doc; - } - - const yDoc = new Y.Doc(); - - fieldName.forEach((field) => { - const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field)); - - Y.applyUpdate(yDoc, update); - }); - - const docAsUint8Array = Y.encodeStateAsUpdate(yDoc); - const base64Doc = Buffer.from(docAsUint8Array).toString("base64"); - - return base64Doc; -}; - /** * @description apply updates to a doc and return the updated doc in base64(binary) format * @param {Uint8Array} document * @param {Uint8Array} updates * @returns {string} base64(binary) form of the updated doc */ -export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => { +export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => { const yDoc = new Y.Doc(); Y.applyUpdate(yDoc, document); Y.applyUpdate(yDoc, updates); const encodedDoc = Y.encodeStateAsUpdate(yDoc); - const base64Updates = Buffer.from(encodedDoc).toString("base64"); - return base64Updates; -}; - -/** - * @description merge multiple updates into one single update - * @param {Uint8Array[]} updates - * @returns {Uint8Array} merged updates - */ -export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => { - const mergedUpdates = Y.mergeUpdates(updates); - return mergedUpdates; + return encodedDoc; }; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts new file mode 100644 index 00000000000..5a004bff284 --- /dev/null +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -0,0 +1,112 @@ +import { useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import Collaboration from "@tiptap/extension-collaboration"; +import { IndexeddbPersistence } from "y-indexeddb"; +// extensions +import { HeadingListExtension, SideMenuExtension } from "@/extensions"; +// hooks +import { useEditor } from "@/hooks/use-editor"; +// plane editor extensions +import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions"; +// types +import { TCollaborativeEditorProps } from "@/types"; + +export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => { + const { + disabledExtensions, + editorClassName, + editorProps = {}, + embedHandler, + extensions, + fileHandler, + forwardedRef, + handleEditorReady, + id, + mentionHandler, + placeholder, + realtimeConfig, + serverHandler, + tabIndex, + user, + } = props; + // states + const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false); + const [hasServerSynced, setHasServerSynced] = useState(false); + // initialize Hocuspocus provider + const provider = useMemo( + () => + new HocuspocusProvider({ + name: id, + parameters: realtimeConfig.queryParams, + // using user id as a token to verify the user on the server + token: user.id, + url: realtimeConfig.url, + onAuthenticationFailed: () => { + serverHandler?.onServerError?.(); + setHasServerConnectionFailed(true); + }, + onConnect: () => serverHandler?.onConnect?.(), + onClose: (data) => { + if (data.event.code === 1006) { + serverHandler?.onServerError?.(); + setHasServerConnectionFailed(true); + } + }, + onSynced: () => setHasServerSynced(true), + }), + [id, realtimeConfig, serverHandler, user.id] + ); + + // destroy and disconnect connection on unmount + useEffect( + () => () => { + provider.destroy(); + provider.disconnect(); + }, + [provider] + ); + // indexed db integration for offline support + useLayoutEffect(() => { + const localProvider = new IndexeddbPersistence(id, provider.document); + return () => { + localProvider?.destroy(); + }; + }, [provider, id]); + + const editor = useEditor({ + id, + editorProps, + editorClassName, + enableHistory: false, + extensions: [ + SideMenuExtension({ + aiEnabled: !disabledExtensions?.includes("ai"), + dragDropEnabled: true, + }), + HeadingListExtension, + Collaboration.configure({ + document: provider.document, + }), + ...(extensions ?? []), + ...DocumentEditorAdditionalExtensions({ + disabledExtensions, + issueEmbedConfig: embedHandler?.issue, + provider, + userDetails: user, + }), + ], + fileHandler, + handleEditorReady, + forwardedRef, + mentionHandler, + placeholder, + provider, + tabIndex, + }); + + return { + editor, + hasServerConnectionFailed, + hasServerSynced, + }; +}; diff --git a/packages/editor/src/core/hooks/use-document-editor.ts b/packages/editor/src/core/hooks/use-document-editor.ts deleted file mode 100644 index b2e87662b53..00000000000 --- a/packages/editor/src/core/hooks/use-document-editor.ts +++ /dev/null @@ -1,114 +0,0 @@ -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/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 7cc26862b10..65e36c01ae6 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -1,110 +1,102 @@ import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { DOMSerializer } from "@tiptap/pm/model"; import { Selection } from "@tiptap/pm/state"; import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import { useEditor as useTiptapEditor, Editor } from "@tiptap/react"; +import * as Y from "yjs"; // components -import { EditorMenuItemNames, getEditorMenuItems } from "@/components/menus"; +import { getEditorMenuItems } from "@/components/menus"; // extensions import { CoreEditorExtensions } from "@/extensions"; // helpers +import { getParagraphCount } from "@/helpers/common"; 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; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; -}; +import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types"; export interface CustomEditorProps { - id?: string; - fileHandler: TFileHandler; - initialValue?: string; editorClassName: string; - // 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; + enableHistory: boolean; + extensions?: any; + fileHandler: TFileHandler; forwardedRef?: MutableRefObject; + handleEditorReady?: (value: boolean) => void; + id?: string; + initialValue?: string; mentionHandler: { highlights: () => Promise; suggestions?: () => Promise; }; - handleEditorReady?: (value: boolean) => void; + onChange?: (json: object, html: string) => void; placeholder?: string | ((isFocused: boolean, value: string) => string); + provider?: HocuspocusProvider; tabIndex?: number; + // undefined when prop is not passed, null if intentionally passed to stop + // swr syncing + value?: string | null | undefined; } -export const useEditor = ({ - id = "", - editorProps = {}, - initialValue, - editorClassName, - value, - extensions = [], - fileHandler, - onChange, - forwardedRef, - tabIndex, - handleEditorReady, - provider, - mentionHandler, - placeholder, -}: CustomEditorProps) => { - const editor = useCustomEditor({ +export const useEditor = (props: CustomEditorProps) => { + const { + editorClassName, + editorProps = {}, + enableHistory, + extensions = [], + fileHandler, + forwardedRef, + handleEditorReady, + id = "", + initialValue, + mentionHandler, + onChange, + placeholder, + provider, + tabIndex, + value, + } = props; + // states + + const [savedSelection, setSavedSelection] = useState(null); + // refs + const editorRef: MutableRefObject = useRef(null); + const savedSelectionRef = useRef(savedSelection); + const editor = useTiptapEditor({ editorProps: { - ...CoreEditorProps(editorClassName), + ...CoreEditorProps({ + editorClassName, + }), ...editorProps, }, extensions: [ ...CoreEditorExtensions({ - mentionConfig: { - mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), - mentionHighlights: mentionHandler.highlights ?? [], - }, + enableHistory, fileConfig: { uploadFile: fileHandler.upload, deleteFile: fileHandler.delete, restoreFile: fileHandler.restore, cancelUploadImage: fileHandler.cancel, }, + mentionConfig: { + mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), + mentionHighlights: mentionHandler.highlights, + }, placeholder, tabIndex, }), ...extensions, ], content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

    ", - onCreate: async () => { - handleEditorReady?.(true); - }, - onTransaction: async ({ editor }) => { - setSavedSelection(editor.state.selection); - }, - onUpdate: async ({ editor }) => { - onChange?.(editor.getJSON(), editor.getHTML()); - }, - onDestroy: async () => { - handleEditorReady?.(false); - }, + onCreate: () => handleEditorReady?.(true), + onTransaction: ({ editor }) => setSavedSelection(editor.state.selection), + onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()), + onDestroy: () => handleEditorReady?.(false), }); - const editorRef: MutableRefObject = useRef(null); - - const [savedSelection, setSavedSelection] = useState(null); - - // Inside your component or hook - const savedSelectionRef = useRef(savedSelection); - // Update the ref whenever savedSelection changes useEffect(() => { savedSelectionRef.current = savedSelection; @@ -115,7 +107,7 @@ export const useEditor = ({ // value is null when intentionally passed where syncing is not yet // supported and value is undefined when the data from swr is not populated if (value === null || value === undefined) return; - if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) { + if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) { try { editor.commands.setContent(value, false, { preserveWhitespace: "full" }); const currentSavedSelection = savedSelectionRef.current; @@ -133,23 +125,23 @@ export const useEditor = ({ useImperativeHandle( forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); + clearEditor: (emitUpdate = false) => { + editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); + editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); }, setEditorValueAtCursorPosition: (content: string) => { if (savedSelection) { insertContentAtSavedSelection(editorRef, content, savedSelection); } }, - executeMenuItemCommand: (itemName: EditorMenuItemNames) => { - const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); + executeMenuItemCommand: (itemKey: TEditorCommands) => { + const editorItems = getEditorMenuItems(editorRef.current); - const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); + const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey); - const item = getEditorMenuItem(itemName); + const item = getEditorMenuItem(itemKey); if (item) { if (item.key === "image") { item.command(savedSelectionRef.current); @@ -157,21 +149,35 @@ export const useEditor = ({ item.command(); } } else { - console.warn(`No command found for item: ${itemName}`); + console.warn(`No command found for item: ${itemKey}`); } }, - isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { - const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); + isMenuItemActive: (itemName: TEditorCommands): boolean => { + const editorItems = getEditorMenuItems(editorRef.current); - const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); + const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); return item ? item.isActive() : false; }, + onHeadingChange: (callback: (headings: IMarking[]) => void) => { + // Subscribe to update event emitted from headers extension + editorRef.current?.on("update", () => { + callback(editorRef.current?.storage.headingList.headings); + }); + // Return a function to unsubscribe to the continuous transactions of + // the editor on unmounting the component that has subscribed to this + // method + return () => { + editorRef.current?.off("update"); + }; + }, + getHeadings: () => editorRef?.current?.storage.headingList.headings, onStateChange: (callback: () => void) => { // Subscribe to editor state changes editorRef.current?.on("transaction", () => { callback(); }); + // Return a function to unsubscribe to the continuous transactions of // the editor on unmounting the component that has subscribed to this // method @@ -183,27 +189,22 @@ export const useEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, - getHTML: (): string => { - const htmlOutput = editorRef.current?.getHTML() ?? "

    "; - return htmlOutput; + getDocument: () => { + const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; + const documentHTML = editorRef.current?.getHTML() ?? "

    "; + const documentJSON = editorRef.current?.getJSON() ?? null; + + return { + binary: documentBinary, + html: documentHTML, + json: documentJSON, + }; }, scrollSummary: (marking: IMarking): void => { 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, + isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editorRef.current || editorRef.current.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); @@ -221,8 +222,58 @@ export const useEditor = ({ console.error("An error occurred while setting focus at position:", error); } }, + getSelectedText: () => { + if (!editorRef.current) return null; + + const { state } = editorRef.current; + const { from, to, empty } = state.selection; + + if (empty) return null; + + const nodesArray: string[] = []; + state.doc.nodesBetween(from, to, (node, pos, parent) => { + if (parent === state.doc && editorRef.current) { + const serializer = DOMSerializer.fromSchema(editorRef.current?.schema); + const dom = serializer.serializeNode(node); + const tempDiv = document.createElement("div"); + tempDiv.appendChild(dom); + nodesArray.push(tempDiv.innerHTML); + } + }); + const selection = nodesArray.join(""); + return selection; + }, + insertText: (contentHTML, insertOnNextLine) => { + if (!editorRef.current) return; + // get selection + const { from, to, empty } = editorRef.current.state.selection; + if (empty) return; + if (insertOnNextLine) { + // move cursor to the end of the selection and insert a new line + editorRef.current + .chain() + .focus() + .setTextSelection(to) + .insertContent("
    ") + .insertContent(contentHTML) + .run(); + } else { + // replace selected text with the content provided + editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run(); + } + }, + getDocumentInfo: () => ({ + characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0, + paragraphs: getParagraphCount(editorRef?.current?.state), + words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, + }), + setProviderDocument: (value) => { + const document = provider?.document; + if (!document) return; + Y.applyUpdate(document, value); + }, }), - [editorRef, savedSelection, fileHandler.upload] + [editorRef, savedSelection] ); if (!editor) { diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts new file mode 100644 index 00000000000..f1bc8c8a136 --- /dev/null +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -0,0 +1,167 @@ +import { DragEvent, useCallback, useEffect, useState } from "react"; +import { Editor } from "@tiptap/core"; +import { isFileValid } from "@/plugins/image"; +import { insertImagesSafely } from "@/extensions/drop"; + +export const useUploader = ({ + onUpload, + editor, + loadImageFromFileSystem, +}: { + onUpload: (url: string) => void; + editor: Editor; + loadImageFromFileSystem: (file: string) => void; +}) => { + const [uploading, setUploading] = useState(false); + + const uploadFile = useCallback( + async (file: File) => { + const setImageUploadInProgress = (isUploading: boolean) => { + editor.storage.imageComponent.uploadInProgress = isUploading; + }; + setImageUploadInProgress(true); + setUploading(true); + const fileNameTrimmed = trimFileName(file.name); + const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); + const isValid = isFileValid(fileWithTrimmedName); + if (!isValid) { + setImageUploadInProgress(false); + return; + } + try { + const reader = new FileReader(); + reader.onload = () => { + if (reader.result) { + loadImageFromFileSystem(reader.result as string); + } else { + console.error("Failed to read the file: reader.result is null"); + } + }; + reader.onerror = () => { + console.error("Error reading file"); + }; + reader.readAsDataURL(fileWithTrimmedName); + // @ts-expect-error - TODO: fix typings, and don't remove await from + // here for now + const url: string = await editor?.commands.uploadImage(fileWithTrimmedName); + + if (!url) { + throw new Error("Something went wrong while uploading the image"); + } + onUpload(url); + } catch (errPayload: any) { + console.log(errPayload); + const error = errPayload?.response?.data?.error || "Something went wrong"; + console.error(error); + } finally { + setImageUploadInProgress(false); + setUploading(false); + } + }, + [onUpload] + ); + + return { uploading, uploadFile }; +}; + +export const useDropZone = ({ + uploader, + editor, + pos, +}: { + uploader: (file: File) => Promise; + editor: Editor; + pos: number; +}) => { + const [isDragging, setIsDragging] = useState(false); + const [draggedInside, setDraggedInside] = useState(false); + + useEffect(() => { + const dragStartHandler = () => { + setIsDragging(true); + }; + + const dragEndHandler = () => { + setIsDragging(false); + }; + + document.body.addEventListener("dragstart", dragStartHandler); + document.body.addEventListener("dragend", dragEndHandler); + + return () => { + document.body.removeEventListener("dragstart", dragStartHandler); + document.body.removeEventListener("dragend", dragEndHandler); + }; + }, []); + + const onDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + setDraggedInside(false); + if (e.dataTransfer.files.length === 0) { + return; + } + const fileList = e.dataTransfer.files; + await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader); + }, + [uploader, editor, pos] + ); + + const onDragEnter = () => { + setDraggedInside(true); + }; + + const onDragLeave = () => { + setDraggedInside(false); + }; + + return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop }; +}; + +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; +} + +// Upload the first image and insert the remaining images for uploading multiple image +// post insertion of image-component +export async function uploadFirstImageAndInsertRemaining( + editor: Editor, + fileList: FileList, + pos: number, + uploaderFn: (file: File) => Promise +) { + const filteredFiles: File[] = []; + for (let i = 0; i < fileList.length; i += 1) { + const item = fileList.item(i); + if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) { + filteredFiles.push(item); + } + } + if (filteredFiles.length !== fileList.length) { + console.warn("Some files were not images and have been ignored."); + } + if (filteredFiles.length === 0) { + console.error("No image files found to upload"); + return; + } + + // Upload the first image + const firstFile = filteredFiles[0]; + uploaderFn(firstFile); + + // Insert the remaining images + const remainingFiles = filteredFiles.slice(1); + + if (remainingFiles.length > 0) { + const docSize = editor.state.doc.content.size; + const posOfNextImageToBeInserted = Math.min(pos + 1, docSize); + insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" }); + } +} diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts new file mode 100644 index 00000000000..1aff29aa745 --- /dev/null +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -0,0 +1,88 @@ +import { useEffect, useLayoutEffect, useMemo, useState } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import Collaboration from "@tiptap/extension-collaboration"; +import { IndexeddbPersistence } from "y-indexeddb"; +// extensions +import { HeadingListExtension } from "@/extensions"; +// hooks +import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; +// types +import { TReadOnlyCollaborativeEditorProps } from "@/types"; + +export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => { + const { + editorClassName, + editorProps = {}, + extensions, + forwardedRef, + handleEditorReady, + id, + mentionHandler, + realtimeConfig, + serverHandler, + user, + } = props; + // states + const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false); + const [hasServerSynced, setHasServerSynced] = useState(false); + // initialize Hocuspocus provider + const provider = useMemo( + () => + new HocuspocusProvider({ + url: realtimeConfig.url, + name: id, + token: user.id, + parameters: realtimeConfig.queryParams, + onAuthenticationFailed: () => { + serverHandler?.onServerError?.(); + setHasServerConnectionFailed(true); + }, + onConnect: () => serverHandler?.onConnect?.(), + onClose: (data) => { + if (data.event.code === 1006) { + serverHandler?.onServerError?.(); + setHasServerConnectionFailed(true); + } + }, + onSynced: () => setHasServerSynced(true), + }), + [id, realtimeConfig, user.id] + ); + // destroy and disconnect connection on unmount + useEffect( + () => () => { + provider.destroy(); + provider.disconnect(); + }, + [provider] + ); + // indexed db integration for offline support + useLayoutEffect(() => { + const localProvider = new IndexeddbPersistence(id, provider.document); + return () => { + localProvider?.destroy(); + }; + }, [provider, id]); + + const editor = useReadOnlyEditor({ + editorProps, + editorClassName, + extensions: [ + ...(extensions ?? []), + HeadingListExtension, + Collaboration.configure({ + document: provider.document, + }), + ], + forwardedRef, + handleEditorReady, + mentionHandler, + provider, + }); + + return { + editor, + hasServerConnectionFailed, + hasServerSynced, + }; +}; diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index fcaf0c6dd1b..6d1ed6fa9f1 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,9 +1,12 @@ import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; +import { HocuspocusProvider } from "@hocuspocus/provider"; import { EditorProps } from "@tiptap/pm/view"; import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import * as Y from "yjs"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; // helpers +import { getParagraphCount } from "@/helpers/common"; import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; @@ -11,7 +14,7 @@ import { CoreReadOnlyEditorProps } from "@/props"; import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; interface CustomReadOnlyEditorProps { - initialValue: string; + initialValue?: string; editorClassName: string; forwardedRef?: MutableRefObject; extensions?: any; @@ -20,22 +23,28 @@ interface CustomReadOnlyEditorProps { mentionHandler: { highlights: () => Promise; }; + provider?: HocuspocusProvider; } -export const useReadOnlyEditor = ({ - initialValue, - editorClassName, - forwardedRef, - extensions = [], - editorProps = {}, - handleEditorReady, - mentionHandler, -}: CustomReadOnlyEditorProps) => { +export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { + const { + initialValue, + editorClassName, + forwardedRef, + extensions = [], + editorProps = {}, + handleEditorReady, + mentionHandler, + provider, + } = props; + const editor = useCustomEditor({ editable: false, content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

    ", editorProps: { - ...CoreReadOnlyEditorProps(editorClassName), + ...CoreReadOnlyEditorProps({ + editorClassName, + }), ...editorProps, }, onCreate: async () => { @@ -55,30 +64,55 @@ export const useReadOnlyEditor = ({ // for syncing swr data on tab refocus etc useEffect(() => { if (initialValue === null || initialValue === undefined) return; - if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue); + if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" }); }, [editor, initialValue]); const editorRef: MutableRefObject = useRef(null); useImperativeHandle(forwardedRef, () => ({ - clearEditor: () => { - editorRef.current?.commands.clearContent(); + clearEditor: (emitUpdate = false) => { + editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run(); }, setEditorValue: (content: string) => { - editorRef.current?.commands.setContent(content); + editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" }); }, getMarkDown: (): string => { const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, - getHTML: (): string => { - const htmlOutput = editorRef.current?.getHTML() ?? "

    "; - return htmlOutput; + getDocument: () => { + const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null; + const documentHTML = editorRef.current?.getHTML() ?? "

    "; + const documentJSON = editorRef.current?.getJSON() ?? null; + + return { + binary: documentBinary, + html: documentHTML, + json: documentJSON, + }; }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; scrollSummary(editorRef.current, marking); }, + getDocumentInfo: () => ({ + characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0, + paragraphs: getParagraphCount(editorRef?.current?.state), + words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0, + }), + onHeadingChange: (callback: (headings: IMarking[]) => void) => { + // Subscribe to update event emitted from headers extension + editorRef.current?.on("update", () => { + callback(editorRef.current?.storage.headingList.headings); + }); + // Return a function to unsubscribe to the continuous transactions of + // the editor on unmounting the component that has subscribed to this + // method + return () => { + editorRef.current?.off("update"); + }; + }, + getHeadings: () => editorRef?.current?.storage.headingList.headings, })); if (!editor) { diff --git a/packages/editor/src/core/plugins/ai-handle.ts b/packages/editor/src/core/plugins/ai-handle.ts new file mode 100644 index 00000000000..5f87df3ffed --- /dev/null +++ b/packages/editor/src/core/plugins/ai-handle.ts @@ -0,0 +1,120 @@ +import { NodeSelection } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +// extensions +import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions"; +// plugins +import { nodeDOMAtCoords } from "@/plugins/drag-handle"; + +const sparklesIcon = + ''; + +const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => { + const boundingRect = node.getBoundingClientRect(); + + return view.posAtCoords({ + left: boundingRect.left + 50 + options.dragHandleWidth, + top: boundingRect.top + 1, + })?.inside; +}; + +const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => { + const boundingRect = node.getBoundingClientRect(); + + return view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.inside; +}; + +const calcNodePos = (pos: number, view: EditorView, node: Element) => { + const maxPos = view.state.doc.content.size; + const safePos = Math.max(0, Math.min(pos, maxPos)); + const $pos = view.state.doc.resolve(safePos); + + if ($pos.depth > 1) { + 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)); + } + } + + return safePos; +}; + +export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => { + let aiHandleElement: HTMLButtonElement | null = null; + + const handleClick = (event: MouseEvent, view: EditorView) => { + view.focus(); + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + if (node.matches("blockquote")) { + let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); + if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + + const docSize = view.state.doc.content.size; + nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); + + if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { + // TODO FIX ERROR + const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + return; + } + + let nodePos = nodePosAtDOM(node, view, options); + + if (nodePos === null || nodePos === undefined) return; + + // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied + nodePos = calcNodePos(nodePos, view, node); + + // TODO FIX ERROR + // Use NodeSelection to select the node at the calculated position + const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + + // Dispatch the transaction to update the selection + view.dispatch(view.state.tr.setSelection(nodeSelection)); + }; + + const view = (view: EditorView, sideMenu: HTMLDivElement | null) => { + // create handle element + const className = + "grid place-items-center font-medium size-5 aspect-square text-xs text-custom-text-300 hover:bg-custom-background-80 rounded-sm opacity-100 !outline-none z-[5] transition-[background-color,_opacity] duration-200 ease-linear"; + aiHandleElement = document.createElement("button"); + aiHandleElement.type = "button"; + aiHandleElement.id = "ai-handle"; + aiHandleElement.classList.value = className; + const iconElement = document.createElement("span"); + iconElement.classList.value = "pointer-events-none"; + iconElement.innerHTML = sparklesIcon; + aiHandleElement.appendChild(iconElement); + // bind events + aiHandleElement.addEventListener("click", (e) => handleClick(e, view)); + + sideMenu?.appendChild(aiHandleElement); + + return { + // destroy the handle element on un-initialize + destroy: () => { + aiHandleElement?.remove(); + aiHandleElement = null; + }, + }; + }; + + const domEvents = {}; + + return { + view, + domEvents, + }; +}; diff --git a/packages/editor/src/core/plugins/drag-handle.ts b/packages/editor/src/core/plugins/drag-handle.ts new file mode 100644 index 00000000000..809802b4f92 --- /dev/null +++ b/packages/editor/src/core/plugins/drag-handle.ts @@ -0,0 +1,350 @@ +import { Fragment, Slice, Node } from "@tiptap/pm/model"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +// @ts-expect-error __serializeForClipboard's is not exported +import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; +// extensions +import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions"; + +const verticalEllipsisIcon = + ''; + +const createDragHandleElement = (): HTMLElement => { + const dragHandleElement = document.createElement("button"); + dragHandleElement.type = "button"; + dragHandleElement.id = "drag-handle"; + dragHandleElement.draggable = true; + dragHandleElement.dataset.dragHandle = ""; + dragHandleElement.classList.value = + "hidden sm:flex items-center size-5 aspect-square rounded-sm cursor-grab outline-none hover:bg-custom-background-80 active:bg-custom-background-80 active:cursor-grabbing transition-[background-color,_opacity] duration-200 ease-linear"; + + const iconElement1 = document.createElement("span"); + iconElement1.classList.value = "pointer-events-none text-custom-text-300"; + iconElement1.innerHTML = verticalEllipsisIcon; + const iconElement2 = document.createElement("span"); + iconElement2.classList.value = "pointer-events-none text-custom-text-300 -ml-2.5"; + iconElement2.innerHTML = verticalEllipsisIcon; + + dragHandleElement.appendChild(iconElement1); + dragHandleElement.appendChild(iconElement2); + + return dragHandleElement; +}; + +export const nodeDOMAtCoords = (coords: { x: number; y: number }) => { + const elements = document.elementsFromPoint(coords.x, coords.y); + const generalSelectors = [ + "li", + "p:not(:first-child)", + ".code-block", + "blockquote", + "h1, h2, h3, h4, h5, h6", + "[data-type=horizontalRule]", + ".table-wrapper", + ".issue-embed", + ".image-component", + ".image-upload-component", + ].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 in td or th + } + + // apply general selector + if (elem.matches(generalSelectors)) { + return elem; + } + } + return null; +}; + +const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => { + const boundingRect = node.getBoundingClientRect(); + + return view.posAtCoords({ + left: boundingRect.left + 50 + options.dragHandleWidth, + top: boundingRect.top + 1, + })?.inside; +}; + +const nodePosAtDOMForBlockQuotes = (node: Element, view: EditorView) => { + const boundingRect = node.getBoundingClientRect(); + + return view.posAtCoords({ + left: boundingRect.left + 1, + top: boundingRect.top + 1, + })?.inside; +}; + +const calcNodePos = (pos: number, view: EditorView, node: Element) => { + const maxPos = view.state.doc.content.size; + const safePos = Math.max(0, Math.min(pos, maxPos)); + const $pos = view.state.doc.resolve(safePos); + + if ($pos.depth > 1) { + 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)); + } + } + + return safePos; +}; + +export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => { + let listType = ""; + const handleDragStart = (event: DragEvent, view: EditorView) => { + view.focus(); + + if (!event.dataTransfer) return; + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + let draggedNodePos = nodePosAtDOM(node, view, options); + if (draggedNodePos == null || draggedNodePos < 0) return; + draggedNodePos = calcNodePos(draggedNodePos, view, node); + + const { from, to } = view.state.selection; + const diff = from - to; + + const fromSelectionPos = calcNodePos(from, view, node); + let differentNodeSelected = false; + + const nodePos = view.state.doc.resolve(fromSelectionPos); + + // Check if nodePos points to the top level node + if (nodePos.node().type.name === "doc") differentNodeSelected = true; + else { + // TODO FIX ERROR + const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before()); + // Check if the node where the drag event started is part of the current selection + differentNodeSelected = !( + draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos + ); + } + + if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) { + const endSelection = NodeSelection.create(view.state.doc, to - 1); + const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos); + view.dispatch(view.state.tr.setSelection(multiNodeSelection)); + } else { + // TODO FIX ERROR + const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + + // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL + if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") { + listType = node.parentElement!.tagName; + } + + if (node.matches("blockquote")) { + let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); + if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + + const docSize = view.state.doc.content.size; + nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); + + if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { + // TODO FIX ERROR + const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + } + + const slice = view.state.selection.content(); + const { dom, text } = __serializeForClipboard(view, slice); + + event.dataTransfer.clearData(); + event.dataTransfer.setData("text/html", dom.innerHTML); + event.dataTransfer.setData("text/plain", text); + event.dataTransfer.effectAllowed = "copyMove"; + + event.dataTransfer.setDragImage(node, 0, 0); + + view.dragging = { slice, move: event.ctrlKey }; + }; + + const handleClick = (event: MouseEvent, view: EditorView) => { + view.focus(); + + const node = nodeDOMAtCoords({ + x: event.clientX + 50 + options.dragHandleWidth, + y: event.clientY, + }); + + if (!(node instanceof Element)) return; + + if (node.matches("blockquote")) { + let nodePosForBlockQuotes = nodePosAtDOMForBlockQuotes(node, view); + if (nodePosForBlockQuotes === null || nodePosForBlockQuotes === undefined) return; + + const docSize = view.state.doc.content.size; + nodePosForBlockQuotes = Math.max(0, Math.min(nodePosForBlockQuotes, docSize)); + + if (nodePosForBlockQuotes >= 0 && nodePosForBlockQuotes <= docSize) { + // TODO FIX ERROR + const nodeSelection = NodeSelection.create(view.state.doc, nodePosForBlockQuotes); + view.dispatch(view.state.tr.setSelection(nodeSelection)); + } + return; + } + + let nodePos = nodePosAtDOM(node, view, options); + + if (nodePos === null || nodePos === undefined) return; + + // Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied + nodePos = calcNodePos(nodePos, view, node); + + // TODO FIX ERROR + // Use NodeSelection to select the node at the calculated position + const nodeSelection = NodeSelection.create(view.state.doc, nodePos); + + // Dispatch the transaction to update the selection + view.dispatch(view.state.tr.setSelection(nodeSelection)); + }; + + let dragHandleElement: HTMLElement | null = null; + // drag handle view actions + const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden"); + const hideDragHandle = () => { + if (!dragHandleElement?.classList.contains("drag-handle-hidden")) + dragHandleElement?.classList.add("drag-handle-hidden"); + }; + + const view = (view: EditorView, sideMenu: HTMLDivElement | null) => { + dragHandleElement = createDragHandleElement(); + dragHandleElement.addEventListener("dragstart", (e) => handleDragStart(e, view)); + dragHandleElement.addEventListener("click", (e) => handleClick(e, view)); + dragHandleElement.addEventListener("contextmenu", (e) => handleClick(e, view)); + + const isScrollable = (node: HTMLElement | SVGElement) => { + if (!(node instanceof HTMLElement || node instanceof SVGElement)) { + return false; + } + const style = getComputedStyle(node); + return ["overflow", "overflow-y"].some((propertyName) => { + const value = style.getPropertyValue(propertyName); + return value === "auto" || value === "scroll"; + }); + }; + + const getScrollParent = (node: HTMLElement | SVGElement) => { + let currentParent = node.parentElement; + while (currentParent) { + if (isScrollable(currentParent)) { + return currentParent; + } + currentParent = currentParent.parentElement; + } + return document.scrollingElement || document.documentElement; + }; + + const maxScrollSpeed = 100; + + dragHandleElement.addEventListener("drag", (e) => { + hideDragHandle(); + const scrollableParent = getScrollParent(dragHandleElement); + if (!scrollableParent) return; + const scrollThreshold = options.scrollThreshold; + + if (e.clientY < scrollThreshold.up) { + const overflow = scrollThreshold.up - e.clientY; + const ratio = Math.min(overflow / scrollThreshold.up, 1); + const scrollAmount = -maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); + } else if (window.innerHeight - e.clientY < scrollThreshold.down) { + const overflow = e.clientY - (window.innerHeight - scrollThreshold.down); + const ratio = Math.min(overflow / scrollThreshold.down, 1); + const scrollAmount = maxScrollSpeed * ratio; + scrollableParent.scrollBy({ top: scrollAmount }); + } + }); + + hideDragHandle(); + + sideMenu?.appendChild(dragHandleElement); + + return { + destroy: () => { + dragHandleElement?.remove?.(); + dragHandleElement = null; + }, + }; + }; + const domEvents = { + mousemove: () => showDragHandle(), + dragenter: (view: EditorView) => { + view.dom.classList.add("dragging"); + hideDragHandle(); + }, + drop: (view: EditorView, event: DragEvent) => { + view.dom.classList.remove("dragging"); + hideDragHandle(); + let droppedNode: Node | null = null; + const dropPos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); + + if (!dropPos) return; + + if (view.state.selection instanceof NodeSelection) { + droppedNode = view.state.selection.node; + } + + if (!droppedNode) return; + + const resolvedPos = view.state.doc.resolve(dropPos.pos); + let isDroppedInsideList = false; + + // Traverse up the document tree to find if we're inside a list item + for (let i = resolvedPos.depth; i > 0; i--) { + if (resolvedPos.node(i).type.name === "listItem") { + isDroppedInsideList = true; + break; + } + } + + // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside

      tag otherwise ol list items will be transformed into ul list item when dropped + if ( + view.state.selection instanceof NodeSelection && + view.state.selection.node.type.name === "listItem" && + !isDroppedInsideList && + listType == "OL" + ) { + const text = droppedNode.textContent; + if (!text) return; + const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text)); + const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph); + + const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem); + const slice = new Slice(Fragment.from(newList), 0, 0); + view.dragging = { slice, move: event.ctrlKey }; + } + }, + dragend: (view: EditorView) => { + view.dom.classList.remove("dragging"); + }, + }; + + return { + view, + domEvents, + }; +}; diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 8dc1bf07229..21c8cd24f61 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -1,22 +1,24 @@ import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // plugins -import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image"; +import { type ImageNode } from "@/plugins/image"; // types import { DeleteImage } from "@/types"; -export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin => +export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin => new Plugin({ - key: deleteKey, + key: new PluginKey(`delete-${nodeType}`), appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { const newImageSources = new Set(); newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === nodeType) { newImageSources.add(node.attrs.src); } }); transactions.forEach((transaction) => { + // if the transaction has meta of skipImageDeletion get to true, then return (like while clearing the editor content programatically) + if (transaction.getMeta("skipImageDeletion")) return; // transaction could be a selection if (!transaction.docChanged) return; @@ -25,7 +27,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag // iterate through all the nodes in the old state oldState.doc.descendants((oldNode) => { // if the node is not an image, then return as no point in checking - if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + if (oldNode.type.name !== nodeType) return; // Check if the node has been deleted or replaced if (!newImageSources.has(oldNode.attrs.src)) { @@ -35,7 +37,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag removedImages.forEach(async (node) => { const src = node.attrs.src; - editor.storage.image.deletedImageSet.set(src, true); + editor.storage[nodeType].deletedImageSet.set(src, true); await onNodeDeleted(src, deleteImage); }); }); @@ -46,6 +48,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { try { + if (!src) return; const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); await deleteImage(assetUrlWithWorkspaceId); } catch (error) { diff --git a/packages/editor/src/core/plugins/image/image-upload-handler.ts b/packages/editor/src/core/plugins/image/image-upload-handler.ts deleted file mode 100644 index d0bd339daef..00000000000 --- a/packages/editor/src/core/plugins/image/image-upload-handler.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorView } from "@tiptap/pm/view"; -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, - file: File, - view: EditorView, - pos: number | null, - uploadFile: UploadImage -) { - editor.storage.image.uploadInProgress = true; - - if (!isFileValid(file)) { - editor.storage.image.uploadInProgress = false; - return; - } - - const id = uuidv4(); - - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); - - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); - view.dispatch(tr); - }; - - // Handle FileReader errors - reader.onerror = (error) => { - console.error("FileReader error: ", error); - removePlaceholder(editor, view, id); - return; - }; - - 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(fileWithTrimmedName, uploadFile); - - if (src == null) { - throw new Error("Resolved image URL is undefined."); - } - - const { schema } = view.state; - pos = findPlaceholder(view.state, id); - - if (pos == null) { - editor.storage.image.uploadInProgress = false; - return; - } - const imageSrc = typeof src === "object" ? reader.result : src; - - const node = schema.nodes.image.create({ src: imageSrc }); - - if (pos < 0 || pos > view.state.doc.content.size) { - throw new Error("Invalid position to insert the image node."); - } - - // insert the image node at the position of the placeholder and remove the placeholder - const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } }); - - view.dispatch(transaction); - - editor.storage.image.uploadInProgress = false; - } catch (error) { - console.error("Error in uploading and inserting image: ", error); - removePlaceholder(editor, view, id); - } -} - -async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise { - try { - const imageUrl = await uploadFile(file); - - if (imageUrl == null) { - throw new Error("Image URL is undefined."); - } - - await new Promise((resolve, reject) => { - const image = new Image(); - image.src = imageUrl; - image.onload = () => { - resolve(); - }; - image.onerror = (error) => { - console.error("Error in loading image: ", error); - reject(error); - }; - }); - - return imageUrl; - } catch (error) { - console.error("Error in uploading image: ", error); - // throw error to remove the placeholder - 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 index e5a290abe85..dfb7878735d 100644 --- a/packages/editor/src/core/plugins/image/index.ts +++ b/packages/editor/src/core/plugins/image/index.ts @@ -2,6 +2,4 @@ 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/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index 036df9b8870..d722e53a639 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -1,17 +1,17 @@ import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // plugins -import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image"; +import { ImageNode } from "@/plugins/image"; // types import { RestoreImage } from "@/types"; -export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin => +export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin => new Plugin({ - key: restoreKey, + key: new PluginKey(`restore-${nodeType}`), appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { const oldImageSources = new Set(); oldState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { + if (node.type.name === nodeType) { oldImageSources.add(node.attrs.src); } }); @@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor const addedImages: ImageNode[] = []; newState.doc.descendants((node, pos) => { - if (node.type.name !== IMAGE_NODE_TYPE) return; + if (node.type.name !== nodeType) return; if (pos < 0 || pos > newState.doc.content.size) return; if (oldImageSources.has(node.attrs.src)) return; addedImages.push(node as ImageNode); }); addedImages.forEach(async (image) => { - const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src); + const src = image.attrs.src; + const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src); if (wasDeleted === undefined) { - editor.storage.image.deletedImageSet.set(image.attrs.src, false); + editor.storage[nodeType].deletedImageSet.set(src, false); } else if (wasDeleted === true) { try { - await onNodeRestored(image.attrs.src, restoreImage); - editor.storage.image.deletedImageSet.set(image.attrs.src, false); + await onNodeRestored(src, restoreImage); + editor.storage[nodeType].deletedImageSet.set(src, false); } catch (error) { console.error("Error restoring image: ", error); } @@ -48,6 +49,7 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { try { + if (!src) return; const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); await restoreImage(assetUrlWithWorkspaceId); } catch (error) { diff --git a/packages/editor/src/core/plugins/image/upload-image.ts b/packages/editor/src/core/plugins/image/upload-image.ts deleted file mode 100644 index e3db70d1366..00000000000 --- a/packages/editor/src/core/plugins/image/upload-image.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { Plugin } from "@tiptap/pm/state"; -import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; -// plugins -import { removePlaceholder, uploadKey } from "@/plugins/image"; - -export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { - let currentView: EditorView | null = null; - - const createPlaceholder = (src: string): HTMLElement => { - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300"); - image.src = src; - placeholder.appendChild(image); - - return placeholder; - }; - - const createCancelButton = (id: string): HTMLButtonElement => { - const cancelButton = document.createElement("button"); - cancelButton.type = "button"; - cancelButton.style.position = "absolute"; - cancelButton.style.right = "3px"; - cancelButton.style.top = "3px"; - cancelButton.setAttribute("class", "opacity-90 rounded-lg"); - - cancelButton.onclick = () => { - if (currentView) { - cancelUploadImage?.(); - removePlaceholder(editor, currentView, id); - } - }; - - // Create an SVG element from the SVG string - const svgString = ``; - const parser = new DOMParser(); - const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement; - - cancelButton.appendChild(svgElement); - - return cancelButton; - }; - - return new Plugin({ - key: uploadKey, - view(editorView) { - currentView = editorView; - return { - destroy() { - currentView = null; - }, - }; - }, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - const action = tr.getMeta(uploadKey); - if (action && action.add) { - const { id, pos, src } = action.add; - - const placeholder = createPlaceholder(src); - const cancelButton = createCancelButton(id); - - placeholder.appendChild(cancelButton); - - const deco = Decoration.widget(pos, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action && action.remove) { - set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, - }); -}; diff --git a/packages/editor/src/core/plugins/image/utils/index.ts b/packages/editor/src/core/plugins/image/utils/index.ts index 217ec411760..08d377a831f 100644 --- a/packages/editor/src/core/plugins/image/utils/index.ts +++ b/packages/editor/src/core/plugins/image/utils/index.ts @@ -1,2 +1 @@ -export * from "./placeholder"; export * from "./validate-file"; diff --git a/packages/editor/src/core/plugins/image/utils/placeholder.ts b/packages/editor/src/core/plugins/image/utils/placeholder.ts deleted file mode 100644 index f05f4d8905f..00000000000 --- a/packages/editor/src/core/plugins/image/utils/placeholder.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState } from "@tiptap/pm/state"; -import { DecorationSet, EditorView } from "@tiptap/pm/view"; -// plugins -import { uploadKey } from "@/plugins/image"; - -export function findPlaceholder(state: EditorState, id: string): number | null { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id); - return found.length ? found[0].from : null; -} - -export function removePlaceholder(editor: Editor, view: EditorView, id: string) { - const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } }); - view.dispatch(removePlaceholderTr); - editor.storage.image.uploadInProgress = false; -} diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts index a7952a0e116..c86e99335fe 100644 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts @@ -1,17 +1,23 @@ -export function isFileValid(file: File): boolean { +export function isFileValid(file: File, showAlert = true): boolean { if (!file) { - alert("No file selected. Please select a file to upload."); + if (showAlert) { + alert("No file selected. Please select a file to upload."); + } return false; } - const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml"]; + const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; if (!allowedTypes.includes(file.type)) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP, or SVG image file."); + if (showAlert) { + alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); + } return false; } if (file.size > 5 * 1024 * 1024) { - alert("File size too large. Please select a file smaller than 5MB."); + if (showAlert) { + alert("File size too large. Please select a file smaller than 5MB."); + } return false; } diff --git a/packages/editor/src/core/props/props.tsx b/packages/editor/src/core/props/props.tsx index 11e829162a3..4bda3e51a2d 100644 --- a/packages/editor/src/core/props/props.tsx +++ b/packages/editor/src/core/props/props.tsx @@ -2,7 +2,13 @@ import { EditorProps } from "@tiptap/pm/view"; // helpers import { cn } from "@/helpers/common"; -export function CoreEditorProps(editorClassName: string): EditorProps { +export type TCoreEditorProps = { + editorClassName: string; +}; + +export const CoreEditorProps = (props: TCoreEditorProps): EditorProps => { + const { editorClassName } = props; + return { attributes: { class: cn( @@ -25,4 +31,4 @@ export function CoreEditorProps(editorClassName: string): EditorProps { return html.replace(//g, ""); }, }; -} +}; diff --git a/packages/editor/src/core/props/read-only.tsx b/packages/editor/src/core/props/read-only.tsx index ea583938f71..aaa635a508f 100644 --- a/packages/editor/src/core/props/read-only.tsx +++ b/packages/editor/src/core/props/read-only.tsx @@ -1,12 +1,18 @@ import { EditorProps } from "@tiptap/pm/view"; // helpers import { cn } from "@/helpers/common"; +// props +import { TCoreEditorProps } from "@/props"; -export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({ - attributes: { - class: cn( - "prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none", - editorClassName - ), - }, -}); +export const CoreReadOnlyEditorProps = (props: TCoreEditorProps): EditorProps => { + const { editorClassName } = props; + + return { + attributes: { + class: cn( + "prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none", + editorClassName + ), + }, + }; +}; diff --git a/packages/editor/src/core/types/ai.ts b/packages/editor/src/core/types/ai.ts new file mode 100644 index 00000000000..448482e6543 --- /dev/null +++ b/packages/editor/src/core/types/ai.ts @@ -0,0 +1,8 @@ +export type TAIMenuProps = { + isOpen: boolean; + onClose: () => void; +}; + +export type TAIHandler = { + menu?: (props: TAIMenuProps) => React.ReactNode; +}; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts new file mode 100644 index 00000000000..4b706a7f9f2 --- /dev/null +++ b/packages/editor/src/core/types/collaboration.ts @@ -0,0 +1,48 @@ +import { Extensions } from "@tiptap/core"; +import { EditorProps } from "@tiptap/pm/view"; +// plane editor types +import { TEmbedConfig } from "@/plane-editor/types"; +// types +import { + EditorReadOnlyRefApi, + EditorRefApi, + IMentionHighlight, + IMentionSuggestion, + TExtensions, + TFileHandler, + TRealtimeConfig, + TUserDetails, +} from "@/types"; + +export type TServerHandler = { + onConnect?: () => void; + onServerError?: () => void; +}; + +type TCollaborativeEditorHookProps = { + disabledExtensions?: TExtensions[]; + editorClassName: string; + editorProps?: EditorProps; + extensions?: Extensions; + handleEditorReady?: (value: boolean) => void; + id: string; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + realtimeConfig: TRealtimeConfig; + serverHandler?: TServerHandler; + user: TUserDetails; +}; + +export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { + embedHandler?: TEmbedConfig; + fileHandler: TFileHandler; + forwardedRef?: React.MutableRefObject; + placeholder?: string | ((isFocused: boolean, value: string) => string); + tabIndex?: number; +}; + +export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { + forwardedRef?: React.MutableRefObject; +}; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts new file mode 100644 index 00000000000..93d612e599e --- /dev/null +++ b/packages/editor/src/core/types/config.ts @@ -0,0 +1,17 @@ +import { DeleteImage, RestoreImage, UploadImage } from "@/types"; + +export type TFileHandler = { + cancel: () => void; + delete: DeleteImage; + upload: UploadImage; + restore: RestoreImage; +}; + +export type TEditorFontStyle = "sans-serif" | "serif" | "monospace"; + +export type TEditorFontSize = "small-font" | "large-font"; + +export type TDisplayConfig = { + fontStyle?: TEditorFontStyle; + fontSize?: TEditorFontSize; +}; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 84e522b5587..3624fa046ce 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,66 +1,129 @@ -// components -import { EditorMenuItemNames } from "@/components/menus"; +import { JSONContent } from "@tiptap/core"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; -// hooks -import { TFileHandler } from "@/hooks/use-editor"; // types -import { IMentionHighlight, IMentionSuggestion } from "@/types"; +import { + IMentionHighlight, + IMentionSuggestion, + TAIHandler, + TDisplayConfig, + TEditorCommands, + TEmbedConfig, + TExtensions, + TFileHandler, + TServerHandler, +} from "@/types"; +// editor refs export type EditorReadOnlyRefApi = { getMarkDown: () => string; - getHTML: () => string; - clearEditor: () => void; + getDocument: () => { + binary: Uint8Array | null; + html: string; + json: JSONContent | null; + }; + clearEditor: (emitUpdate?: boolean) => void; setEditorValue: (content: string) => void; scrollSummary: (marking: IMarking) => void; + getDocumentInfo: () => { + characters: number; + paragraphs: number; + words: number; + }; + onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void; + getHeadings: () => IMarking[]; }; export interface EditorRefApi extends EditorReadOnlyRefApi { setEditorValueAtCursorPosition: (content: string) => void; - executeMenuItemCommand: (itemName: EditorMenuItemNames) => void; - isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; + executeMenuItemCommand: (itemKey: TEditorCommands) => void; + isMenuItemActive: (itemKey: TEditorCommands) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; isEditorReadyToDiscard: () => boolean; - setSynced: () => void; - hasUnsyncedChanges: () => boolean; + getSelectedText: () => string | null; + insertText: (contentHTML: string, insertOnNextLine?: boolean) => void; + setProviderDocument: (value: Uint8Array) => void; } +// editor props export interface IEditorProps { containerClassName?: string; + displayConfig?: TDisplayConfig; editorClassName?: string; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; - id?: string; + id: string; initialValue: string; mentionHandler: { highlights: () => Promise; suggestions?: () => Promise; }; onChange?: (json: object, html: string) => void; - onEnterKeyPress?: (descriptionHTML: string) => void; + onEnterKeyPress?: (e?: any) => void; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; value?: string | null; } -export interface ILiteTextEditor extends IEditorProps {} +export type ILiteTextEditor = IEditorProps; export interface IRichTextEditor extends IEditorProps { dragDropEnabled?: boolean; } +export interface ICollaborativeDocumentEditor + extends Omit { + aiHandler?: TAIHandler; + disabledExtensions: TExtensions[]; + embedHandler: TEmbedConfig; + handleEditorReady?: (value: boolean) => void; + id: string; + realtimeConfig: TRealtimeConfig; + serverHandler?: TServerHandler; + user: TUserDetails; +} + +// read only editor props export interface IReadOnlyEditorProps { containerClassName?: string; + displayConfig?: TDisplayConfig; editorClassName?: string; forwardedRef?: React.MutableRefObject; + id: string; initialValue: string; mentionHandler: { highlights: () => Promise; }; - tabIndex?: number; } -export interface ILiteTextReadOnlyEditor extends IReadOnlyEditorProps {} +export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; + +export type IRichTextReadOnlyEditor = IReadOnlyEditorProps; + +export interface ICollaborativeDocumentReadOnlyEditor extends Omit { + embedHandler: TEmbedConfig; + handleEditorReady?: (value: boolean) => void; + id: string; + realtimeConfig: TRealtimeConfig; + serverHandler?: TServerHandler; + user: TUserDetails; +} + +export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps { + embedHandler: TEmbedConfig; + handleEditorReady?: (value: boolean) => void; +} -export interface IRichTextReadOnlyEditor extends IReadOnlyEditorProps {} +export type TUserDetails = { + color: string; + id: string; + name: string; +}; + +export type TRealtimeConfig = { + url: string; + queryParams: { + [key: string]: string; + }; +}; diff --git a/packages/editor/src/core/types/extensions.ts b/packages/editor/src/core/types/extensions.ts new file mode 100644 index 00000000000..da8713f10cd --- /dev/null +++ b/packages/editor/src/core/types/extensions.ts @@ -0,0 +1 @@ +export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed"; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index f4dd894128c..8da9ed276e5 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -1,5 +1,10 @@ +export * from "./ai"; +export * from "./collaboration"; +export * from "./config"; export * from "./editor"; export * from "./embed"; +export * from "./extensions"; export * from "./image"; export * from "./mention-suggestion"; export * from "./slash-commands-suggestion"; +export * from "@/plane-editor/types"; diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts index 34e451098f1..3cb9d76b0ea 100644 --- a/packages/editor/src/core/types/slash-commands-suggestion.ts +++ b/packages/editor/src/core/types/slash-commands-suggestion.ts @@ -1,13 +1,35 @@ import { ReactNode } from "react"; import { Editor, Range } from "@tiptap/core"; +export type TEditorCommands = + | "text" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "bulleted-list" + | "numbered-list" + | "to-do-list" + | "quote" + | "code" + | "table" + | "image" + | "divider" + | "issue-embed"; + export type CommandProps = { editor: Editor; range: Range; }; export type ISlashCommandItem = { - key: string; + key: TEditorCommands; title: string; description: string; searchTerms: string[]; diff --git a/packages/editor/src/ee/providers/index.ts b/packages/editor/src/ee/providers/index.ts deleted file mode 100644 index 3f53c1e7a3e..00000000000 --- a/packages/editor/src/ee/providers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "src/ce/providers"; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 828fab0218f..fc9fe1ac603 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -7,7 +7,8 @@ import "src/styles/drag-drop.css"; // editors export { - DocumentEditorWithRef, + CollaborativeDocumentEditorWithRef, + CollaborativeDocumentReadOnlyEditorWithRef, DocumentReadOnlyEditorWithRef, LiteTextEditorWithRef, LiteTextReadOnlyEditorWithRef, @@ -19,11 +20,9 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele // 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"; @@ -34,5 +33,5 @@ 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 type { CustomEditorProps } from "@/hooks/use-editor"; export * from "@/types"; diff --git a/packages/editor/src/lib.ts b/packages/editor/src/lib.ts new file mode 100644 index 00000000000..e14c40127fb --- /dev/null +++ b/packages/editor/src/lib.ts @@ -0,0 +1 @@ +export * from "@/extensions/core-without-props"; diff --git a/packages/editor/src/styles/drag-drop.css b/packages/editor/src/styles/drag-drop.css index 74eaeb4752d..7db6ed87554 100644 --- a/packages/editor/src/styles/drag-drop.css +++ b/packages/editor/src/styles/drag-drop.css @@ -1,86 +1,81 @@ -/* drag handle */ -.drag-handle { +/* side menu */ +#editor-side-menu { position: fixed; + display: flex; + align-items: center; opacity: 1; - transition: opacity ease-in 0.2s; - height: 20px; - width: 15px; - display: grid; - place-items: center; - z-index: 5; - cursor: grab; - border-radius: 2px; - transition: background-color 0.2s; - - &:hover { - background-color: rgba(var(--color-background-80)); - } - - &:active { - background-color: rgba(var(--color-background-80)); - cursor: grabbing; - } + transition: opacity 0.2s ease 0.2s; - &.hidden { + &.side-menu-hidden { opacity: 0; pointer-events: none; } } +/* end side menu */ + +/* drag handle */ +#drag-handle { + opacity: 1; -@media screen and (max-width: 600px) { - .drag-handle { - display: none; + &.drag-handle-hidden { + opacity: 0; pointer-events: none; } } +/* end drag handle */ -.drag-handle-container { - height: 15px; - width: 15px; - display: grid; - place-items: center; -} - -.drag-handle-dots { - height: 100%; - width: 12px; - display: grid; - grid-template-columns: repeat(2, 1fr); - place-items: center; -} +/* ai handle */ +#ai-handle { + opacity: 1; -.drag-handle-dot { - height: 2.5px; - width: 2.5px; - background-color: rgba(var(--color-text-300)); - border-radius: 50%; + &.handle-hidden { + opacity: 0; + pointer-events: none; + } } -/* end drag handle */ +/* end ai handle */ -.ProseMirror:not(.dragging) .ProseMirror-selectednode { +.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) { position: relative; cursor: grab; outline: none !important; box-shadow: none; -} -.ProseMirror:not(.dragging) .ProseMirror-selectednode::after { - content: ""; - position: absolute; - top: 0; - left: -5px; - height: 100%; - width: 100%; - background-color: rgba(var(--color-primary-100), 0.2); - border-radius: 4px; + --horizontal-offset: 5px; + + &:has(.issue-embed), + &.table-wrapper { + --horizontal-offset: 0px; + } + + &::after { + content: ""; + position: absolute; + top: 0; + left: calc(-1 * var(--horizontal-offset)); + height: 100%; + width: calc(100% + (var(--horizontal-offset) * 2)); + background-color: rgba(var(--color-primary-100), 0.2); + border-radius: 4px; + pointer-events: none; + } + + &.node-imageComponent, + &.node-image { + --horizontal-offset: 0px; + + &::after { + background-color: rgba(var(--color-background-100), 0.2); + } + } } -/* for targetting the taks list items */ +/* for targeting the task list items */ li.ProseMirror-selectednode:not(.dragging)[data-checked]::after { margin-left: -5px; } -/* for targetting the unordered list items */ +/* for targeting the unordered list items */ ul > li.ProseMirror-selectednode:not(.dragging)::after { margin-left: -10px; /* Adjust as needed */ } @@ -90,23 +85,24 @@ ol { counter-reset: item; } -/* for targetting the ordered list items */ +/* for targeting 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 */ +/* for targeting 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 */ +/* for targeting the ordered list items after the 99th item */ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { margin-left: -35px; } -.ProseMirror img { +.ProseMirror node-image, +.ProseMirror node-imageComponent { transition: filter 0.1s ease-in-out; cursor: pointer; @@ -118,9 +114,3 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after { filter: brightness(90%); } } - -:not(.dragging) .ProseMirror-selectednode.table-wrapper { - padding: 4px 2px; - background-color: rgba(var(--color-primary-300), 0.1) !important; - box-shadow: rgba(var(--color-primary-100)) 0px 0px 0px 2px inset !important; -} diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index 28fb2dd1113..e5047fb0c48 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -1,14 +1,85 @@ +.editor-container { + &.large-font { + --font-size-h1: 1.75rem; + --font-size-h2: 1.5rem; + --font-size-h3: 1.375rem; + --font-size-h4: 1.25rem; + --font-size-h5: 1.125rem; + --font-size-h6: 1rem; + --font-size-regular: 1rem; + --font-size-list: var(--font-size-regular); + --font-size-code: var(--font-size-regular); + + --line-height-h1: 2.25rem; + --line-height-h2: 2rem; + --line-height-h3: 1.75rem; + --line-height-h4: 1.5rem; + --line-height-h5: 1.5rem; + --line-height-h6: 1.5rem; + --line-height-regular: 1.5rem; + --line-height-list: var(--line-height-regular); + --line-height-code: var(--line-height-regular); + } + + &.small-font { + --font-size-h1: 1.4rem; + --font-size-h2: 1.2rem; + --font-size-h3: 1.1rem; + --font-size-h4: 1rem; + --font-size-h5: 0.9rem; + --font-size-h6: 0.8rem; + --font-size-regular: 0.8rem; + --font-size-list: var(--font-size-regular); + --font-size-code: var(--font-size-regular); + + --line-height-h1: 1.8rem; + --line-height-h2: 1.6rem; + --line-height-h3: 1.4rem; + --line-height-h4: 1.2rem; + --line-height-h5: 1.2rem; + --line-height-h6: 1.2rem; + --line-height-regular: 1.2rem; + --line-height-list: var(--line-height-regular); + --line-height-code: var(--line-height-regular); + } + + &.sans-serif { + --font-style: sans-serif; + } + + &.serif { + --font-style: serif; + } + + &.monospace { + --font-style: monospace; + } +} + .ProseMirror { - --font-size-h1: 1.5rem; - --font-size-h2: 1.3125rem; - --font-size-h3: 1.125rem; - --font-size-h4: 0.9375rem; - --font-size-h5: 0.8125rem; - --font-size-h6: 0.75rem; - --font-size-regular: 0.9375rem; - --font-size-list: var(--font-size-regular); + position: relative; + word-wrap: break-word; + white-space: pre-wrap; + -moz-tab-size: 4; + tab-size: 4; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + outline: none; + cursor: text; + font-family: var(--font-style); + font-size: var(--font-size-regular); + line-height: 1.2; + color: inherit; + -moz-box-sizing: border-box; + box-sizing: border-box; + appearance: textfield; + -webkit-appearance: textfield; + -moz-appearance: textfield; } +/* Placeholder only for the first line in an empty editor. */ .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; @@ -17,6 +88,15 @@ height: 0; } +/* Display Placeholders on every new line. */ +.ProseMirror p.is-empty::before { + content: attr(data-placeholder); + float: left; + color: rgb(var(--color-text-400)); + pointer-events: none; + height: 0; +} + .ProseMirror li blockquote { margin-top: 10px; padding-inline-start: 1em; @@ -40,28 +120,23 @@ display: none; } -.ProseMirror .is-empty::before { - content: attr(data-placeholder); - float: left; - color: rgb(var(--color-text-400)); - pointer-events: none; - height: 0; -} - /* Custom image styles */ .ProseMirror img { - transition: filter 0.1s ease-in-out; - margin-top: 8px; + margin-top: 0 !important; margin-bottom: 0; - &:hover { - cursor: pointer; - filter: brightness(90%); - } + &:not(.read-only-image):not(.loading-image) { + transition: filter 0.1s ease-in-out; - &.ProseMirror-selectednode { - outline: 3px solid rgba(var(--color-primary-100)); - filter: brightness(90%); + &:hover { + cursor: pointer; + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + outline: 3px solid rgba(var(--color-primary-100)); + filter: brightness(90%); + } } } @@ -98,13 +173,13 @@ ul[data-type="taskList"] li > label input[type="checkbox"]:hover { background-color: rgba(var(--color-background-80)) !important; } -ul[data-type="taskList"] li > label input[type="checkbox"]:checked { +ul[data-type="taskList"] li > label input[type="checkbox"][checked] { background-color: rgba(var(--color-primary-100)) !important; border-color: rgba(var(--color-primary-100)) !important; color: white !important; } -ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover { +ul[data-type="taskList"] li > label input[type="checkbox"][checked]:hover { background-color: rgba(var(--color-primary-300)) !important; border-color: rgba(var(--color-primary-300)) !important; } @@ -157,7 +232,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] { clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); } - &:checked::before { + &[checked]::before { transform: scale(1) translate(-50%, -50%); } } @@ -168,8 +243,7 @@ ul[data-type="taskList"] li > div > p { ul[data-type="taskList"] li[data-checked="true"] > div > p { color: rgb(var(--color-text-400)); - text-decoration: line-through; - text-decoration-thickness: 2px; + transition: color 0.2s ease; } /* end to-do list */ @@ -179,29 +253,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { max-width: 400px !important; } -.ProseMirror { - position: relative; - word-wrap: break-word; - white-space: pre-wrap; - -moz-tab-size: 4; - tab-size: 4; - -webkit-user-select: text; - -moz-user-select: text; - -ms-user-select: text; - user-select: text; - outline: none; - cursor: text; - line-height: 1.2; - font-family: inherit; - font-size: var(--font-size-regular); - color: inherit; - -moz-box-sizing: border-box; - box-sizing: border-box; - appearance: textfield; - -webkit-appearance: textfield; - -moz-appearance: textfield; -} - .fade-in { opacity: 1; transition: opacity 0.3s ease-in; @@ -212,27 +263,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { transition: opacity 0.2s ease-out; } -.img-placeholder { - position: relative; - width: 35%; - margin-top: 0 !important; - margin-bottom: 0 !important; - - &::before { - content: ""; - box-sizing: border-box; - position: absolute; - top: 50%; - left: 45%; - width: 20px; - height: 20px; - border-radius: 50%; - border: 3px solid rgba(var(--color-text-200)); - border-top-color: rgba(var(--color-text-800)); - animation: spinning 0.6s linear infinite; - } -} - @keyframes spinning { to { transform: rotate(360deg); @@ -248,6 +278,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { opacity: 0; } +/* code block, inline code */ .ProseMirror pre { font-family: JetBrainsMono, monospace; tab-size: 2; @@ -256,10 +287,14 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { .ProseMirror pre code { background: none; color: inherit; - font-size: 0.8rem; padding: 0; } +.ProseMirror code { + font-size: var(--font-size-code); +} +/* end code block, inline code */ + div[data-type="horizontalRule"] { line-height: 0; padding: 0.25rem 0; @@ -342,48 +377,48 @@ ul[data-type="taskList"] ul[data-type="taskList"] { margin-top: 2rem; margin-bottom: 4px; font-size: var(--font-size-h1); + line-height: var(--line-height-h1); font-weight: 600; - line-height: 1.3; } .prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1.4rem; margin-bottom: 1px; font-size: var(--font-size-h2); + line-height: var(--line-height-h2); font-weight: 600; - line-height: 1.3; } .prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; font-size: var(--font-size-h3); + line-height: var(--line-height-h3); font-weight: 600; - line-height: 1.3; } .prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; font-size: var(--font-size-h4); + line-height: var(--line-height-h4); font-weight: 600; - line-height: 1.5; } .prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; font-size: var(--font-size-h5); + line-height: var(--line-height-h5); font-weight: 600; - line-height: 1.5; } .prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) { margin-top: 1rem; margin-bottom: 1px; font-size: var(--font-size-h6); + line-height: var(--line-height-h6); font-weight: 600; - line-height: 1.5; } .prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) { @@ -391,13 +426,13 @@ ul[data-type="taskList"] ul[data-type="taskList"] { margin-bottom: 1px; padding: 3px 0; font-size: var(--font-size-regular); - line-height: 1.5; + line-height: var(--line-height-regular); } .prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p, .prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p { font-size: var(--font-size-list); - line-height: 1.5; + line-height: var(--line-height-list); } .prose :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) { diff --git a/packages/editor/src/styles/table.css b/packages/editor/src/styles/table.css index 6b45abcf558..2a0140a2bfc 100644 --- a/packages/editor/src/styles/table.css +++ b/packages/editor/src/styles/table.css @@ -12,10 +12,6 @@ width: 100%; } -.table-wrapper table p { - font-size: 14px; -} - .table-wrapper table td, .table-wrapper table th { min-width: 1em; @@ -115,4 +111,3 @@ opacity: 0; pointer-events: none; } - diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index cfe8401f6f9..8edd9106fc2 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -1,13 +1,19 @@ { - "extends": "tsconfig/react-library.json", - "include": ["src/**/*", "index.d.ts"], - "exclude": ["dist", "build", "node_modules"], "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2015", "DOM"], + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES6", + "sourceMap": true, "baseUrl": ".", "paths": { "@/*": ["src/core/*"], "@/styles/*": ["src/styles/*"], "@/plane-editor/*": ["src/ce/*"] - } - } + }, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*", "index.d.ts"], + "exclude": ["dist", "build", "node_modules"] } diff --git a/packages/editor/tsup.config.ts b/packages/editor/tsup.config.ts index 5e89e04afad..c378c0b2b2d 100644 --- a/packages/editor/tsup.config.ts +++ b/packages/editor/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig, Options } from "tsup"; export default defineConfig((options: Options) => ({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/lib.ts"], format: ["cjs", "esm"], dts: true, clean: false, diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json deleted file mode 100644 index 161ca7182c8..00000000000 --- a/packages/eslint-config-custom/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "eslint-config-custom", - "private": true, - "version": "0.22.0", - "main": "index.js", - "license": "MIT", - "devDependencies": {}, - "dependencies": { - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", - "eslint": "^8.57.0", - "eslint-config-next": "^14.1.0", - "eslint-config-prettier": "^9.1.0", - "eslint-config-turbo": "^1.12.4", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-react": "^7.33.2", - "typescript": "^5.3.3" - } -} diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config/library.js similarity index 53% rename from packages/eslint-config-custom/index.js rename to packages/eslint-config/library.js index 6f651f08e1d..283590693eb 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config/library.js @@ -1,20 +1,26 @@ +const { resolve } = require("node:path"); + +const project = resolve(process.cwd(), "tsconfig.json"); + +/** @type {import("eslint").Linter.Config} */ module.exports = { - extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"], - parser: "@typescript-eslint/parser", - parserOptions: { - ecmaVersion: 2021, // Or the ECMAScript version you are using - sourceType: "module", // Or 'script' if you're using CommonJS or other modules - }, + extends: ["prettier", "plugin:@typescript-eslint/recommended"], plugins: ["react", "@typescript-eslint", "import"], + globals: { + React: true, + JSX: true, + }, + env: { + node: true, + browser: true, + }, settings: { - next: { - rootDir: ["web/", "space/", "admin/", "packages/*/"], + "import/resolver": { + typescript: { + project, + }, }, }, - globals: { - React: "readonly", - JSX: "readonly", - }, rules: { "no-useless-escape": "off", "prefer-const": "error", @@ -32,18 +38,12 @@ 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"], + // "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-unused-expressions": "warn", + "@typescript-eslint/no-unused-vars": ["warn"], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-useless-empty-export": "error", - "@typescript-eslint/prefer-ts-expect-error": "error", - "@typescript-eslint/naming-convention": [ - "error", - { - selector: ["function", "variable"], - format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"], - leadingUnderscore: "allow", - }, - ], + "@typescript-eslint/prefer-ts-expect-error": "warn", }, + ignorePatterns: [".*.js", "node_modules/", "dist/"], }; diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js new file mode 100644 index 00000000000..543cd131a42 --- /dev/null +++ b/packages/eslint-config/next.js @@ -0,0 +1,92 @@ +const { resolve } = require("node:path"); +const project = resolve(process.cwd(), "tsconfig.json"); + +module.exports = { + extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"], + globals: { + React: "readonly", + JSX: "readonly", + }, + env: { + node: true, + browser: true, + }, + plugins: ["react", "@typescript-eslint", "import"], + settings: { + "import/resolver": { + typescript: { + project, + }, + }, + }, + ignorePatterns: [".*.js", "node_modules/"], + 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", + "react/jsx-key": "error", + "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-expressions": "warn", + "@typescript-eslint/no-unused-vars": ["warn"], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-useless-empty-export": "error", + "@typescript-eslint/prefer-ts-expect-error": "warn", + "@typescript-eslint/naming-convention": [ + "warn", + { + selector: "variable", + format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"], + leadingUnderscore: "allow", + }, + ], + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroups: [ + { + pattern: "react", + group: "external", + position: "before", + }, + { + pattern: "lucide-react", + group: "external", + position: "after", + }, + { + pattern: "@headlessui/**", + 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/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 00000000000..335047356ee --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,21 @@ +{ + "name": "@plane/eslint-config", + "private": true, + "version": "0.23.1", + "files": [ + "library.js", + "next.js", + "server.js" + ], + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.6.0", + "@typescript-eslint/parser": "^8.6.0", + "eslint": "8", + "eslint-config-next": "^14.1.0", + "eslint-config-prettier": "^9.1.0", + "eslint-config-turbo": "^1.12.4", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react": "^7.33.2", + "typescript": "5.3.3" + } +} diff --git a/packages/eslint-config/server.js b/packages/eslint-config/server.js new file mode 100644 index 00000000000..824e2537571 --- /dev/null +++ b/packages/eslint-config/server.js @@ -0,0 +1,11 @@ +module.exports = { + extends: ["eslint:recommended"], + env: { + node: true, + es6: true, + }, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, +}; diff --git a/packages/helpers/hooks/index.ts b/packages/helpers/hooks/index.ts new file mode 100644 index 00000000000..c7a8f4c06b8 --- /dev/null +++ b/packages/helpers/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-outside-click-detector"; diff --git a/packages/helpers/hooks/use-outside-click-detector.tsx b/packages/helpers/hooks/use-outside-click-detector.tsx new file mode 100644 index 00000000000..9436b51bf37 --- /dev/null +++ b/packages/helpers/hooks/use-outside-click-detector.tsx @@ -0,0 +1,29 @@ +import React, { useEffect } from "react"; + +export const useOutsideClickDetector = ( + ref: React.RefObject | any, + callback: () => void, + useCapture = false +) => { + const handleClick = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as any)) { + // check for the closest element with attribute name data-prevent-outside-click + const preventOutsideClickElement = ( + event.target as unknown as HTMLElement | undefined + )?.closest("[data-prevent-outside-click]"); + // if the closest element with attribute name data-prevent-outside-click is found, return + if (preventOutsideClickElement) { + return; + } + // else call the callback + callback(); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", handleClick, useCapture); + return () => { + document.removeEventListener("mousedown", handleClick, useCapture); + }; + }); +}; diff --git a/packages/helpers/index.ts b/packages/helpers/index.ts new file mode 100644 index 00000000000..007f69d09c5 --- /dev/null +++ b/packages/helpers/index.ts @@ -0,0 +1 @@ +export * from "./hooks"; diff --git a/packages/helpers/package.json b/packages/helpers/package.json new file mode 100644 index 00000000000..b4b94db1f5f --- /dev/null +++ b/packages/helpers/package.json @@ -0,0 +1,24 @@ +{ + "name": "@plane/helpers", + "version": "0.23.1", + "description": "Helper functions shared across multiple apps internally", + "private": true, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "scripts": { + "build": "tsup ./index.ts --format esm,cjs --dts --external react --minify" + }, + "devDependencies": { + "@types/node": "^22.5.4", + "@types/react": "^18.3.5", + "typescript": "^5.6.2", + "tsup": "^7.2.0" + }, + "dependencies": { + "react": "^18.3.1" + } +} diff --git a/packages/helpers/tsconfig.json b/packages/helpers/tsconfig.json new file mode 100644 index 00000000000..f9715d3d8b1 --- /dev/null +++ b/packages/helpers/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@plane/typescript-config/react-library.json", + "compilerOptions": { + "jsx": "react", + "lib": ["esnext", "dom"] + }, + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index b40c4f37cdd..cec6628a644 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.22.0", + "version": "0.23.1", "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 7cece3a9b5c..4f57a3a6487 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -18,8 +18,8 @@ module.exports = { "./pages/**/*.tsx", "./app/**/*.tsx", "./ui/**/*.tsx", - "../packages/ui/**/*.{js,ts,jsx,tsx}", - "../packages/editor/**/src/**/*.{js,ts,jsx,tsx}", + "../packages/ui/src/**/*.{js,ts,jsx,tsx}", + "../packages/editor/src/**/*.{js,ts,jsx,tsx}", "!../packages/ui/**/*.stories{js,ts,jsx,tsx}", ], }, @@ -270,7 +270,7 @@ module.exports = { "--tw-prose-headings": convertToRGB("--color-text-100"), "--tw-prose-lead": convertToRGB("--color-text-100"), "--tw-prose-links": convertToRGB("--color-primary-100"), - "--tw-prose-bold": convertToRGB("--color-text-100"), + "--tw-prose-bold": "inherit", "--tw-prose-counters": convertToRGB("--color-text-100"), "--tw-prose-bullets": convertToRGB("--color-text-100"), "--tw-prose-hr": convertToRGB("--color-text-100"), @@ -333,6 +333,8 @@ module.exports = { 72: "16.2rem", 80: "18rem", 96: "21.6rem", + "page-x": "1.35rem", + "page-y": "1.35rem", }, margin: { 0: "0", @@ -434,5 +436,26 @@ module.exports = { custom: ["Inter", "sans-serif"], }, }, - plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], + plugins: [ + require("tailwindcss-animate"), + require("@tailwindcss/typography"), + function ({ addUtilities }) { + const newUtilities = { + // Mobile screens + ".px-page-x": { + paddingLeft: "1.25rem", + paddingRight: "1.25rem", + }, + // Medium screens (768px and up) + "@media (min-width: 768px)": { + ".px-page-x": { + paddingLeft: "1.35rem", + paddingRight: "1.35rem", + }, + }, + }; + + addUtilities(newUtilities, ["responsive"]); + }, + ], }; diff --git a/packages/types/package.json b/packages/types/package.json index 3d7d663f68d..5962ca25c9d 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,7 @@ { "name": "@plane/types", - "version": "0.22.0", + "version": "0.23.1", "private": true, + "types": "./src/index.d.ts", "main": "./src/index.d.ts" } diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts index 6a8c725a82d..5fe31ad0006 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -19,5 +19,6 @@ export type TLogoProps = { icon?: { name?: string; color?: string; + background_color?: string; }; }; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index b0528ccc113..fdcffb52b39 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -43,6 +43,18 @@ export type TCycleEstimateDistribution = { completion_chart: TCycleCompletionChartDistribution; labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[]; }; +export type TCycleProgress = { + date: string; + started: number; + actual: number; + pending: number; + ideal: number | null; + scope: number; + completed: number; + unstarted: number; + backlog: number; + cancelled: number; +}; export type TProgressSnapshot = { total_issues: number; @@ -61,6 +73,10 @@ export type TProgressSnapshot = { estimate_distribution?: TCycleEstimateDistribution; }; +export interface IProjectDetails { + id: string; +} + export interface ICycle extends TProgressSnapshot { progress_snapshot: TProgressSnapshot | undefined; @@ -85,6 +101,9 @@ export interface ICycle extends TProgressSnapshot { filters: IIssueFilterOptions; }; workspace_id: string; + project_detail: IProjectDetails; + progress: any[]; + version: number; } export interface CycleIssueResponse { @@ -111,4 +130,5 @@ export type CycleDateCheckData = { cycle_id?: string; }; -export type TCyclePlotType = "burndown" | "points"; +export type TCycleEstimateType = "issues" | "points"; +export type TCyclePlotType = "burndown" | "burnup"; diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 9abd1bf22c7..3b1c825a0ab 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -109,6 +109,7 @@ export type TWidgetIssue = TIssue & { project_id: string; relation_type: TIssueRelationTypes; sequence_id: number; + type_id: string | null; }[]; }; @@ -145,17 +146,8 @@ export type TRecentActivityWidgetResponse = IIssueActivity; export type TRecentProjectsWidgetResponse = string[]; export type TRecentCollaboratorsWidgetResponse = { - count: number; - extra_stats: Object | null; - next_cursor: string; - next_page_results: boolean; - prev_cursor: string; - prev_page_results: boolean; - results: { - active_issue_count: number; - user_id: string; - }[]; - total_pages: number; + active_issue_count: number; + user_id: string; }; export type TWidgetStatsResponse = @@ -166,7 +158,7 @@ export type TWidgetStatsResponse = | TCreatedIssuesWidgetResponse | TRecentActivityWidgetResponse[] | TRecentProjectsWidgetResponse - | TRecentCollaboratorsWidgetResponse; + | TRecentCollaboratorsWidgetResponse[]; // dashboard export type TDashboard = { diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 08949bd1792..914ebb0c3de 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -1,10 +1,14 @@ -export enum EUserProjectRoles { - GUEST = 5, - VIEWER = 10, - MEMBER = 15, +export enum EUserPermissions { ADMIN = 20, + MEMBER = 15, + GUEST = 5, } +export type TUserPermissions = + | EUserPermissions.ADMIN + | EUserPermissions.MEMBER + | EUserPermissions.GUEST; + // project pages export enum EPageAccess { PUBLIC = 0, diff --git a/packages/types/src/favorite/favorite.d.ts b/packages/types/src/favorite/favorite.d.ts new file mode 100644 index 00000000000..65df793840b --- /dev/null +++ b/packages/types/src/favorite/favorite.d.ts @@ -0,0 +1,19 @@ +import { TLogoProps } from "../common"; + +export type IFavorite = { + id: string; + name: string; + entity_type: string; + entity_data: { + id?: string; + name: string; + logo_props?: TLogoProps | undefined; + }; + is_folder: boolean; + sort_order: number; + parent: string | null; + entity_identifier?: string | null; + children: IFavorite[]; + project_id: string | null; + sequence: number; +}; diff --git a/packages/types/src/favorite/index.d.ts b/packages/types/src/favorite/index.d.ts new file mode 100644 index 00000000000..e11ce8f3c1f --- /dev/null +++ b/packages/types/src/favorite/index.d.ts @@ -0,0 +1 @@ +export * from "./favorite"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 353aeaf08da..6dfddc6b638 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,3 +28,4 @@ export * from "./common"; export * from "./pragmatic"; export * from "./publish"; export * from "./workspace-notifications"; +export * from "./favorite"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index dcf3c57384c..5a3dd11347f 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -54,6 +54,9 @@ export interface IInstanceConfig { app_base_url: string | undefined; space_base_url: string | undefined; admin_base_url: string | undefined; + // intercom + is_intercom_enabled: boolean; + intercom_app_id: string | undefined; } export interface IInstanceAdmin { @@ -68,11 +71,16 @@ export interface IInstanceAdmin { user_detail: IUserLite; } +export type TInstanceIntercomConfigurationKeys = + | "IS_INTERCOM_ENABLED" + | "INTERCOM_APP_ID"; + export type TInstanceConfigurationKeys = | TInstanceAIConfigurationKeys | TInstanceEmailConfigurationKeys | TInstanceImageConfigurationKeys - | TInstanceAuthenticationKeys; + | TInstanceAuthenticationKeys + | TInstanceIntercomConfigurationKeys; export interface IInstanceConfiguration { id: string; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index 0f2bb8af262..eff81f857b2 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -140,6 +140,7 @@ export interface IIssueActivity { name: string; priority: string | null; sequence_id: string; + type_id: string; } | null; new_identifier: string | null; new_value: string | null; diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts index 9f17d78c7de..82b881fd940 100644 --- a/packages/types/src/issues/activity/base.d.ts +++ b/packages/types/src/issues/activity/base.d.ts @@ -55,4 +55,14 @@ export type TIssueActivityComment = id: string; activity_type: "ACTIVITY"; created_at?: string; + } + | { + id: string; + activity_type: "WORKLOG"; + created_at?: string; + } + | { + id: string; + activity_type: "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY"; + created_at?: string; }; diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index 1ad8530cd97..8292c111649 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -10,7 +10,12 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; -export type TLoader = "init-loader" | "mutation" | "pagination" | undefined; +export type TLoader = + | "init-loader" + | "mutation" + | "pagination" + | "loaded" + | undefined; export type TGroupedIssues = { [group_id: string]: string[]; @@ -36,4 +41,4 @@ export type TGroupedIssueCount = { [group_id: string]: number; }; -export type TUnGroupedIssues = string[]; \ No newline at end of file +export type TUnGroupedIssues = string[]; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index d86ab24d231..1584a3d16ce 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -25,6 +25,7 @@ export type TBaseIssue = { parent_id: string | null; cycle_id: string | null; module_ids: string[] | null; + type_id: string | null; created_at: string; updated_at: string; @@ -42,15 +43,14 @@ export type TBaseIssue = { export type TIssue = TBaseIssue & { description_html?: string; is_subscribed?: boolean; - - parent?: partial; - + parent?: Partial; issue_reactions?: TIssueReaction[]; issue_attachment?: TIssueAttachment[]; issue_link?: TIssueLink[]; - // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; + // sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response. + sourceIssueId?: string; }; export type TIssueMap = { @@ -84,7 +84,7 @@ export type TIssuesResponse = { total_pages: number; extra_stats: null; results: TIssueResponseResults; -} +}; export type TBulkIssueProperties = Pick< TIssue, @@ -94,9 +94,18 @@ export type TBulkIssueProperties = Pick< | "assignee_ids" | "start_date" | "target_date" + | "module_ids" + | "cycle_id" + | "estimate_point" >; export type TBulkOperationsPayload = { issue_ids: string[]; properties: Partial; }; + +export type TIssueDetailWidget = + | "sub-issues" + | "relations" + | "links" + | "attachments"; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 9b7249bdc9e..011f92d69ba 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -46,3 +46,27 @@ export type TPageFilters = { sortBy: TPageFiltersSortBy; filters?: TPageFilterProps; }; + +export type TPageEmbedType = "mention" | "issue"; + +export type TPageVersion = { + created_at: string; + created_by: string; + deleted_at: string | null; + description_binary?: string | null; + description_html?: string | null; + description_json?: object; + id: string; + last_saved_at: string; + owned_by: string; + page: string; + updated_at: string; + updated_by: string; + workspace: string; +} + +export type TDocumentPayload = { + description_binary: string; + description_html: string; + description: object; +} \ No newline at end of file diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index 59ccf73b6e2..a46f490f16f 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -1,4 +1,3 @@ -import { EUserProjectRoles } from "@/constants/project"; import type { IProjectViewProps, IUser, @@ -9,6 +8,7 @@ import type { TLogoProps, TStateGroups, } from ".."; +import { TUserPermissions } from "../enums"; export interface IProject { archive_in: number; @@ -30,13 +30,16 @@ export interface IProject { draft_issues: number; draft_sub_issues: number; estimate: string | null; + guest_view_all_features: boolean; id: string; identifier: string; anchor: string | null; is_favorite: boolean; + is_issue_type_enabled: boolean; is_member: boolean; + is_time_tracking_enabled: boolean; logo_props: TLogoProps; - member_role: EUserProjectRoles | null; + member_role: TUserPermissions | null; members: IProjectMemberLite[]; name: string; network: number; @@ -57,6 +60,7 @@ export interface IProjectLite { id: string; name: string; identifier: string; + logo_props: TLogoProps; } type ProjectPreferences = { @@ -82,7 +86,7 @@ export interface IProjectMember { project: IProjectLite; workspace: IWorkspaceLite; comment: string; - role: EUserProjectRoles; + role: TUserPermissions; preferences: ProjectPreferences; @@ -98,11 +102,11 @@ export interface IProjectMember { export interface IProjectMembership { id: string; member: string; - role: EUserProjectRoles; + role: TUserPermissions; } export interface IProjectBulkAddFormData { - members: { role: EUserProjectRoles; member_id: string }[]; + members: { role: TUserPermissions; member_id: string }[]; } export interface IGithubRepository { @@ -141,4 +145,5 @@ export interface ISearchIssueResponse { state__group: TStateGroups; state__name: string; workspace__slug: string; + type_id: string; } diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 99ba7a4a8ca..4d5db28f9c5 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -1,9 +1,5 @@ -import { - EUserProjectRoles, - IIssueActivity, - TIssuePriorities, - TStateGroups, -} from "."; +import { IIssueActivity, TIssuePriorities, TStateGroups } from "."; +import { TUserPermissions } from "./enums"; type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google"; @@ -134,7 +130,6 @@ export interface IUserActivityResponse { export type UserAuth = { isMember: boolean; isOwner: boolean; - isViewer: boolean; isGuest: boolean; }; @@ -175,7 +170,7 @@ export interface IUserProfileProjectSegregation { } export interface IUserProjectsRole { - [projectId: string]: EUserProjectRoles; + [projectId: string]: TUserPermissions; } export interface IUserEmailNotificationSettings { diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 82302dda156..59d5ffded52 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -51,7 +51,7 @@ export type TIssueOrderByOptions = | "sub_issues_count" | "-sub_issues_count"; -export type TIssueTypeFilters = "active" | "backlog" | null; +export type TIssueGroupingFilters = "active" | "backlog" | null; export type TIssueExtraOptions = "show_empty_groups" | "sub_issue"; @@ -76,7 +76,8 @@ export type TIssueParams = | "sub_issue" | "show_empty_groups" | "cursor" - | "per_page"; + | "per_page" + | "issue_type"; export type TCalendarLayouts = "month" | "week"; @@ -94,6 +95,7 @@ export interface IIssueFilterOptions { state_group?: string[] | null; subscriber?: string[] | null; target_date?: string[] | null; + issue_type?: string[] | null; } export interface IIssueDisplayFilterOptions { @@ -107,7 +109,7 @@ export interface IIssueDisplayFilterOptions { order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; - type?: TIssueTypeFilters; + type?: TIssueGroupingFilters; } export interface IIssueDisplayProperties { assignee?: boolean; @@ -125,6 +127,7 @@ export interface IIssueDisplayProperties { updated_on?: boolean; modules?: boolean; cycle?: boolean; + issue_type?: boolean; } export type TIssueKanbanFilters = { @@ -202,4 +205,6 @@ export interface IssuePaginationOptions { before?: string; after?: string; groupedBy?: TIssueGroupByOptions; + subGroupedBy?: TIssueGroupByOptions; + orderBy?: TIssueOrderByOptions; } diff --git a/packages/types/src/views.d.ts b/packages/types/src/views.d.ts index 1c61ab69c6d..54e1a395c32 100644 --- a/packages/types/src/views.d.ts +++ b/packages/types/src/views.d.ts @@ -25,9 +25,21 @@ export interface IProjectView { workspace: string; logo_props: TLogoProps | undefined; is_locked: boolean; + anchor?: string; owned_by: string; } +export type TPublishViewSettings = { + is_comments_enabled: boolean; + is_reactions_enabled: boolean; + is_votes_enabled: boolean; +}; + +export type TPublishViewDetails = TPublishViewSettings & { + id: string; + anchor: string; +}; + export type TViewFiltersSortKey = "name" | "created_at" | "updated_at"; export type TViewFiltersSortBy = "asc" | "desc"; diff --git a/packages/types/src/workspace-notifications.d.ts b/packages/types/src/workspace-notifications.d.ts index 0e5bb0975fc..7d960015b9b 100644 --- a/packages/types/src/workspace-notifications.d.ts +++ b/packages/types/src/workspace-notifications.d.ts @@ -28,7 +28,7 @@ export type TNotificationData = { actor: string | undefined; field: string | undefined; issue_comment: string | undefined; - verb: "created" | "updated"; + verb: "created" | "updated" | "deleted"; new_value: string | undefined; old_value: string | undefined; }; @@ -51,6 +51,7 @@ export type TNotification = { archived_at: string | undefined; snoozed_till: string | undefined; is_inbox_issue: boolean | undefined; + is_mentioned_notification: boolean | undefined; workspace: string | undefined; project: string | undefined; created_at: string | undefined; @@ -64,6 +65,7 @@ export type TNotificationPaginatedInfoQueryParams = { type?: string | undefined; snoozed?: boolean; archived?: boolean; + mentioned?: boolean; read?: boolean; per_page?: number; cursor?: string; @@ -86,9 +88,10 @@ export type TNotificationPaginatedInfo = { // notification count export type TUnreadNotificationsCount = { total_unread_notifications_count: number; + mention_unread_notifications_count: number; }; -export type TCurrentSelectedNotification = { +export type TNotificationLite = { workspace_slug: string | undefined; project_id: string | undefined; notification_id: string | undefined; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 4e40009e15d..f72f52463e3 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -1,10 +1,11 @@ -import { EUserWorkspaceRoles } from "@/constants/workspace"; import type { + ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, } from "@plane/types"; +import { TUserPermissions } from "./enums"; export interface IWorkspace { readonly id: string; @@ -35,7 +36,7 @@ export interface IWorkspaceMemberInvitation { id: string; message: string; responded_at: Date; - role: EUserWorkspaceRoles; + role: TUserPermissions; token: string; workspace: { id: string; @@ -46,7 +47,7 @@ export interface IWorkspaceMemberInvitation { } export interface IWorkspaceBulkInviteFormData { - emails: { email: string; role: EUserWorkspaceRoles }[]; + emails: { email: string; role: TUserPermissions }[]; } export type Properties = { @@ -68,7 +69,15 @@ export type Properties = { export interface IWorkspaceMember { id: string; member: IUserLite; - role: EUserWorkspaceRoles; + role: TUserPermissions; + created_at?: string; + avatar?: string; + email?: string; + first_name?: string; + last_name?: string; + joining_date?: string; + display_name?: string; + last_login_medium?: string; } export interface IWorkspaceMemberMe { @@ -78,7 +87,7 @@ export interface IWorkspaceMemberMe { default_props: IWorkspaceViewProps; id: string; member: string; - role: EUserWorkspaceRoles; + role: TUserPermissions; updated_at: Date; updated_by: string; view_props: IWorkspaceViewProps; @@ -110,6 +119,7 @@ export interface IWorkspaceIssueSearchResult { project_id: string; sequence_id: number; workspace__slug: string; + type_id: string; } export interface IWorkspacePageSearchResult { @@ -190,3 +200,25 @@ export interface IProductUpdateResponse { eyes: number; }; } + +export interface IWorkspaceActiveCyclesResponse { + count: number; + extra_stats: null; + next_cursor: string; + next_page_results: boolean; + prev_cursor: string; + prev_page_results: boolean; + results: ICycle[]; + total_pages: number; +} + +export interface IWorkspaceProgressResponse { + completed_issues: number; + total_issues: number; + started_issues: number; + cancelled_issues: number; + unstarted_issues: number; +} +export interface IWorkspaceAnalyticsResponse { + completion_chart: any; +} diff --git a/packages/tsconfig/base.json b/packages/typescript-config/base.json similarity index 83% rename from packages/tsconfig/base.json rename to packages/typescript-config/base.json index 2825abe07ce..c98cc5b968e 100644 --- a/packages/tsconfig/base.json +++ b/packages/typescript-config/base.json @@ -9,14 +9,13 @@ "forceConsistentCasingInFileNames": true, "inlineSources": false, "isolatedModules": true, - "moduleResolution": "node", + "module": "NodeNext", + "moduleResolution": "NodeNext", "noUnusedLocals": false, "noUnusedParameters": false, "preserveWatchOutput": true, "skipLibCheck": true, "strict": true }, - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/packages/tsconfig/nextjs.json b/packages/typescript-config/nextjs.json similarity index 84% rename from packages/tsconfig/nextjs.json rename to packages/typescript-config/nextjs.json index 3b7dfa900d1..2d2f1c72341 100644 --- a/packages/tsconfig/nextjs.json +++ b/packages/typescript-config/nextjs.json @@ -3,6 +3,7 @@ "display": "Next.js", "extends": "./base.json", "compilerOptions": { + "plugins": [{ "name": "next" }], "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -12,7 +13,8 @@ "noEmit": true, "incremental": true, "esModuleInterop": true, - "module": "esnext", + "module": "ESNext", + "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" diff --git a/packages/tsconfig/package.json b/packages/typescript-config/package.json similarity index 62% rename from packages/tsconfig/package.json rename to packages/typescript-config/package.json index 9e5d22eccf5..f6b12920c7e 100644 --- a/packages/tsconfig/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { - "name": "tsconfig", - "version": "0.22.0", + "name": "@plane/typescript-config", + "version": "0.23.1", "private": true, "files": [ "base.json", diff --git a/packages/tsconfig/react-library.json b/packages/typescript-config/react-library.json similarity index 52% rename from packages/tsconfig/react-library.json rename to packages/typescript-config/react-library.json index 211c87d8d8e..47cc9ef898a 100644 --- a/packages/tsconfig/react-library.json +++ b/packages/typescript-config/react-library.json @@ -3,10 +3,9 @@ "display": "React Library", "extends": "./base.json", "compilerOptions": { - "jsx": "react-jsx", - "lib": ["ES2015", "DOM"], - "module": "ESNext", - "target": "es6", - "sourceMap": true - } + "lib": ["ES2015"], + "target": "ES6", + "jsx": "react-jsx" + }, + "exclude": ["node_modules"] } diff --git a/packages/ui/.eslintrc.js b/packages/ui/.eslintrc.js new file mode 100644 index 00000000000..1b79b55f34e --- /dev/null +++ b/packages/ui/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@plane/eslint-config/library.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: __dirname, + }, +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 3f3ee777b8d..09019457aee 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.22.0", + "version": "0.23.1", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -17,7 +17,8 @@ "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "postcss": "postcss styles/globals.css -o styles/output.css --watch" + "postcss": "postcss styles/globals.css -o styles/output.css --watch", + "lint": "eslint src --ext .ts,.tsx" }, "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.1.10", @@ -26,6 +27,7 @@ "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^1.7.3", "@popperjs/core": "^2.11.8", + "@plane/helpers": "*", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", "lodash": "^4.17.21", @@ -49,21 +51,22 @@ "@storybook/react": "^8.1.1", "@storybook/react-webpack5": "^8.1.1", "@storybook/test": "^8.1.1", + "@types/lodash": "^4.17.6", "@types/node": "^20.5.2", "@types/react": "^18.2.42", "@types/react-color": "^3.0.9", "@types/react-dom": "^18.2.17", "autoprefixer": "^10.4.19", "classnames": "^2.3.2", - "eslint-config-custom": "*", + "@plane/eslint-config": "*", "postcss-cli": "^11.0.0", "postcss-nested": "^6.0.1", "react": "^18.2.0", "storybook": "^8.1.1", "tailwind-config-custom": "*", "tailwindcss": "^3.4.3", - "tsconfig": "*", - "tsup": "^5.10.1", - "typescript": "4.7.4" + "@plane/typescript-config": "*", + "tsup": "^7.2.0", + "typescript": "5.3.3" } } diff --git a/packages/ui/src/avatar/avatar.stories.tsx b/packages/ui/src/avatar/avatar.stories.tsx index e19f4c2627f..c3053cd738d 100644 --- a/packages/ui/src/avatar/avatar.stories.tsx +++ b/packages/ui/src/avatar/avatar.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { fn } from "@storybook/test"; import { Avatar } from "./avatar"; const meta: Meta = { diff --git a/packages/ui/src/badge/helper.tsx b/packages/ui/src/badge/helper.tsx index b2e1beb4885..31f18aef7f8 100644 --- a/packages/ui/src/badge/helper.tsx +++ b/packages/ui/src/badge/helper.tsx @@ -25,6 +25,7 @@ export interface IBadgeStyling { }; } +// TODO: convert them to objects instead of enums enum badgeSizeStyling { sm = `px-2.5 py-1 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, @@ -32,10 +33,12 @@ enum badgeSizeStyling { xl = `px-5 py-3 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`, } +// TODO: convert them to objects instead of enums enum badgeIconStyling { sm = "h-3 w-3 flex justify-center items-center overflow-hidden flex-shrink-0", md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden flex-shrink-0", lg = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0", + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values xl = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0", } diff --git a/packages/ui/src/button/helper.tsx b/packages/ui/src/button/helper.tsx index ae9e85359f0..4a823aad7a8 100644 --- a/packages/ui/src/button/helper.tsx +++ b/packages/ui/src/button/helper.tsx @@ -33,7 +33,7 @@ enum buttonIconStyling { sm = "h-3 w-3 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0", md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0", lg = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0", - xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0", + xl = "h-4 w-4 flex justify-center items-center overflow-hidden my-0.5 flex-shrink-0 ", } export const buttonStyling: IButtonStyling = { diff --git a/packages/ui/src/button/toggle-switch.tsx b/packages/ui/src/button/toggle-switch.tsx index 6f76f2e7ddb..c779cb4360e 100644 --- a/packages/ui/src/button/toggle-switch.tsx +++ b/packages/ui/src/button/toggle-switch.tsx @@ -26,7 +26,7 @@ const ToggleSwitch: React.FC = (props) => { "h-4 w-6": size === "sm", "h-5 w-8": size === "md", "bg-custom-primary-100": value, - "cursor-not-allowed": disabled, + "cursor-not-allowed bg-custom-background-80": disabled, }, className )} @@ -43,7 +43,7 @@ const ToggleSwitch: React.FC = (props) => { "translate-x-3": value && size === "sm", "translate-x-4": value && size === "md", "translate-x-0.5 bg-custom-background-90": !value, - "cursor-not-allowed": disabled, + "cursor-not-allowed bg-custom-background-90": disabled, } )} /> diff --git a/packages/ui/src/card/card.tsx b/packages/ui/src/card/card.tsx new file mode 100644 index 00000000000..6030e65bb0c --- /dev/null +++ b/packages/ui/src/card/card.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import { cn } from "../../helpers"; +import { + ECardDirection, + ECardSpacing, + ECardVariant, + getCardStyle, + TCardDirection, + TCardSpacing, + TCardVariant, +} from "./helper"; + +export interface CardProps { + variant?: TCardVariant; + spacing?: TCardSpacing; + direction?: TCardDirection; + className?: string; + children: React.ReactNode; +} + +const Card = React.forwardRef((props, ref) => { + const { + variant = ECardVariant.WITH_SHADOW, + direction = ECardDirection.COLUMN, + className = "", + spacing = ECardSpacing.LG, + children, + ...rest + } = props; + + const style = getCardStyle(variant, spacing, direction); + return ( +
      + {children} +
      + ); +}); + +Card.displayName = "plane-ui-card"; + +export { Card, ECardVariant, ECardSpacing, ECardDirection }; diff --git a/packages/ui/src/card/helper.tsx b/packages/ui/src/card/helper.tsx new file mode 100644 index 00000000000..8b80d0d9330 --- /dev/null +++ b/packages/ui/src/card/helper.tsx @@ -0,0 +1,36 @@ +export enum ECardVariant { + WITHOUT_SHADOW = "without-shadow", + WITH_SHADOW = "with-shadow", +} +export enum ECardDirection { + ROW = "row", + COLUMN = "column", +} +export enum ECardSpacing { + SM = "sm", + LG = "lg", +} +export type TCardVariant = ECardVariant.WITHOUT_SHADOW | ECardVariant.WITH_SHADOW; +export type TCardDirection = ECardDirection.ROW | ECardDirection.COLUMN; +export type TCardSpacing = ECardSpacing.SM | ECardSpacing.LG; + +export interface ICardProperties { + [key: string]: string; +} + +const DEFAULT_STYLE = + "bg-custom-background-100 rounded-lg border-[0.5px] border-custom-border-200 w-full flex flex-col"; +export const containerStyle: ICardProperties = { + [ECardVariant.WITHOUT_SHADOW]: "", + [ECardVariant.WITH_SHADOW]: "hover:shadow-custom-shadow-4xl duration-300", +}; +export const spacings = { + [ECardSpacing.SM]: "p-4", + [ECardSpacing.LG]: "p-6", +}; +export const directions = { + [ECardDirection.ROW]: "flex-row space-x-3", + [ECardDirection.COLUMN]: "flex-col space-y-3", +}; +export const getCardStyle = (variant: TCardVariant, spacing: TCardSpacing, direction: TCardDirection) => + DEFAULT_STYLE + " " + directions[direction] + " " + containerStyle[variant] + " " + spacings[spacing]; diff --git a/packages/ui/src/card/index.ts b/packages/ui/src/card/index.ts new file mode 100644 index 00000000000..1d243e763f7 --- /dev/null +++ b/packages/ui/src/card/index.ts @@ -0,0 +1 @@ +export * from "./card"; diff --git a/packages/ui/src/collapsible/collapsible-button.tsx b/packages/ui/src/collapsible/collapsible-button.tsx new file mode 100644 index 00000000000..a56a724b4cb --- /dev/null +++ b/packages/ui/src/collapsible/collapsible-button.tsx @@ -0,0 +1,33 @@ +import React, { FC } from "react"; +import { DropdownIcon } from "../icons"; +import { cn } from "../../helpers"; + +type Props = { + isOpen: boolean; + title: string; + hideChevron?: boolean; + indicatorElement?: React.ReactNode; + actionItemElement?: React.ReactNode; +}; + +export const CollapsibleButton: FC = (props) => { + const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props; + return ( +
      +
      +
      + {!hideChevron && ( + + )} + {title} +
      + {indicatorElement && indicatorElement} +
      + {actionItemElement && isOpen && actionItemElement} +
      + ); +}; diff --git a/packages/ui/src/collapsible/collapsible.tsx b/packages/ui/src/collapsible/collapsible.tsx index 6c08563f0a6..0431c51c4cb 100644 --- a/packages/ui/src/collapsible/collapsible.tsx +++ b/packages/ui/src/collapsible/collapsible.tsx @@ -4,6 +4,8 @@ import { Disclosure, Transition } from "@headlessui/react"; export type TCollapsibleProps = { title: string | React.ReactNode; children: React.ReactNode; + buttonRef?: React.RefObject; + className?: string; buttonClassName?: string; isOpen?: boolean; onToggle?: () => void; @@ -11,7 +13,7 @@ export type TCollapsibleProps = { }; export const Collapsible: FC = (props) => { - const { title, children, buttonClassName, isOpen, onToggle, defaultOpen } = props; + const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props; // state const [localIsOpen, setLocalIsOpen] = useState(isOpen || defaultOpen ? true : false); @@ -31,13 +33,12 @@ export const Collapsible: FC = (props) => { }, [isOpen, onToggle]); return ( - - + + {title} { + variant?: TRowVariant; + className?: string; + children: React.ReactNode; +} +const DEFAULT_STYLE = "flex flex-col vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto"; + +const ContentWrapper = React.forwardRef((props, ref) => { + const { variant = ERowVariant.REGULAR, className = "", children, ...rest } = props; + + return ( + + {children} + + ); +}); + +ContentWrapper.displayName = "plane-ui-wrapper"; + +export { ContentWrapper }; diff --git a/packages/ui/src/content-wrapper/index.ts b/packages/ui/src/content-wrapper/index.ts new file mode 100644 index 00000000000..14eaf12a456 --- /dev/null +++ b/packages/ui/src/content-wrapper/index.ts @@ -0,0 +1 @@ +export * from "./content-wrapper"; diff --git a/packages/ui/src/dropdown/common/input-search.tsx b/packages/ui/src/dropdown/common/input-search.tsx index 10fc258e14a..984f997356d 100644 --- a/packages/ui/src/dropdown/common/input-search.tsx +++ b/packages/ui/src/dropdown/common/input-search.tsx @@ -14,10 +14,12 @@ interface IInputSearch { inputContainerClassName?: string; inputClassName?: string; inputPlaceholder?: string; + isMobile: boolean; } export const InputSearch: FC = (props) => { - const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder } = props; + const { isOpen, query, updateQuery, inputIcon, inputContainerClassName, inputClassName, inputPlaceholder, isMobile } = + props; const inputRef = useRef(null); @@ -29,10 +31,10 @@ export const InputSearch: FC = (props) => { }; useEffect(() => { - if (isOpen) { + if (isOpen && !isMobile) { inputRef.current && inputRef.current.focus(); } - }, [isOpen]); + }, [isOpen, isMobile]); return (
      @@ -38,6 +39,7 @@ export const DropdownOptions: React.FC )}
      diff --git a/packages/ui/src/dropdown/dropdown.d.ts b/packages/ui/src/dropdown/dropdown.d.ts index 8264bda21e8..74cd0e45d9d 100644 --- a/packages/ui/src/dropdown/dropdown.d.ts +++ b/packages/ui/src/dropdown/dropdown.d.ts @@ -85,6 +85,7 @@ export interface IDropdownOptions { renderItem: (({ value, selected }: { value: string; selected: boolean }) => React.ReactNode) | undefined; options: TDropdownOption[] | undefined; loader?: React.ReactNode; + isMobile?: boolean; } export interface IMultiSelectDropdownOptions extends IDropdownOptions { diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx index 3b51351173b..6b50183702c 100644 --- a/packages/ui/src/dropdown/multi-select.tsx +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -4,12 +4,13 @@ import sortBy from "lodash/sortBy"; import { Combobox } from "@headlessui/react"; // popper-js import { usePopper } from "react-popper"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // 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 @@ -104,7 +105,6 @@ export const MultiSelectDropdown: FC = (props) => { (option) => !(value ?? []).includes(option.data[option.value]), () => sortByKey && sortByKey.toLowerCase(), ]); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, options]); // hooks diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx index 06987e10ff5..1c3b05f5b6a 100644 --- a/packages/ui/src/dropdown/single-select.tsx +++ b/packages/ui/src/dropdown/single-select.tsx @@ -4,12 +4,13 @@ import sortBy from "lodash/sortBy"; import { Combobox } from "@headlessui/react"; // popper-js import { usePopper } from "react-popper"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // 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 @@ -104,7 +105,6 @@ export const Dropdown: FC = (props) => { (option) => !(value ?? []).includes(option.data[option.value]), () => sortByKey && sortByKey.toLowerCase(), ]); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, options]); // hooks diff --git a/packages/ui/src/dropdowns/combo-box.tsx b/packages/ui/src/dropdowns/combo-box.tsx new file mode 100644 index 00000000000..ab68f97c0ae --- /dev/null +++ b/packages/ui/src/dropdowns/combo-box.tsx @@ -0,0 +1,76 @@ +import { Combobox } from "@headlessui/react"; +import React, { + ElementType, + Fragment, + KeyboardEventHandler, + ReactNode, + Ref, + forwardRef, + useEffect, + useRef, + useState, +} from "react"; + +type Props = { + as?: ElementType | undefined; + ref?: Ref | undefined; + tabIndex?: number | undefined; + className?: string | undefined; + value?: string | string[] | null; + onChange?: (value: any) => void; + disabled?: boolean | undefined; + onKeyDown?: KeyboardEventHandler | undefined; + multiple?: boolean; + renderByDefault?: boolean; + button: ReactNode; + children: ReactNode; +}; + +const ComboDropDown = forwardRef((props: Props, ref) => { + const { button, renderByDefault = true, children, ...rest } = props; + + const dropDownButtonRef = useRef(null); + + const [shouldRender, setShouldRender] = useState(renderByDefault); + + const onHover = () => { + setShouldRender(true); + }; + + useEffect(() => { + const element = dropDownButtonRef.current as any; + + if (!element) return; + + element.addEventListener("mouseenter", onHover); + + return () => { + element?.removeEventListener("mouseenter", onHover); + }; + }, [dropDownButtonRef, shouldRender]); + + if (!shouldRender) { + return ( +
      + {button} +
      + ); + } + + return ( + // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error, @typescript-eslint/ban-ts-comment + // @ts-expect-error + + {button} + {children} + + ); +}); + +const ComboOptions = Combobox.Options; +const ComboOption = Combobox.Option; +const ComboInput = Combobox.Input; + +ComboDropDown.displayName = "ComboDropDown"; + +export { ComboDropDown, ComboOptions, ComboOption, ComboInput }; diff --git a/packages/ui/src/dropdowns/context-menu/root.tsx b/packages/ui/src/dropdowns/context-menu/root.tsx index bea6189de12..03fe0cf7bc5 100644 --- a/packages/ui/src/dropdowns/context-menu/root.tsx +++ b/packages/ui/src/dropdowns/context-menu/root.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // components import { ContextMenuItem } from "./item"; // helpers import { cn } from "../../../helpers"; // hooks -import useOutsideClickDetector from "../../hooks/use-outside-click-detector"; import { usePlatformOS } from "../../hooks/use-platform-os"; export type TContextMenuItem = { diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 9df40f1a861..274601822ea 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -3,9 +3,10 @@ import ReactDOM from "react-dom"; import { Menu } from "@headlessui/react"; import { usePopper } from "react-popper"; import { ChevronDown, MoreHorizontal } from "lucide-react"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // hooks import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "../hooks/use-outside-click-detector"; // helpers import { cn } from "../../helpers"; // types @@ -35,6 +36,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { tabIndex, closeOnSelect, openOnHover = false, + useCaptureForOutsideClick = false, } = props; const [referenceElement, setReferenceElement] = React.useState(null); @@ -88,10 +90,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { } }; - useOutsideClickDetector(dropdownRef, closeDropdown); + useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick); let menuItems = ( - +
      { onClick={handleMenuButtonClick} className={customButtonClassName} tabIndex={customButtonTabIndex} + disabled={disabled} > {customButton} @@ -172,6 +175,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { } ${buttonClassName}`} onClick={handleMenuButtonClick} tabIndex={customButtonTabIndex} + disabled={disabled} > {label} {!noChevron && } @@ -218,4 +222,4 @@ const MenuItem: React.FC = (props) => { CustomMenu.MenuItem = MenuItem; -export { CustomMenu }; \ No newline at end of file +export { CustomMenu }; diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index 275506ad733..7842f1531de 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -1,20 +1,25 @@ import React, { useRef, useState } from "react"; import { usePopper } from "react-popper"; import { Combobox } from "@headlessui/react"; -import { Check, ChevronDown, Search } from "lucide-react"; +import { Check, ChevronDown, Info, Search } from "lucide-react"; +import { createPortal } from "react-dom"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // hooks import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "../hooks/use-outside-click-detector"; // helpers import { cn } from "../../helpers"; // types import { ICustomSearchSelectProps } from "./helper"; +// local components +import { Tooltip } from "../tooltip"; export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { const { customButtonClassName = "", buttonClassName = "", className = "", + chevronClassName = "", customButton, placement, disabled = false, @@ -59,10 +64,12 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; + const closeDropdown = () => { setIsOpen(false); onClose && onClose(); }; + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); useOutsideClickDetector(dropdownRef, closeDropdown); @@ -90,11 +97,10 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { )} - {isOpen && ( - -
      -
      - - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
      + {isOpen && + createPortal( +
      - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - cn( - "w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none", - { - "bg-custom-background-80": active, - } - ) - } - onClick={() => { - if (!multiple) closeDropdown(); - }} - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) +
      + + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
      +
      + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + cn( + "w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none", + { + "bg-custom-background-80": active, + "text-custom-text-400 opacity-60 cursor-not-allowed": option.disabled, + } + ) + } + onClick={() => { + if (!multiple) closeDropdown(); + }} + disabled={option.disabled} + > + {({ selected }) => ( + <> + {option.content} + {selected && } + {option.tooltip && ( + <> + { + typeof option.tooltip === "string" ? ( + + + + ) : ( + option.tooltip + ) + } + + )} + + )} + + )) + ) : ( +

      No matches found

      + ) ) : ( -

      No matches found

      - ) - ) : ( -

      Loading...

      - )} +

      Loading...

      + )} +
      + {footerOption}
      - {footerOption} -
      -
      - )} + , + document.body + )} ); }} diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 4c4009b1b16..7d73cdc0475 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -2,9 +2,10 @@ import React, { useRef, useState } from "react"; import { usePopper } from "react-popper"; import { Listbox } from "@headlessui/react"; import { Check, ChevronDown } from "lucide-react"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // hooks import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "../hooks/use-outside-click-detector"; // helpers import { cn } from "../../helpers"; // types @@ -82,11 +83,16 @@ const CustomSelect = (props: ICustomSelectProps) => {
    + + + {columns.map((column) => ( + + ))} + + + + {data.map((item) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
    + {(column?.thRender && column?.thRender()) || column.content} +
    + {column.tdRender(item)} +
    + ); +}; diff --git a/packages/ui/src/tables/types.ts b/packages/ui/src/tables/types.ts new file mode 100644 index 00000000000..220e4d738b6 --- /dev/null +++ b/packages/ui/src/tables/types.ts @@ -0,0 +1,20 @@ +export type TTableColumn = { + key: string; + content: string; + thRender?: () => JSX.Element; + tdRender: (rowData: T) => JSX.Element; +}; + +export type TTableData = { + data: T[]; + columns: TTableColumn[]; + keyExtractor: (rowData: T) => string; + // classNames + tableClassName?: string; + tHeadClassName?: string; + tHeadTrClassName?: string; + thClassName?: string; + tBodyClassName?: string; + tBodyTrClassName?: string; + tdClassName?: string; +}; diff --git a/packages/ui/src/tag/helper.tsx b/packages/ui/src/tag/helper.tsx new file mode 100644 index 00000000000..064da5c2318 --- /dev/null +++ b/packages/ui/src/tag/helper.tsx @@ -0,0 +1,24 @@ +export enum ETagVariant { + OUTLINED = "outlined", +} +export enum ETagSize { + SM = "sm", + LG = "lg", +} +export type TTagVariant = ETagVariant.OUTLINED; + +export type TTagSize = ETagSize.SM | ETagSize.LG; +export interface ITagProperties { + [key: string]: string; +} + +export const containerStyle: ITagProperties = { + [ETagVariant.OUTLINED]: + "flex items-center rounded-md border border-custom-border-200 text-xs text-custom-text-300 hover:text-custom-text-200 min-h-[36px] my-auto capitalize flex-wrap cursor-pointer gap-1.5", +}; +export const sizes = { + [ETagSize.SM]: "p-1.5", + [ETagSize.LG]: "p-6", +}; + +export const getTagStyle = (variant: TTagVariant, size: TTagSize) => containerStyle[variant] + " " + sizes[size]; diff --git a/packages/ui/src/tag/index.ts b/packages/ui/src/tag/index.ts new file mode 100644 index 00000000000..219d72b8c8c --- /dev/null +++ b/packages/ui/src/tag/index.ts @@ -0,0 +1 @@ +export * from "./tag"; diff --git a/packages/ui/src/tag/tag.tsx b/packages/ui/src/tag/tag.tsx new file mode 100644 index 00000000000..deb3d1b0f67 --- /dev/null +++ b/packages/ui/src/tag/tag.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { cn } from "../../helpers"; +import { ETagSize, ETagVariant, getTagStyle, TTagSize, TTagVariant } from "./helper"; + +export interface TagProps extends React.ComponentProps<"div"> { + variant?: TTagVariant; + size?: TTagSize; + className?: string; + children: React.ReactNode; +} + +const Tag = React.forwardRef((props, ref) => { + const { variant = ETagVariant.OUTLINED, className = "", size = ETagSize.SM, children, ...rest } = props; + + const style = getTagStyle(variant, size); + return ( +
    + {children} +
    + ); +}); + +Tag.displayName = "plane-ui-container"; + +export { Tag, ETagVariant, ETagSize }; diff --git a/packages/ui/src/toast/index.tsx b/packages/ui/src/toast/index.tsx index ce2d05ef784..94cf1fa28c0 100644 --- a/packages/ui/src/toast/index.tsx +++ b/packages/ui/src/toast/index.tsx @@ -69,7 +69,7 @@ export const setToast = (props: SetToastProps) => { borderColorClassName, }: ToastContentProps) => props.type === TOAST_TYPE.LOADING ? ( -
    +
    { e.stopPropagation(); @@ -96,6 +96,7 @@ export const setToast = (props: SetToastProps) => {
    ) : (
    { e.stopPropagation(); e.preventDefault(); diff --git a/packages/ui/src/tooltip/tooltip.tsx b/packages/ui/src/tooltip/tooltip.tsx index 30fa5ba5bc2..ca4f5c88a54 100644 --- a/packages/ui/src/tooltip/tooltip.tsx +++ b/packages/ui/src/tooltip/tooltip.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Tooltip2 } from "@blueprintjs/popover2"; // helpers import { cn } from "../../helpers"; @@ -30,6 +30,7 @@ interface ITooltipProps { openDelay?: number; closeDelay?: number; isMobile?: boolean; + renderByDefault?: boolean; } export const Tooltip: React.FC = ({ @@ -42,37 +43,68 @@ export const Tooltip: React.FC = ({ openDelay = 200, closeDelay, isMobile = false, -}) => ( - - {tooltipHeading &&
    {tooltipHeading}
    } - {tooltipContent} + renderByDefault = true, //FIXME: tooltip should always render on hover and not by default, this is a temporary fix +}) => { + const toolTipRef = useRef(null); + + const [shouldRender, setShouldRender] = useState(renderByDefault); + + const onHover = () => { + setShouldRender(true); + }; + + useEffect(() => { + const element = toolTipRef.current as any; + + if (!element) return; + + element.addEventListener("mouseenter", onHover); + + return () => { + element?.removeEventListener("mouseenter", onHover); + }; + }, [toolTipRef, shouldRender]); + + if (!shouldRender) { + return ( +
    + {children}
    - } - position={position} - renderTarget={({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isOpen: isTooltipOpen, - ref: eleReference, - ...tooltipProps - }) => - React.cloneElement(children, { + ); + } + + return ( + + {tooltipHeading &&
    {tooltipHeading}
    } + {tooltipContent} +
    + } + position={position} + renderTarget={({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isOpen: isTooltipOpen, ref: eleReference, - ...tooltipProps, - ...children.props, - }) - } - /> -); + ...tooltipProps + }) => + React.cloneElement(children, { + ref: eleReference, + ...tooltipProps, + ...children.props, + }) + } + /> + ); +}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 08a82e90799..f9715d3d8b1 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,7 +1,8 @@ { - "extends": "tsconfig/react-library.json", + "extends": "@plane/typescript-config/react-library.json", "compilerOptions": { - "jsx": "react" + "jsx": "react", + "lib": ["esnext", "dom"] }, "include": ["."], "exclude": ["dist", "build", "node_modules"] diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts new file mode 100644 index 00000000000..5e89e04afad --- /dev/null +++ b/packages/ui/tsup.config.ts @@ -0,0 +1,11 @@ +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/setup.sh b/setup.sh index 838f5bbac92..2376cb0016b 100755 --- a/setup.sh +++ b/setup.sh @@ -9,6 +9,7 @@ cp ./web/.env.example ./web/.env cp ./apiserver/.env.example ./apiserver/.env cp ./space/.env.example ./space/.env cp ./admin/.env.example ./admin/.env +cp ./live/.env.example ./live/.env # Generate the SECRET_KEY that will be used by django -echo "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env \ No newline at end of file +echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env diff --git a/space/.eslintrc.js b/space/.eslintrc.js index 57d39bcfad1..58822f90baf 100644 --- a/space/.eslintrc.js +++ b/space/.eslintrc.js @@ -1,52 +1,10 @@ +/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, - extends: ["custom"], + extends: ["@plane/eslint-config/next.js"], parser: "@typescript-eslint/parser", - settings: { - "import/resolver": { - typescript: {}, - node: { - moduleDirectory: ["node_modules", "."], - }, - }, - }, - 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: "@headlessui/**", - group: "external", - position: "after", - }, - { - pattern: "@plane/**", - group: "external", - position: "after", - }, - { - pattern: "@/**", - group: "internal", - } - ], - pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], - alphabetize: { - order: "asc", - caseInsensitive: true, - }, - }, - ], + parserOptions: { + project: true, }, + rules: {}, }; diff --git a/space/app/issues/[anchor]/layout.tsx b/space/app/issues/[anchor]/layout.tsx index 651facb8c89..ac5dba43078 100644 --- a/space/app/issues/[anchor]/layout.tsx +++ b/space/app/issues/[anchor]/layout.tsx @@ -6,6 +6,7 @@ import useSWR from "swr"; // components import { LogoSpinner } from "@/components/common"; import { IssuesNavbarRoot } from "@/components/issues"; +import { SomethingWentWrongError } from "@/components/issues/issue-layouts/error"; // hooks import { useIssueFilter, usePublish, usePublishList } from "@/hooks/store"; // assets @@ -27,7 +28,7 @@ const IssuesLayout = observer((props: Props) => { const publishSettings = usePublish(anchor); const { updateLayoutOptions } = useIssueFilter(); // fetch publish settings - useSWR( + const { error } = useSWR( anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? async () => { @@ -45,7 +46,9 @@ const IssuesLayout = observer((props: Props) => { : null ); - if (!publishSettings) return ; + if (!publishSettings && !error) return ; + + if (error) return ; return (
    diff --git a/space/app/issues/[anchor]/page.tsx b/space/app/issues/[anchor]/page.tsx index 1b16def829b..2bc37eecb0b 100644 --- a/space/app/issues/[anchor]/page.tsx +++ b/space/app/issues/[anchor]/page.tsx @@ -2,10 +2,11 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; // components import { IssuesLayoutsRoot } from "@/components/issues"; // hooks -import { usePublish } from "@/hooks/store"; +import { usePublish, useLabel, useStates } from "@/hooks/store"; type Props = { params: { @@ -19,6 +20,12 @@ const IssuesPage = observer((props: Props) => { // params const searchParams = useSearchParams(); const peekId = searchParams.get("peekId") || undefined; + // store + const { fetchStates } = useStates(); + const { fetchLabels } = useLabel(); + + useSWR(anchor ? `PUBLIC_STATES_${anchor}` : null, anchor ? () => fetchStates(anchor) : null); + useSWR(anchor ? `PUBLIC_LABELS_${anchor}` : null, anchor ? () => fetchLabels(anchor) : null); const publishSettings = usePublish(anchor); diff --git a/space/app/views/[anchor]/layout.tsx b/space/app/views/[anchor]/layout.tsx new file mode 100644 index 00000000000..cf7643bb607 --- /dev/null +++ b/space/app/views/[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 { SomethingWentWrongError } from "@/components/issues/issue-layouts/error"; +// hooks +import { usePublish, usePublishList } from "@/hooks/store"; +// Plane web +import { ViewNavbarRoot } from "@/plane-web/components/navbar"; +import { useView } from "@/plane-web/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 { viewData, fetchViewDetails } = useView(); + const publishSettings = usePublish(anchor); + + // fetch publish settings && view details + const { error } = useSWR( + anchor ? `PUBLISHED_VIEW_SETTINGS_${anchor}` : null, + anchor + ? async () => { + const promises = []; + promises.push(fetchPublishSettings(anchor)); + promises.push(fetchViewDetails(anchor)); + await Promise.all(promises); + } + : null + ); + + if (error) return ; + + if (!publishSettings || !viewData) return ; + + return ( + + ); +}); + +export default IssuesLayout; diff --git a/space/app/views/[anchor]/page.tsx b/space/app/views/[anchor]/page.tsx new file mode 100644 index 00000000000..21bb5a96574 --- /dev/null +++ b/space/app/views/[anchor]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +// hooks +import { usePublish } from "@/hooks/store"; +// plane-web +import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root"; + +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/ce/components/issue-layouts/root.tsx b/space/ce/components/issue-layouts/root.tsx new file mode 100644 index 00000000000..5fa40fe1171 --- /dev/null +++ b/space/ce/components/issue-layouts/root.tsx @@ -0,0 +1,10 @@ +import { PageNotFound } from "@/components/ui/not-found"; +import { PublishStore } from "@/store/publish/publish.store"; + +type Props = { + peekId: string | undefined; + publishSettings: PublishStore; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ViewLayoutsRoot = (props: Props) => ; diff --git a/space/ce/components/navbar/index.tsx b/space/ce/components/navbar/index.tsx new file mode 100644 index 00000000000..6e6fa444149 --- /dev/null +++ b/space/ce/components/navbar/index.tsx @@ -0,0 +1,8 @@ +import { PublishStore } from "@/store/publish/publish.store"; + +type Props = { + publishSettings: PublishStore; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const ViewNavbarRoot = (props: Props) => <>; diff --git a/space/ce/hooks/store/index.ts b/space/ce/hooks/store/index.ts new file mode 100644 index 00000000000..a5fc99eef89 --- /dev/null +++ b/space/ce/hooks/store/index.ts @@ -0,0 +1 @@ +export * from "./use-published-view"; diff --git a/space/ce/hooks/store/use-published-view.ts b/space/ce/hooks/store/use-published-view.ts new file mode 100644 index 00000000000..170d934da20 --- /dev/null +++ b/space/ce/hooks/store/use-published-view.ts @@ -0,0 +1,5 @@ +export const useView = () => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fetchViewDetails: (anchor: string) => {}, + viewData: {}, +}); diff --git a/space/core/components/account/auth-forms/email.tsx b/space/core/components/account/auth-forms/email.tsx index ec29a429dc1..f0b88407a83 100644 --- a/space/core/components/account/auth-forms/email.tsx +++ b/space/core/components/account/auth-forms/email.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, FormEvent, useMemo, useState } from "react"; +import { FC, FormEvent, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; @@ -22,7 +22,6 @@ export const AuthEmailForm: FC = observer((props) => { // states const [isSubmitting, setIsSubmitting] = useState(false); const [email, setEmail] = useState(defaultEmail); - const [isFocused, setFocused] = useState(false); const emailError = useMemo( () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), @@ -41,6 +40,9 @@ export const AuthEmailForm: FC = observer((props) => { const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + const [isFocused, setIsFocused] = useState(true) + const inputRef = useRef(null); + return (
    @@ -52,6 +54,9 @@ export const AuthEmailForm: FC = observer((props) => { `relative flex items-center rounded-md bg-onboarding-background-200 border`, !isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100` )} + tabIndex={-1} + onFocus={() => {setIsFocused(true)}} + onBlur={() => {setIsFocused(false)}} > = observer((props) => { onChange={(e) => setEmail(e.target.value)} placeholder="name@company.com" className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} + autoComplete="on" autoFocus + ref={inputRef} /> - {email.length > 0 && ( + {email.length > 0 && ( setEmail("")} + className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs" + onClick={() => { + setEmail(""); + inputRef.current?.focus(); + }} /> )}
    @@ -84,4 +92,4 @@ export const AuthEmailForm: FC = observer((props) => { ); -}); +}); \ No newline at end of file diff --git a/space/core/components/account/auth-forms/password.tsx b/space/core/components/account/auth-forms/password.tsx index c3a5e9c31db..3d87cded59c 100644 --- a/space/core/components/account/auth-forms/password.tsx +++ b/space/core/components/account/auth-forms/password.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; import { Button, Input, Spinner } from "@plane/ui"; @@ -39,8 +39,10 @@ const authService = new AuthService(); export const AuthPasswordForm: React.FC = observer((props: Props) => { const { email, nextPath, isSMTPConfigured, handleAuthStep, handleEmailClear, mode } = props; + // ref + const formRef = useRef(null); // states - const [csrfToken, setCsrfToken] = useState(undefined); + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); const [showPassword, setShowPassword] = useState({ password: false, @@ -57,9 +59,11 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { setPasswordFormData((prev) => ({ ...prev, [key]: value })); useEffect(() => { - if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); - }, [csrfToken]); + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); const redirectToUniqueCodeSignIn = async () => { handleAuthStep(EAuthSteps.UNIQUE_CODE); @@ -88,15 +92,29 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const confirmPassword = passwordFormData.confirm_password ?? ""; const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + const handleCSRFToken = async () => { + if (!formRef || !formRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + return (
    setIsSubmitting(true)} + onSubmit={async (event) => { + event.preventDefault(); + await handleCSRFToken(); + formRef.current && formRef.current.submit(); + setIsSubmitting(true); + }} onError={() => setIsSubmitting(false)} > - +
    @@ -139,6 +157,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { 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)} + autoComplete="on" autoFocus /> {showPassword?.password ? ( diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 698a6695c18..186f44a1011 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -5,7 +5,7 @@ import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/edi import { IssueCommentToolbar } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; -import { isEmptyHtmlString } from "@/helpers/string.helper"; +import { isCommentEmpty } from "@/helpers/string.helper"; // hooks import { useMention } from "@/hooks/use-mention"; // services @@ -33,10 +33,7 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } - const isEmpty = - props.initialValue?.trim() === "" || - props.initialValue === "

    " || - (isEmptyHtmlString(props.initialValue ?? "") && !props.initialValue?.includes("mention-component")); + const isEmpty = isCommentEmpty(props.initialValue); return (
    @@ -64,11 +61,7 @@ export const LiteTextEditor = React.forwardRef { - if (isMutableRefObject(ref)) { - rest.onEnterKeyPress?.(ref.current?.getHTML() ?? ""); - } - }} + handleSubmit={(e) => rest.onEnterKeyPress?.(e)} isCommentEmpty={isEmpty} editorRef={isMutableRefObject(ref) ? ref : null} /> diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 0659939baa4..033b98ccd19 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -6,7 +6,7 @@ import { cn } from "@/helpers/common.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -interface LiteTextReadOnlyEditorWrapperProps extends Omit {} +type LiteTextReadOnlyEditorWrapperProps = Omit; export const LiteTextReadOnlyEditor = React.forwardRef( ({ ...props }, ref) => { diff --git a/space/core/components/editor/rich-text-read-only-editor.tsx b/space/core/components/editor/rich-text-read-only-editor.tsx index 3fd7cae57ee..9994db477b2 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -6,7 +6,7 @@ import { cn } from "@/helpers/common.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -interface RichTextReadOnlyEditorWrapperProps extends Omit {} +type RichTextReadOnlyEditorWrapperProps = Omit; export const RichTextReadOnlyEditor = React.forwardRef( ({ ...props }, ref) => { diff --git a/space/core/components/editor/toolbar.tsx b/space/core/components/editor/toolbar.tsx index d97c04d5f4c..45e94c2d9a5 100644 --- a/space/core/components/editor/toolbar.tsx +++ b/space/core/components/editor/toolbar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from "react"; // editor -import { EditorMenuItemNames, EditorRefApi } from "@plane/editor"; +import { EditorRefApi, TEditorCommands } from "@plane/editor"; // ui import { Button, Tooltip } from "@plane/ui"; // constants @@ -11,8 +11,8 @@ import { TOOLBAR_ITEMS } from "@/constants/editor"; import { cn } from "@/helpers/common.helper"; type Props = { - executeCommand: (commandName: EditorMenuItemNames) => void; - handleSubmit: () => void; + executeCommand: (commandKey: TEditorCommands) => void; + handleSubmit: (event: React.MouseEvent) => void; isCommentEmpty: boolean; isSubmitting: boolean; showSubmitButton: boolean; @@ -57,7 +57,6 @@ export const IssueCommentToolbar: React.FC = (props) => { key={key} className={cn("flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5", { "pl-0": index === 0, - "pr-0": index === Object.keys(toolbarItems).length - 1, })} > {toolbarItems[key].map((item) => ( diff --git a/space/core/components/issues/filters/applied-filters/filters-list.tsx b/space/core/components/issues/filters/applied-filters/filters-list.tsx index 4216daec2a4..4a4ed4eda22 100644 --- a/space/core/components/issues/filters/applied-filters/filters-list.tsx +++ b/space/core/components/issues/filters/applied-filters/filters-list.tsx @@ -3,8 +3,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // types -import { IStateLite } from "@plane/types"; -import { IIssueLabel, TFilters } from "@/types/issue"; +import { TFilters } from "@/types/issue"; // components import { AppliedPriorityFilters } from "./priority"; import { AppliedStateFilters } from "./state"; @@ -13,14 +12,12 @@ type Props = { appliedFilters: TFilters; handleRemoveAllFilters: () => void; handleRemoveFilter: (key: keyof TFilters, value: string | null) => void; - labels?: IIssueLabel[] | undefined; - states?: IStateLite[] | undefined; }; export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const AppliedFiltersList: React.FC = observer((props) => { - const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter, states } = props; + const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props; return (
    @@ -52,10 +49,9 @@ export const AppliedFiltersList: React.FC = observer((props) => { /> )} */} - {filterKey === "state" && states && ( + {filterKey === "state" && ( handleRemoveFilter("state", val)} - states={states} values={filterValue ?? []} /> )} diff --git a/space/core/components/issues/filters/applied-filters/root.tsx b/space/core/components/issues/filters/applied-filters/root.tsx index 43024bb85e4..6bed9007699 100644 --- a/space/core/components/issues/filters/applied-filters/root.tsx +++ b/space/core/components/issues/filters/applied-filters/root.tsx @@ -5,7 +5,7 @@ import cloneDeep from "lodash/cloneDeep"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; // hooks -import { useIssue, useIssueFilter } from "@/hooks/store"; +import { useIssueFilter } from "@/hooks/store"; // store import { TIssueQueryFilters } from "@/types/issue"; // components @@ -21,7 +21,6 @@ export const IssueAppliedFilters: FC = observer((props) => const router = useRouter(); // store hooks const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); - const { states, labels } = useIssue(); // derived values const issueFilters = getIssueFilters(anchor); const activeLayout = issueFilters?.display_filters?.layout || undefined; @@ -65,14 +64,18 @@ export const IssueAppliedFilters: FC = observer((props) => ); const handleRemoveAllFilters = () => { - initIssueFilters(anchor, { - display_filters: { layout: activeLayout || "list" }, - filters: { - state: [], - priority: [], - labels: [], + initIssueFilters( + anchor, + { + display_filters: { layout: activeLayout || "list" }, + filters: { + state: [], + priority: [], + labels: [], + }, }, - }); + true + ); router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`); }; @@ -85,8 +88,6 @@ export const IssueAppliedFilters: FC = observer((props) => appliedFilters={appliedFilters || {}} handleRemoveFilter={handleFilters as any} handleRemoveAllFilters={handleRemoveAllFilters} - labels={labels ?? []} - states={states ?? []} />
    ); diff --git a/space/core/components/issues/filters/applied-filters/state.tsx b/space/core/components/issues/filters/applied-filters/state.tsx index 7d3b9ef576e..23bfc87e624 100644 --- a/space/core/components/issues/filters/applied-filters/state.tsx +++ b/space/core/components/issues/filters/applied-filters/state.tsx @@ -2,19 +2,20 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// types -import { IStateLite } from "@plane/types"; // ui import { StateGroupIcon } from "@plane/ui"; +// hooks +import { useStates } from "@/hooks/store"; type Props = { handleRemove: (val: string) => void; - states: IStateLite[]; values: string[]; }; export const AppliedStateFilters: React.FC = observer((props) => { - const { handleRemove, states, values } = props; + const { handleRemove, values } = props; + + const { sortedStates: states } = useStates(); return ( <> diff --git a/space/core/components/issues/filters/root.tsx b/space/core/components/issues/filters/root.tsx index cd427e2e308..641cf007c0e 100644 --- a/space/core/components/issues/filters/root.tsx +++ b/space/core/components/issues/filters/root.tsx @@ -12,7 +12,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks -import { useIssue, useIssueFilter } from "@/hooks/store"; +import { useIssueFilter } from "@/hooks/store"; // types import { TIssueQueryFilters } from "@/types/issue"; @@ -26,7 +26,6 @@ export const IssueFiltersDropdown: FC = observer((pro const router = useRouter(); // hooks const { getIssueFilters, updateIssueFilters } = useIssueFilter(); - const { states, labels } = useIssue(); // derived values const issueFilters = getIssueFilters(anchor); const activeLayout = issueFilters?.display_filters?.layout || undefined; @@ -65,8 +64,6 @@ export const IssueFiltersDropdown: FC = observer((pro filters={issueFilters?.filters ?? {}} handleFilters={handleFilters as any} layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[activeLayout]?.filters : []} - states={states ?? undefined} - labels={labels ?? undefined} />
    diff --git a/space/core/components/issues/filters/selection.tsx b/space/core/components/issues/filters/selection.tsx index 3f3ccb037ff..2b5d54074c7 100644 --- a/space/core/components/issues/filters/selection.tsx +++ b/space/core/components/issues/filters/selection.tsx @@ -4,8 +4,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { Search, X } from "lucide-react"; // types -import { IStateLite } from "@plane/types"; -import { IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; +import { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; // components import { FilterPriority, FilterState } from "."; @@ -13,12 +12,10 @@ type Props = { filters: IIssueFilterOptions; handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; layoutDisplayFiltersOptions: TIssueFilterKeys[]; - labels?: IIssueLabel[] | undefined; - states?: IStateLite[] | undefined; }; export const FilterSelection: React.FC = observer((props) => { - const { filters, handleFilters, layoutDisplayFiltersOptions, states } = props; + const { filters, handleFilters, layoutDisplayFiltersOptions } = props; const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -63,7 +60,6 @@ export const FilterSelection: React.FC = observer((props) => { appliedFilters={filters.state ?? null} handleUpdate={(val) => handleFilters("state", val)} searchQuery={filtersSearchQuery} - states={states} />
    )} diff --git a/space/core/components/issues/filters/state.tsx b/space/core/components/issues/filters/state.tsx index f61237eef2b..00cf4c0ff35 100644 --- a/space/core/components/issues/filters/state.tsx +++ b/space/core/components/issues/filters/state.tsx @@ -1,22 +1,24 @@ "use client"; import React, { useState } from "react"; -// types -import { IStateLite } from "@plane/types"; +import { observer } from "mobx-react"; // ui import { Loader, StateGroupIcon } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers"; +// hooks +import { useStates } from "@/hooks/store"; type Props = { appliedFilters: string[] | null; handleUpdate: (val: string) => void; searchQuery: string; - states: IStateLite[] | undefined; }; -export const FilterState: React.FC = (props) => { - const { appliedFilters, handleUpdate, searchQuery, states } = props; +export const FilterState: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const { sortedStates: states } = useStates(); const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); @@ -77,4 +79,4 @@ export const FilterState: React.FC = (props) => { )} ); -}; +}); diff --git a/space/core/components/issues/issue-layouts/error.tsx b/space/core/components/issues/issue-layouts/error.tsx new file mode 100644 index 00000000000..34789c21371 --- /dev/null +++ b/space/core/components/issues/issue-layouts/error.tsx @@ -0,0 +1,17 @@ +import Image from "next/image"; +// assets +import SomethingWentWrongImage from "public/something-went-wrong.svg"; + +export const SomethingWentWrongError = () => ( +
    +
    +
    +
    + Oops! Something went wrong +
    +
    +

    Oops! Something went wrong.

    +

    The public board does not exist. Please check the URL.

    +
    +
    +); diff --git a/space/core/components/issues/issue-layouts/index.ts b/space/core/components/issues/issue-layouts/index.ts index 5ab6813cdfe..2115cf42d1e 100644 --- a/space/core/components/issues/issue-layouts/index.ts +++ b/space/core/components/issues/issue-layouts/index.ts @@ -1,4 +1,4 @@ -export * from "./kanban"; -export * from "./list"; +export * from "./kanban/base-kanban-root"; +export * from "./list/base-list-root"; export * from "./properties"; export * from "./root"; diff --git a/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx b/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx new file mode 100644 index 00000000000..cbb8aa551d6 --- /dev/null +++ b/space/core/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -0,0 +1,33 @@ +import { observer } from "mobx-react"; +import { TLoader } from "@plane/types"; +import { LogoSpinner } from "@/components/common"; + +interface Props { + children: string | JSX.Element | JSX.Element[]; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; +} + +export const IssueLayoutHOC = observer((props: Props) => { + const { getIssueLoader, getGroupIssueCount } = props; + + const issueCount = getGroupIssueCount(undefined, undefined, false); + + if (getIssueLoader() === "init-loader" || issueCount === undefined) { + return ( +
    + +
    + ); + } + + if (getGroupIssueCount(undefined, undefined, false) === 0) { + return
    No Issues Found
    ; + } + + return <>{props.children}; +}); diff --git a/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx new file mode 100644 index 00000000000..f81f1aed8c8 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import debounce from "lodash/debounce"; +import { observer } from "mobx-react"; +// types +import { IIssueDisplayProperties } from "@plane/types"; +// components +import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; +// hooks +import { useIssue } from "@/hooks/store"; + +import { KanBan } from "./default"; + +type Props = { + anchor: string; +}; +export const IssueKanbanLayoutRoot: React.FC = observer((props: Props) => { + const { anchor } = props; + // store hooks + const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue(); + + const displayProperties: IIssueDisplayProperties = useMemo( + () => ({ + key: true, + state: true, + labels: true, + priority: true, + due_date: true, + }), + [] + ); + + const fetchMoreIssues = useCallback( + (groupId?: string, subgroupId?: string) => { + if (getIssueLoader(groupId, subgroupId) !== "pagination") { + fetchNextPublicIssues(anchor, groupId, subgroupId); + } + }, + [fetchNextPublicIssues] + ); + + const debouncedFetchMoreIssues = debounce( + (groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId), + 300, + { leading: true, trailing: false } + ); + + const scrollableContainerRef = useRef(null); + + return ( + +
    +
    +
    + +
    +
    +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx b/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx new file mode 100644 index 00000000000..241a087e781 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/block-reactions.tsx @@ -0,0 +1,45 @@ +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +//plane +import { cn } from "@plane/editor"; +// components +import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions"; +// hooks +import { usePublish } from "@/hooks/store"; + +type Props = { + issueId: string; +}; +export const BlockReactions = observer((props: Props) => { + const { issueId } = props; + const { anchor } = useParams(); + const { canVote, canReact } = usePublish(anchor.toString()); + + // if the user cannot vote or react then return empty + if (!canVote && !canReact) return <>; + + return ( +
    +
    + {canVote && ( +
    + +
    + )} + {canReact && ( +
    + +
    + )} +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/block.tsx b/space/core/components/issues/issue-layouts/kanban/block.tsx index 4a94c4e77db..7c246cc339f 100644 --- a/space/core/components/issues/issue-layouts/kanban/block.tsx +++ b/space/core/components/issues/issue-layouts/kanban/block.tsx @@ -1,78 +1,107 @@ "use client"; -import { FC } from "react"; +import { MutableRefObject } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; +// plane +import { cn } from "@plane/editor"; +import { IIssueDisplayProperties } from "@plane/types"; +import { Tooltip } from "@plane/ui"; // components -import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/components/issues"; +import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useIssueDetails, usePublish } from "@/hooks/store"; -// interfaces +// import { IIssue } from "@/types/issue"; +import { IssueProperties } from "../properties/all-properties"; +import { getIssueBlockId } from "../utils"; +import { BlockReactions } from "./block-reactions"; -type Props = { - anchor: string; +interface IssueBlockProps { + issueId: string; + groupId: string; + subGroupId: string; + displayProperties: IIssueDisplayProperties | undefined; + scrollableContainerRef?: MutableRefObject; +} + +interface IssueDetailsBlockProps { issue: IIssue; - params: any; -}; + displayProperties: IIssueDisplayProperties | undefined; +} + +const KanbanIssueDetailsBlock: React.FC = observer((props) => { + const { issue, displayProperties } = props; + const { anchor } = useParams(); + // hooks + const { project_details } = usePublish(anchor.toString()); + + return ( +
    + +
    +
    + {project_details?.identifier}-{issue.sequence_id} +
    +
    +
    -export const IssueKanBanBlock: FC = observer((props) => { - const { anchor, issue } = props; +
    + + {issue.name} + +
    + + +
    + ); +}); + +export const KanbanIssueBlock: React.FC = observer((props) => { + const { issueId, groupId, subGroupId, displayProperties } = props; const searchParams = useSearchParams(); // query params const board = searchParams.get("board"); - const state = searchParams.get("state"); - const priority = searchParams.get("priority"); - const labels = searchParams.get("labels"); - // store hooks - const { project_details } = usePublish(anchor); - const { setPeekId } = useIssueDetails(); - - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); + // hooks + const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails(); - const handleBlockClick = () => { - setPeekId(issue.id); + const handleIssuePeekOverview = () => { + setPeekId(issueId); }; - return ( - - {/* id */} -
    - {project_details?.identifier}-{issue?.sequence_id} -
    + const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId }); - {/* name */} -
    - {issue.name} -
    + const issue = getIssueById(issueId); -
    - {/* priority */} - {issue?.priority && ( -
    - -
    - )} - {/* state */} - {issue?.state_detail && ( -
    - -
    - )} - {/* due date */} - {issue?.target_date && ( -
    - -
    + if (!issue) return null; + + return ( +
    +
    + + + +
    - +
    ); }); + +KanbanIssueBlock.displayName = "KanbanIssueBlock"; diff --git a/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx b/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx new file mode 100644 index 00000000000..c0a58325b5d --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -0,0 +1,45 @@ +import { MutableRefObject } from "react"; +import { observer } from "mobx-react"; +//types +import { IIssueDisplayProperties } from "@plane/types"; +// components +import { KanbanIssueBlock } from "./block"; + +interface IssueBlocksListProps { + subGroupId: string; + groupId: string; + issueIds: string[]; + displayProperties: IIssueDisplayProperties | undefined; + scrollableContainerRef?: MutableRefObject; +} + +export const KanbanIssueBlocksList: React.FC = observer((props) => { + const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props; + + return ( + <> + {issueIds && issueIds.length > 0 ? ( + <> + {issueIds.map((issueId) => { + if (!issueId) return null; + + let draggableId = issueId; + if (groupId) draggableId = `${draggableId}__${groupId}`; + if (subGroupId) draggableId = `${draggableId}__${subGroupId}`; + + return ( + + ); + })} + + ) : null} + + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/default.tsx b/space/core/components/issues/issue-layouts/kanban/default.tsx new file mode 100644 index 00000000000..50f7b0f7138 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/default.tsx @@ -0,0 +1,123 @@ +import { MutableRefObject } from "react"; +import isNil from "lodash/isNil"; +import { observer } from "mobx-react"; +// types +import { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store"; +// +import { getGroupByColumns } from "../utils"; +// components +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { KanbanGroup } from "./kanban-group"; + +export interface IKanBan { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + subGroupId?: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; + showEmptyGroup?: boolean; +} + +export const KanBan: React.FC = observer((props) => { + const { + groupedIssueIds, + displayProperties, + subGroupBy, + groupBy, + subGroupId = "null", + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + showEmptyGroup = true, + } = props; + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member); + + if (!groupList) return null; + + const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => { + const groupVisibility = { + showGroup: true, + showIssues: true, + }; + + if (!showEmptyGroup) { + groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0; + } + return groupVisibility; + }; + + return ( +
    + {groupList && + groupList.length > 0 && + groupList.map((subList: IGroupByColumn) => { + const groupByVisibilityToggle = visibilityGroupBy(subList); + + if (groupByVisibilityToggle.showGroup === false) return <>; + return ( +
    + {isNil(subGroupBy) && ( +
    + +
    + )} + + {groupByVisibilityToggle.showIssues && ( + + )} +
    + ); + })} +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/header.tsx b/space/core/components/issues/issue-layouts/kanban/header.tsx deleted file mode 100644 index e8182aa3035..00000000000 --- a/space/core/components/issues/issue-layouts/kanban/header.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -// types -import { IStateLite } from "@plane/types"; -// ui -import { StateGroupIcon } from "@plane/ui"; - -type Props = { - state: IStateLite; -}; - -export const IssueKanBanHeader: React.FC = observer((props) => { - const { state } = props; - - return ( -
    -
    - -
    -
    {state?.name}
    - {/* {getCountOfIssuesByState(state.id)} */} -
    - ); -}); diff --git a/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx new file mode 100644 index 00000000000..a36d9f92299 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { Circle } from "lucide-react"; +// types +import { TIssueGroupByOptions } from "@plane/types"; + +interface IHeaderGroupByCard { + groupBy: TIssueGroupByOptions | undefined; + icon?: React.ReactNode; + title: string; + count: number; +} + +export const HeaderGroupByCard: FC = observer((props) => { + const { icon, title, count } = props; + + return ( + <> +
    +
    + {icon ? icon : } +
    + +
    +
    + {title} +
    +
    {count || 0}
    +
    +
    + + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx new file mode 100644 index 00000000000..2e91624d111 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -0,0 +1,35 @@ +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { Circle, ChevronDown, ChevronUp } from "lucide-react"; +// mobx + +interface IHeaderSubGroupByCard { + icon?: React.ReactNode; + title: string; + count: number; + isExpanded: boolean; + toggleExpanded: () => void; +} + +export const HeaderSubGroupByCard: FC = observer((props) => { + const { icon, title, count, isExpanded, toggleExpanded } = props; + return ( +
    toggleExpanded()} + > +
    + {isExpanded ? : } +
    + +
    + {icon ? icon : } +
    + +
    +
    {title}
    +
    {count || 0}
    +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/index.ts b/space/core/components/issues/issue-layouts/kanban/index.ts index 62874fbda4e..068da7360b3 100644 --- a/space/core/components/issues/issue-layouts/kanban/index.ts +++ b/space/core/components/issues/issue-layouts/kanban/index.ts @@ -1,3 +1,2 @@ export * from "./block"; -export * from "./header"; -export * from "./root"; +export * from "./blocks-list"; diff --git a/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx b/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx new file mode 100644 index 00000000000..fd00d20bac3 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +//types +import { + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// +import { KanbanIssueBlocksList } from "."; + +interface IKanbanGroup { + groupId: string; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + subGroupId: string; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; +} + +// Loader components +const KanbanIssueBlockLoader = forwardRef((props, ref) => ( + +)); +KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader"; + +export const KanbanGroup = observer((props: IKanbanGroup) => { + const { + groupId, + subGroupId, + subGroupBy, + displayProperties, + groupedIssueIds, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + scrollableContainerRef, + } = props; + + // hooks + const [intersectionElement, setIntersectionElement] = useState(null); + const columnRef = useRef(null); + + const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef; + + const loadMoreIssuesInThisGroup = useCallback(() => { + loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId); + }, [loadMoreIssues, groupId, subGroupId]); + + const isPaginating = !!getIssueLoader(groupId, subGroupId); + + useIntersectionObserver( + containerRef, + isPaginating ? null : intersectionElement, + loadMoreIssuesInThisGroup, + `0% 100% 100% 100%` + ); + + const isSubGroup = !!subGroupId && subGroupId !== "null"; + + const issueIds = isSubGroup + ? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? [] + : (groupedIssueIds as TGroupedIssues)?.[groupId] ?? []; + + const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0; + const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
    + {" "} + Load More ↓ +
    + ); + + const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults; + + return ( +
    + + + {shouldLoadMore && (isSubGroup ? <>{loadMore} : )} +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/root.tsx b/space/core/components/issues/issue-layouts/kanban/root.tsx deleted file mode 100644 index b73b65d3371..00000000000 --- a/space/core/components/issues/issue-layouts/kanban/root.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { FC } from "react"; -import { observer } from "mobx-react"; -// components -import { IssueKanBanBlock, IssueKanBanHeader } from "@/components/issues"; -// ui -import { Icon } from "@/components/ui"; -// mobx hook -import { useIssue } from "@/hooks/store"; - -type Props = { - anchor: string; -}; - -export const IssueKanbanLayoutRoot: FC = observer((props) => { - const { anchor } = props; - // store hooks - const { states, getFilteredIssuesByState } = useIssue(); - - return ( -
    - {states?.map((state) => { - const issues = getFilteredIssuesByState(state.id); - - return ( -
    -
    - -
    -
    - {issues && issues.length > 0 ? ( -
    - {issues.map((issue) => ( - - ))} -
    - ) : ( -
    - - No issues in this state -
    - )} -
    -
    - ); - })} -
    - ); -}); diff --git a/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx b/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx new file mode 100644 index 00000000000..902dff670d0 --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -0,0 +1,294 @@ +import { MutableRefObject, useState } from "react"; +import { observer } from "mobx-react"; +// types +import { + GroupByColumnTypes, + IGroupByColumn, + TGroupedIssues, + IIssueDisplayProperties, + TSubGroupedIssues, + TIssueGroupByOptions, + TIssueOrderByOptions, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store"; +// +import { getGroupByColumns } from "../utils"; +import { KanBan } from "./default"; +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; + +export interface IKanBanSwimLanes { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + displayProperties: IIssueDisplayProperties | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + showEmptyGroup: boolean; + scrollableContainerRef?: MutableRefObject; + orderBy: TIssueOrderByOptions | undefined; +} + +export const KanBanSwimLanes: React.FC = observer((props) => { + const { + groupedIssueIds, + displayProperties, + subGroupBy, + groupBy, + orderBy, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member); + const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member); + + if (!groupByList || !subGroupByList) return null; + + return ( +
    +
    + +
    + + {subGroupBy && ( + + )} +
    + ); +}); + +interface ISubGroupSwimlaneHeader { + subGroupBy: TIssueGroupByOptions | undefined; + groupBy: TIssueGroupByOptions | undefined; + groupList: IGroupByColumn[]; + showEmptyGroup: boolean; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => { + let subGroupHeaderVisibility = true; + + if (showEmptyGroup) subGroupHeaderVisibility = true; + else { + if (subGroupIssueCount > 0) subGroupHeaderVisibility = true; + else subGroupHeaderVisibility = false; + } + + return subGroupHeaderVisibility; +}; + +const SubGroupSwimlaneHeader: React.FC = observer( + ({ subGroupBy, groupBy, groupList, showEmptyGroup, getGroupIssueCount }) => ( +
    + {groupList && + groupList.length > 0 && + groupList.map((group: IGroupByColumn) => { + const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + + const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup); + + if (subGroupByVisibilityToggle === false) return <>; + + return ( +
    + +
    + ); + })} +
    + ) +); + +interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + showEmptyGroup: boolean; + displayProperties: IIssueDisplayProperties | undefined; + orderBy: TIssueOrderByOptions | undefined; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; +} + +const SubGroupSwimlane: React.FC = observer((props) => { + const { + groupedIssueIds, + subGroupBy, + groupBy, + groupList, + displayProperties, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + return ( +
    + {groupList && + groupList.length > 0 && + groupList.map((group: IGroupByColumn) => ( + + ))} +
    + ); +}); + +interface ISubGroup { + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + showEmptyGroup: boolean; + displayProperties: IIssueDisplayProperties | undefined; + groupBy: TIssueGroupByOptions | undefined; + subGroupBy: TIssueGroupByOptions | undefined; + group: IGroupByColumn; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; + scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; +} + +const SubGroup: React.FC = observer((props) => { + const { + groupedIssueIds, + subGroupBy, + groupBy, + group, + displayProperties, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + showEmptyGroup, + scrollableContainerRef, + } = props; + + const [isExpanded, setIsExpanded] = useState(true); + + const toggleExpanded = () => { + setIsExpanded((prevState) => !prevState); + }; + + const visibilitySubGroupBy = ( + _list: IGroupByColumn, + subGroupCount: number + ): { showGroup: boolean; showIssues: boolean } => { + const subGroupVisibility = { + showGroup: true, + showIssues: true, + }; + if (showEmptyGroup) subGroupVisibility.showGroup = true; + else { + if (subGroupCount > 0) subGroupVisibility.showGroup = true; + else subGroupVisibility.showGroup = false; + } + return subGroupVisibility; + }; + + const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0; + const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount); + if (subGroupByVisibilityToggle.showGroup === false) return <>; + + return ( + <> +
    +
    +
    + +
    +
    + + {subGroupByVisibilityToggle.showIssues && isExpanded && ( +
    + +
    + )} +
    + + ); +}); diff --git a/space/core/components/issues/issue-layouts/list/base-list-root.tsx b/space/core/components/issues/issue-layouts/list/base-list-root.tsx new file mode 100644 index 00000000000..737664b7394 --- /dev/null +++ b/space/core/components/issues/issue-layouts/list/base-list-root.tsx @@ -0,0 +1,63 @@ +import { useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +// types +import { IIssueDisplayProperties, TGroupedIssues } from "@plane/types"; +// constants +// components +import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; +// hooks +import { useIssue } from "@/hooks/store"; +import { List } from "./default"; + +type Props = { + anchor: string; +}; + +export const IssuesListLayoutRoot = observer((props: Props) => { + const { anchor } = props; + // store hooks + const { + groupedIssueIds: storeGroupedIssueIds, + fetchNextPublicIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = useIssue(); + + const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined; + // auth + const displayProperties: IIssueDisplayProperties = useMemo( + () => ({ + key: true, + state: true, + labels: true, + priority: true, + due_date: true, + }), + [] + ); + + const loadMoreIssues = useCallback( + (groupId?: string) => { + fetchNextPublicIssues(anchor, groupId); + }, + [fetchNextPublicIssues] + ); + + return ( + +
    + +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/list/block.tsx b/space/core/components/issues/issue-layouts/list/block.tsx index a1f7e82970e..39a298448ac 100644 --- a/space/core/components/issues/issue-layouts/list/block.tsx +++ b/space/core/components/issues/issue-layouts/list/block.tsx @@ -1,85 +1,90 @@ "use client"; -import { FC } from "react"; + +import { useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -// components -import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockState } from "@/components/issues"; +import { useParams, useSearchParams } from "next/navigation"; +// types +import { cn } from "@plane/editor"; +import { IIssueDisplayProperties } from "@plane/types"; +import { Tooltip } from "@plane/ui"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; -// hook +// hooks import { useIssueDetails, usePublish } from "@/hooks/store"; -// types -import { IIssue } from "@/types/issue"; +// +import { IssueProperties } from "../properties/all-properties"; -type IssueListBlockProps = { - anchor: string; - issue: IIssue; -}; +interface IssueBlockProps { + issueId: string; + groupId: string; + displayProperties: IIssueDisplayProperties | undefined; +} -export const IssueListLayoutBlock: FC = observer((props) => { - const { anchor, issue } = props; - // query params +export const IssueBlock = observer((props: IssueBlockProps) => { + const { anchor } = useParams(); + const { issueId, displayProperties } = props; const searchParams = useSearchParams(); - const board = searchParams.get("board") || undefined; - const state = searchParams.get("state") || undefined; - const priority = searchParams.get("priority") || undefined; - const labels = searchParams.get("labels") || undefined; - // store hooks - const { setPeekId } = useIssueDetails(); - const { project_details } = usePublish(anchor); + // query params + const board = searchParams.get("board"); + // ref + const issueRef = useRef(null); + // hooks + const { project_details } = usePublish(anchor.toString()); + const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails(); - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); - const handleBlockClick = () => { - setPeekId(issue.id); + const handleIssuePeekOverview = () => { + setPeekId(issueId); }; - return ( - -
    - {/* id */} -
    - {project_details?.identifier}-{issue?.sequence_id} -
    - {/* name */} -
    - {issue.name} -
    -
    + const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId }); -
    - {/* priority */} - {issue?.priority && ( -
    - -
    - )} + const issue = getIssueById(issueId); - {/* state */} - {issue?.state_detail && ( -
    - -
    - )} + if (!issue) return null; - {/* labels */} - {issue?.label_details && issue?.label_details.length > 0 && ( -
    - -
    - )} + const projectIdentifier = project_details?.identifier; - {/* due date */} - {issue?.target_date && ( -
    - + return ( +
    +
    +
    +
    + {displayProperties && displayProperties?.key && ( +
    + {projectIdentifier}-{issue.sequence_id} +
    + )}
    - )} + + + +

    {issue.name}

    +
    + +
    +
    +
    +
    - +
    ); }); diff --git a/space/core/components/issues/issue-layouts/list/blocks-list.tsx b/space/core/components/issues/issue-layouts/list/blocks-list.tsx new file mode 100644 index 00000000000..bb25e5c1675 --- /dev/null +++ b/space/core/components/issues/issue-layouts/list/blocks-list.tsx @@ -0,0 +1,25 @@ +import { FC, MutableRefObject } from "react"; +// types +import { IIssueDisplayProperties } from "@plane/types"; +import { IssueBlock } from "./block"; + +interface Props { + issueIds: string[] | undefined; + groupId: string; + displayProperties?: IIssueDisplayProperties; + containerRef: MutableRefObject; +} + +export const IssueBlocksList: FC = (props) => { + const { issueIds = [], groupId, displayProperties } = props; + + return ( +
    + {issueIds && + issueIds?.length > 0 && + issueIds.map((issueId: string) => ( + + ))} +
    + ); +}; diff --git a/space/core/components/issues/issue-layouts/list/default.tsx b/space/core/components/issues/issue-layouts/list/default.tsx new file mode 100644 index 00000000000..0f61dcf1f2a --- /dev/null +++ b/space/core/components/issues/issue-layouts/list/default.tsx @@ -0,0 +1,86 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +// types +import { + GroupByColumnTypes, + TGroupedIssues, + IIssueDisplayProperties, + TIssueGroupByOptions, + IGroupByColumn, + TPaginationData, + TLoader, +} from "@plane/types"; +// hooks +import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store"; +// +import { getGroupByColumns } from "../utils"; +import { ListGroup } from "./list-group"; + +export interface IList { + groupedIssueIds: TGroupedIssues; + groupBy: TIssueGroupByOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; +} + +export const List: React.FC = observer((props) => { + const { + groupedIssueIds, + groupBy, + displayProperties, + showEmptyGroup, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = props; + + const containerRef = useRef(null); + + const member = useMember(); + const label = useLabel(); + const cycle = useCycle(); + const modules = useModule(); + const state = useStates(); + + const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true); + + if (!groupList) return null; + + return ( +
    + {groupList && ( + <> +
    + {groupList.map((group: IGroupByColumn) => ( + + ))} +
    + + )} +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/list/header.tsx b/space/core/components/issues/issue-layouts/list/header.tsx deleted file mode 100644 index 6ac0213ed35..00000000000 --- a/space/core/components/issues/issue-layouts/list/header.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import React from "react"; -import { observer } from "mobx-react"; -// types -import { IStateLite } from "@plane/types"; -// ui -import { StateGroupIcon } from "@plane/ui"; - -type Props = { - state: IStateLite; -}; - -export const IssueListLayoutHeader: React.FC = observer((props) => { - const { state } = props; - - return ( -
    -
    - -
    -
    {state?.name}
    - {/*
    {count}
    */} -
    - ); -}); diff --git a/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx b/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx new file mode 100644 index 00000000000..e92e9daeda4 --- /dev/null +++ b/space/core/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { observer } from "mobx-react"; +import { CircleDashed } from "lucide-react"; + +interface IHeaderGroupByCard { + groupID: string; + icon?: React.ReactNode; + title: string; + count: number; + toggleListGroup: (id: string) => void; +} + +export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { + const { groupID, icon, title, count, toggleListGroup } = props; + + return ( + <> +
    toggleListGroup(groupID)} + > +
    + {icon ?? } +
    + +
    +
    {title}
    +
    {count || 0}
    +
    +
    + + ); +}); diff --git a/space/core/components/issues/issue-layouts/list/index.ts b/space/core/components/issues/issue-layouts/list/index.ts index 62874fbda4e..068da7360b3 100644 --- a/space/core/components/issues/issue-layouts/list/index.ts +++ b/space/core/components/issues/issue-layouts/list/index.ts @@ -1,3 +1,2 @@ export * from "./block"; -export * from "./header"; -export * from "./root"; +export * from "./blocks-list"; diff --git a/space/core/components/issues/issue-layouts/list/list-group.tsx b/space/core/components/issues/issue-layouts/list/list-group.tsx new file mode 100644 index 00000000000..742cfeef156 --- /dev/null +++ b/space/core/components/issues/issue-layouts/list/list-group.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { Fragment, MutableRefObject, forwardRef, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { cn } from "@plane/editor"; +// plane +import { IGroupByColumn, TIssueGroupByOptions, IIssueDisplayProperties, TPaginationData, TLoader } from "@plane/types"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// +import { IssueBlocksList } from "./blocks-list"; +import { HeaderGroupByCard } from "./headers/group-by-card"; + +interface Props { + groupIssueIds: string[] | undefined; + group: IGroupByColumn; + groupBy: TIssueGroupByOptions | undefined; + displayProperties: IIssueDisplayProperties | undefined; + containerRef: MutableRefObject; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined; + getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader; +} + +// List loader component +const ListLoaderItemRow = forwardRef((props, ref) => ( +
    +
    + + +
    +
    + {[...Array(6)].map((_, index) => ( + + + + ))} +
    +
    +)); +ListLoaderItemRow.displayName = "ListLoaderItemRow"; + +export const ListGroup = observer((props: Props) => { + const { + groupIssueIds = [], + group, + groupBy, + displayProperties, + containerRef, + showEmptyGroup, + loadMoreIssues, + getGroupIssueCount, + getPaginationData, + getIssueLoader, + } = props; + const [isExpanded, setIsExpanded] = useState(true); + const groupRef = useRef(null); + + const [intersectionElement, setIntersectionElement] = useState(null); + + const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0; + const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults; + const isPaginating = !!getIssueLoader(group.id); + + useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`); + + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds + ? groupIssueIds.length < groupIssueCount + : !!nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
    loadMoreIssues(group.id)} + > + Load More ↓ +
    + ); + + const validateEmptyIssueGroups = (issueCount: number = 0) => { + if (!showEmptyGroup && issueCount <= 0) return false; + return true; + }; + + const toggleListGroup = () => { + setIsExpanded((prevState) => !prevState); + }; + + const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy; + + return validateEmptyIssueGroups(groupIssueCount) ? ( +
    +
    + +
    + {shouldExpand && ( +
    + {groupIssueIds && ( + + )} + + {shouldLoadMore && (groupBy ? <>{loadMore} : )} +
    + )} +
    + ) : null; +}); diff --git a/space/core/components/issues/issue-layouts/list/root.tsx b/space/core/components/issues/issue-layouts/list/root.tsx deleted file mode 100644 index ec22e745af5..00000000000 --- a/space/core/components/issues/issue-layouts/list/root.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; -import { FC } from "react"; -import { observer } from "mobx-react"; -// components -import { IssueListLayoutBlock, IssueListLayoutHeader } from "@/components/issues"; -// mobx hook -import { useIssue } from "@/hooks/store"; - -type Props = { - anchor: string; -}; - -export const IssuesListLayoutRoot: FC = observer((props) => { - const { anchor } = props; - // store hooks - const { states, getFilteredIssuesByState } = useIssue(); - - return ( - <> - {states?.map((state) => { - const issues = getFilteredIssuesByState(state.id); - - return ( -
    - - {issues && issues.length > 0 ? ( -
    - {issues.map((issue) => ( - - ))} -
    - ) : ( -
    No issues.
    - )} -
    - ); - })} - - ); -}); diff --git a/space/core/components/issues/issue-layouts/properties/all-properties.tsx b/space/core/components/issues/issue-layouts/properties/all-properties.tsx new file mode 100644 index 00000000000..3c596cb53c9 --- /dev/null +++ b/space/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { observer } from "mobx-react"; +import { Layers, Link, Paperclip } from "lucide-react"; +// types +import { cn } from "@plane/editor"; +import { IIssueDisplayProperties } from "@plane/types"; +import { Tooltip } from "@plane/ui"; +// ui +// components +import { + IssueBlockDate, + IssueBlockLabels, + IssueBlockPriority, + IssueBlockState, + IssueBlockMembers, + IssueBlockModules, + IssueBlockCycle, +} from "@/components/issues"; +import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +//// hooks +import { IIssue } from "@/types/issue"; + +export interface IIssueProperties { + issue: IIssue; + displayProperties: IIssueDisplayProperties | undefined; + className: string; +} + +export const IssueProperties: React.FC = observer((props) => { + const { issue, displayProperties, className } = props; + + if (!displayProperties || !issue.project_id) return null; + + const minDate = getDate(issue.start_date); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(issue.target_date); + maxDate?.setDate(maxDate.getDate()); + + return ( +
    + {/* basic properties */} + {/* state */} + {issue.state_id && ( + +
    + +
    +
    + )} + + {/* priority */} + +
    + +
    +
    + + {/* label */} + +
    + +
    +
    + + {/* start date */} + {issue?.start_date && ( + +
    + +
    +
    + )} + + {/* target/due date */} + {issue?.target_date && ( + +
    + +
    +
    + )} + + {/* assignee */} + +
    + +
    +
    + + {/* modules */} + {issue.module_ids && issue.module_ids.length > 0 && ( + +
    + +
    +
    + )} + + {/* cycles */} + {issue.cycle_id && ( + +
    + +
    +
    + )} + + {/* estimates */} + {/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && ( + +
    + +
    +
    + )} */} + + {/* extra render properties */} + {/* sub-issues */} + !!properties.sub_issue_count && !!issue.sub_issues_count} + > + +
    + +
    {issue.sub_issues_count}
    +
    +
    +
    + + {/* attachments */} + !!properties.attachment_count && !!issue.attachment_count} + > + +
    + +
    {issue.attachment_count}
    +
    +
    +
    + + {/* link */} + !!properties.link && !!issue.link_count} + > + +
    + +
    {issue.link_count}
    +
    +
    +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/properties/cycle.tsx b/space/core/components/issues/issue-layouts/properties/cycle.tsx new file mode 100644 index 00000000000..52c10578978 --- /dev/null +++ b/space/core/components/issues/issue-layouts/properties/cycle.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { observer } from "mobx-react"; +// ui +import { cn } from "@plane/editor"; +import { ContrastIcon, Tooltip } from "@plane/ui"; +//hooks +import { useCycle } from "@/hooks/store/use-cycle"; + +type Props = { + cycleId: string | undefined; + shouldShowBorder?: boolean; +}; + +export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: Props) => { + const { getCycleById } = useCycle(); + + const cycle = getCycleById(cycleId); + + return ( + +
    +
    + +
    {cycle?.name ?? "No Cycle"}
    +
    +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/properties/due-date.tsx b/space/core/components/issues/issue-layouts/properties/due-date.tsx index 3b73973e72f..fd4875154b0 100644 --- a/space/core/components/issues/issue-layouts/properties/due-date.tsx +++ b/space/core/components/issues/issue-layouts/properties/due-date.tsx @@ -1,32 +1,41 @@ "use client"; +import { observer } from "mobx-react"; import { CalendarCheck2 } from "lucide-react"; -// types -import { TStateGroups } from "@plane/types"; +import { Tooltip } from "@plane/ui"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks +import { useStates } from "@/hooks/store"; type Props = { - due_date: string; - group: TStateGroups; + due_date: string | undefined; + stateId: string | undefined; + shouldHighLight?: boolean; + shouldShowBorder?: boolean; }; -export const IssueBlockDueDate = (props: Props) => { - const { due_date, group } = props; +export const IssueBlockDate = observer((props: Props) => { + const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props; + const { getStateById } = useStates(); + + const state = getStateById(stateId); + + const formattedDate = renderFormattedDate(due_date); return ( -
    - - {renderFormattedDate(due_date)} -
    + +
    + + {formattedDate ? formattedDate : "No Date"} +
    +
    ); -}; +}); diff --git a/space/core/components/issues/issue-layouts/properties/index.ts b/space/core/components/issues/issue-layouts/properties/index.ts index de78f996697..44df8ad47a4 100644 --- a/space/core/components/issues/issue-layouts/properties/index.ts +++ b/space/core/components/issues/issue-layouts/properties/index.ts @@ -2,3 +2,7 @@ export * from "./due-date"; export * from "./labels"; export * from "./priority"; export * from "./state"; +export * from "./cycle"; +export * from "./member"; +export * from "./modules"; +export * from "./all-properties"; diff --git a/space/core/components/issues/issue-layouts/properties/labels.tsx b/space/core/components/issues/issue-layouts/properties/labels.tsx index 75c32c4a035..e124663e8d0 100644 --- a/space/core/components/issues/issue-layouts/properties/labels.tsx +++ b/space/core/components/issues/issue-layouts/properties/labels.tsx @@ -1,17 +1,70 @@ "use client"; -export const IssueBlockLabels = ({ labels }: any) => ( -
    - {labels?.map((_label: any) => ( -
    -
    -
    -
    {_label?.name}
    +import { observer } from "mobx-react"; +import { Tags } from "lucide-react"; +import { Tooltip } from "@plane/ui"; +import { useLabel } from "@/hooks/store"; + +type Props = { + labelIds: string[]; + shouldShowLabel?: boolean; +}; + +export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => { + const { getLabelsByIds } = useLabel(); + + const labels = getLabelsByIds(labelIds); + + const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels"; + + if (labels.length <= 0) + return ( + +
    + + {shouldShowLabel && No Labels} +
    +
    + ); + + return ( +
    + {labels.length <= 2 ? ( + <> + {labels.map((label) => ( + +
    +
    + +
    {label?.name}
    +
    +
    +
    + ))} + + ) : ( +
    + +
    + + {`${labels.length} Labels`} +
    +
    -
    - ))} -
    -); + )} +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/properties/member.tsx b/space/core/components/issues/issue-layouts/properties/member.tsx new file mode 100644 index 00000000000..bac44d52322 --- /dev/null +++ b/space/core/components/issues/issue-layouts/properties/member.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { observer } from "mobx-react"; +// icons +import { LucideIcon, Users } from "lucide-react"; +// ui +import { cn } from "@plane/editor"; +import { Avatar, AvatarGroup } from "@plane/ui"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +// +import { TPublicMember } from "@/types/member"; + +type Props = { + memberIds: string[]; + shouldShowBorder?: boolean; +}; + +type AvatarProps = { + showTooltip: boolean; + members: TPublicMember[]; + icon?: LucideIcon; +}; + +export const ButtonAvatars: React.FC = observer((props: AvatarProps) => { + const { showTooltip, members, icon: Icon } = props; + + if (Array.isArray(members)) { + if (members.length > 1) { + return ( + + {members.map((member) => { + if (!member) return; + return ; + })} + + ); + } else if (members.length === 1) { + return ( + + ); + } + } + + return Icon ? : ; +}); + +export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => { + const { getMembersByIds } = useMember(); + + const members = getMembersByIds(memberIds); + + return ( +
    +
    +
    + + {!shouldShowBorder && members.length <= 1 && ( + {members?.[0]?.member__display_name ?? "No Assignees"} + )} +
    +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/properties/modules.tsx b/space/core/components/issues/issue-layouts/properties/modules.tsx new file mode 100644 index 00000000000..eaa30d9908b --- /dev/null +++ b/space/core/components/issues/issue-layouts/properties/modules.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { observer } from "mobx-react"; +// planes +import { cn } from "@plane/editor"; +import { DiceIcon, Tooltip } from "@plane/ui"; +// hooks +import { useModule } from "@/hooks/store/use-module"; + +type Props = { + moduleIds: string[] | undefined; + shouldShowBorder?: boolean; +}; + +export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true }: Props) => { + const { getModulesByIds } = useModule(); + + const modules = getModulesByIds(moduleIds ?? []); + + const modulesString = modules.map((module) => module.name).join(", "); + + return ( +
    + + {modules.length <= 1 ? ( +
    +
    + +
    {modules?.[0]?.name ?? "No Modules"}
    +
    +
    + ) : ( +
    +
    +
    {modules.length} Modules
    +
    +
    + )} +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/properties/priority.tsx b/space/core/components/issues/issue-layouts/properties/priority.tsx index b91d56bb87b..efaa8ea36ac 100644 --- a/space/core/components/issues/issue-layouts/properties/priority.tsx +++ b/space/core/components/issues/issue-layouts/properties/priority.tsx @@ -2,17 +2,29 @@ // types import { TIssuePriorities } from "@plane/types"; +import { Tooltip } from "@plane/ui"; // constants import { issuePriorityFilter } from "@/constants/issue"; -export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorities | null }) => { +export const IssueBlockPriority = ({ + priority, + shouldShowName = false, +}: { + priority: TIssuePriorities | null; + shouldShowName?: boolean; +}) => { const priority_detail = priority != null ? issuePriorityFilter(priority) : null; if (priority_detail === null) return <>; return ( -
    - {priority_detail?.icon} -
    + +
    +
    + {priority_detail?.icon} +
    + {shouldShowName && {priority_detail?.title}} +
    +
    ); }; diff --git a/space/core/components/issues/issue-layouts/properties/state.tsx b/space/core/components/issues/issue-layouts/properties/state.tsx index 31e851c6495..56a09bcd99f 100644 --- a/space/core/components/issues/issue-layouts/properties/state.tsx +++ b/space/core/components/issues/issue-layouts/properties/state.tsx @@ -1,13 +1,33 @@ "use client"; +import { observer } from "mobx-react"; // ui -import { StateGroupIcon } from "@plane/ui"; - -export const IssueBlockState = ({ state }: any) => ( -
    -
    - -
    {state?.name}
    -
    -
    -); +import { cn } from "@plane/editor"; +import { StateGroupIcon, Tooltip } from "@plane/ui"; +//hooks +import { useStates } from "@/hooks/store"; + +type Props = { + stateId: string | undefined; + shouldShowBorder?: boolean; +}; +export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => { + const { getStateById } = useStates(); + + const state = getStateById(stateId); + + return ( + +
    +
    + +
    {state?.name ?? "State"}
    +
    +
    +
    + ); +}); diff --git a/space/core/components/issues/issue-layouts/root.tsx b/space/core/components/issues/issue-layouts/root.tsx index 59a50875d9f..b487aff3a98 100644 --- a/space/core/components/issues/issue-layouts/root.tsx +++ b/space/core/components/issues/issue-layouts/root.tsx @@ -2,8 +2,6 @@ import { FC, useEffect } from "react"; import { observer } from "mobx-react"; -import Image from "next/image"; -import { useSearchParams } from "next/navigation"; import useSWR from "swr"; // components import { IssueKanbanLayoutRoot, IssuesListLayoutRoot } from "@/components/issues"; @@ -14,7 +12,7 @@ import { useIssue, useIssueDetails, useIssueFilter } from "@/hooks/store"; // store import { PublishStore } from "@/store/publish/publish.store"; // assets -import SomethingWentWrongImage from "public/something-went-wrong.svg"; +import { SomethingWentWrongError } from "./error"; type Props = { peekId: string | undefined; @@ -23,22 +21,22 @@ type Props = { export const IssuesLayoutsRoot: FC = observer((props) => { const { peekId, publishSettings } = props; - // query params - const searchParams = useSearchParams(); - const states = searchParams.get("states") || undefined; - const priority = searchParams.get("priority") || undefined; - const labels = searchParams.get("labels") || undefined; // store hooks const { getIssueFilters } = useIssueFilter(); - const { loader, issues, error, fetchPublicIssues } = useIssue(); + const { fetchPublicIssues } = useIssue(); const issueDetailStore = useIssueDetails(); // derived values const { anchor } = publishSettings; const issueFilters = anchor ? getIssueFilters(anchor) : undefined; + // derived values + const activeLayout = issueFilters?.display_filters?.layout || undefined; - useSWR( + const { error } = useSWR( anchor ? `PUBLIC_ISSUES_${anchor}` : null, - anchor ? () => fetchPublicIssues(anchor, { states, priority, labels }) : null + anchor + ? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 }) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } ); useEffect(() => { @@ -47,51 +45,29 @@ export const IssuesLayoutsRoot: FC = observer((props) => { } }, [peekId, issueDetailStore]); - // derived values - const activeLayout = issueFilters?.display_filters?.layout || undefined; - if (!anchor) return null; + if (error) return ; + return (
    {peekId && } + {activeLayout && ( +
    + {/* applied filters */} + - {loader && !issues ? ( -
    Loading...
    - ) : ( - <> - {error ? ( -
    -
    -
    -
    - Oops! Something went wrong -
    -
    -

    Oops! Something went wrong.

    -

    The public board does not exist. Please check the URL.

    -
    + {activeLayout === "list" && ( +
    + +
    + )} + {activeLayout === "kanban" && ( +
    +
    - ) : ( - activeLayout && ( -
    - {/* applied filters */} - - - {activeLayout === "list" && ( -
    - -
    - )} - {activeLayout === "kanban" && ( -
    - -
    - )} -
    - ) )} - +
    )}
    ); diff --git a/space/core/components/issues/issue-layouts/utils.tsx b/space/core/components/issues/issue-layouts/utils.tsx new file mode 100644 index 00000000000..992f6367c93 --- /dev/null +++ b/space/core/components/issues/issue-layouts/utils.tsx @@ -0,0 +1,240 @@ +"use client"; + +import isNil from "lodash/isNil"; +import { ContrastIcon } from "lucide-react"; +// types +import { + GroupByColumnTypes, + IGroupByColumn, + TCycleGroups, + IIssueDisplayProperties, + TGroupedIssues, +} from "@plane/types"; +// ui +import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui"; +// components +// constants +import { ISSUE_PRIORITIES } from "@/constants/issue"; +// stores +import { ICycleStore } from "@/store/cycle.store"; +import { IIssueLabelStore } from "@/store/label.store"; +import { IIssueMemberStore } from "@/store/members.store"; +import { IIssueModuleStore } from "@/store/module.store"; +import { IStateStore } from "@/store/state.store"; + +export const HIGHLIGHT_CLASS = "highlight"; +export const HIGHLIGHT_WITH_LINE = "highlight-with-line"; + +export const getGroupByColumns = ( + groupBy: GroupByColumnTypes | null, + cycle: ICycleStore, + module: IIssueModuleStore, + label: IIssueLabelStore, + projectState: IStateStore, + member: IIssueMemberStore, + includeNone?: boolean +): IGroupByColumn[] | undefined => { + switch (groupBy) { + case "cycle": + return getCycleColumns(cycle); + case "module": + return getModuleColumns(module); + case "state": + return getStateColumns(projectState); + case "priority": + return getPriorityColumns(); + case "labels": + return getLabelsColumns(label) as any; + case "assignees": + return getAssigneeColumns(member) as any; + case "created_by": + return getCreatedByColumns(member) as any; + default: + if (includeNone) return [{ id: `All Issues`, name: `All Issues`, payload: {}, icon: undefined }]; + } +}; + +const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => { + const { cycles } = cycleStore; + + if (!cycles) return; + + const cycleGroups: IGroupByColumn[] = []; + + cycles.map((cycle) => { + if (cycle) { + const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + cycleGroups.push({ + id: cycle.id, + name: cycle.name, + icon: , + payload: { cycle_id: cycle.id }, + }); + } + }); + cycleGroups.push({ + id: "None", + name: "None", + icon: , + payload: { cycle_id: null }, + }); + + return cycleGroups; +}; + +const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => { + const { modules } = moduleStore; + + if (!modules) return; + + const moduleGroups: IGroupByColumn[] = []; + + modules.map((moduleInfo) => { + if (moduleInfo) + moduleGroups.push({ + id: moduleInfo.id, + name: moduleInfo.name, + icon: , + payload: { module_ids: [moduleInfo.id] }, + }); + }) as any; + moduleGroups.push({ + id: "None", + name: "None", + icon: , + payload: { module_ids: [] }, + }); + + return moduleGroups as any; +}; + +const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => { + const { sortedStates } = projectState; + if (!sortedStates) return; + + return sortedStates.map((state) => ({ + id: state.id, + name: state.name, + icon: ( +
    + +
    + ), + payload: { state_id: state.id }, + })) as any; +}; + +const getPriorityColumns = () => { + const priorities = ISSUE_PRIORITIES; + + return priorities.map((priority) => ({ + id: priority.key, + name: priority.title, + icon: , + payload: { priority: priority.key }, + })); +}; + +const getLabelsColumns = (label: IIssueLabelStore) => { + const { labels: storeLabels } = label; + + if (!storeLabels) return; + + const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }]; + + return labels.map((label) => ({ + id: label.id, + name: label.name, + icon: ( +
    + ), + payload: label?.id === "None" ? {} : { label_ids: [label.id] }, + })); +}; + +const getAssigneeColumns = (member: IIssueMemberStore) => { + const { members } = member; + + if (!members) return; + + const assigneeColumns: any = members.map((member) => ({ + id: member.id, + name: member?.member__display_name || "", + icon: , + payload: { assignee_ids: [member.id] }, + })); + + assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); + + return assigneeColumns; +}; + +const getCreatedByColumns = (member: IIssueMemberStore) => { + const { members } = member; + + if (!members) return; + + return members.map((member) => ({ + id: member.id, + name: member?.member__display_name || "", + icon: , + payload: {}, + })); +}; + +export const getDisplayPropertiesCount = ( + displayProperties: IIssueDisplayProperties, + ignoreFields?: (keyof IIssueDisplayProperties)[] +) => { + const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[]; + + let count = 0; + + for (const propertyKey of propertyKeys) { + if (ignoreFields && ignoreFields.includes(propertyKey)) continue; + if (displayProperties[propertyKey]) count++; + } + + return count; +}; + +export const getIssueBlockId = ( + issueId: string | undefined, + groupId: string | undefined, + subGroupId?: string | undefined +) => `issue_${issueId}_${groupId}_${subGroupId}`; + +/** + * returns empty Array if groupId is None + * @param groupId + * @returns + */ +export const getGroupId = (groupId: string) => { + if (groupId === "None") return []; + return [groupId]; +}; + +/** + * method that removes Null or undefined Keys from object + * @param obj + * @returns + */ +export const removeNillKeys = (obj: T) => + Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value))); + +/** + * This Method returns if the the grouped values are subGrouped + * @param groupedIssueIds + * @returns + */ +export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => { + if (!groupedIssueIds || Array.isArray(groupedIssueIds)) { + return false; + } + + if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) { + return false; + } + + return true; +}; diff --git a/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx b/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx new file mode 100644 index 00000000000..51ce71a7723 --- /dev/null +++ b/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { IIssueDisplayProperties } from "@plane/types"; + +interface IWithDisplayPropertiesHOC { + displayProperties: IIssueDisplayProperties; + shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean; + displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[]; + children: ReactNode; +} + +export const WithDisplayPropertiesHOC = observer( + ({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => { + let shouldDisplayPropertyFromFilters = false; + if (Array.isArray(displayPropertyKey)) + shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]); + else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey]; + + const renderProperty = + shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true); + + if (!renderProperty) return null; + + return <>{children}; + } +); diff --git a/space/core/components/issues/peek-overview/comment/add-comment.tsx b/space/core/components/issues/peek-overview/comment/add-comment.tsx index 6f9dda0cd79..29b1519f5e3 100644 --- a/space/core/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -66,12 +66,13 @@ export const AddComment: React.FC = observer((props) => { control={control} render={({ field: { value, onChange } }) => ( { - if (currentUser) handleSubmit(onSubmit)(); + onEnterKeyPress={(e) => { + if (currentUser) handleSubmit(onSubmit)(e); }} workspaceId={workspaceID?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""} ref={editorRef} + id="peek-overview-add-comment" initialValue={ !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) ? watch("comment_html") @@ -79,6 +80,7 @@ export const AddComment: React.FC = observer((props) => { } onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} + placeholder="Add Comment..." /> )} /> diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 4f36cb55fbc..47b506b9658 100644 --- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -103,8 +103,9 @@ export const CommentCard: React.FC = observer((props) => { handleSubmit(handleCommentUpdate)()} + onEnterKeyPress={handleSubmit(handleCommentUpdate)} ref={editorRef} + id={comment.id} initialValue={value} value={null} onChange={(comment_json, comment_html) => onChange(comment_html)} @@ -132,7 +133,7 @@ export const CommentCard: React.FC = observer((props) => {
    - +
    diff --git a/space/core/components/issues/peek-overview/index.ts b/space/core/components/issues/peek-overview/index.ts index f42253e5e79..e0142b02406 100644 --- a/space/core/components/issues/peek-overview/index.ts +++ b/space/core/components/issues/peek-overview/index.ts @@ -7,5 +7,3 @@ export * from "./issue-properties"; export * from "./layout"; export * from "./side-peek-view"; export * from "./issue-reaction"; -export * from "./issue-vote-reactions"; -export * from "./issue-emoji-reactions"; diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx index 97a659554b4..b47bfad68cb 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -1,6 +1,8 @@ +import { observer } from "mobx-react"; // components import { RichTextReadOnlyEditor } from "@/components/editor"; import { IssueReactions } from "@/components/issues/peek-overview"; +import { usePublish } from "@/hooks/store"; // types import { IIssue } from "@/types/issue"; @@ -9,19 +11,22 @@ type Props = { issueDetails: IIssue; }; -export const PeekOverviewIssueDetails: React.FC = (props) => { +export const PeekOverviewIssueDetails: React.FC = observer((props) => { const { anchor, issueDetails } = props; + const { project_details } = usePublish(anchor); + const description = issueDetails.description_html; return (
    - {issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id} + {project_details?.identifier}-{issueDetails?.sequence_id}

    {issueDetails.name}

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

    " && ( = (props) => {
    ); -}; +}); diff --git a/space/core/components/issues/peek-overview/issue-properties.tsx b/space/core/components/issues/peek-overview/issue-properties.tsx index 8b81f8c5e31..0749f8519b4 100644 --- a/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/space/core/components/issues/peek-overview/issue-properties.tsx @@ -1,5 +1,7 @@ "use client"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { CalendarCheck2, Signal } from "lucide-react"; // ui import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; @@ -12,6 +14,8 @@ 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"; +// hooks +import { usePublish, useStates } from "@/hooks/store"; // types import { IIssue, IPeekMode } from "@/types/issue"; @@ -20,8 +24,13 @@ type Props = { mode?: IPeekMode; }; -export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mode }) => { - const state = issueDetails.state_detail; +export const PeekOverviewIssueProperties: React.FC = observer(({ issueDetails, mode }) => { + const { getStateById } = useStates(); + const state = getStateById(issueDetails?.state_id ?? undefined); + + const { anchor } = useParams(); + + const { project_details } = usePublish(anchor?.toString()); const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null; @@ -42,7 +51,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod {mode === "full" && (
    - {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} + {project_details?.identifier}-{issueDetails.sequence_id}
    - + {addSpaceIfCamelCase(state?.name ?? "")}
    @@ -101,10 +110,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod {issueDetails.target_date ? (
    @@ -118,4 +124,4 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod
    ); -}; +}); diff --git a/space/core/components/issues/peek-overview/issue-reaction.tsx b/space/core/components/issues/peek-overview/issue-reaction.tsx index 953852d05af..13455314138 100644 --- a/space/core/components/issues/peek-overview/issue-reaction.tsx +++ b/space/core/components/issues/peek-overview/issue-reaction.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { IssueEmojiReactions, IssueVotes } from "@/components/issues/peek-overview"; +import { IssueEmojiReactions, IssueVotes } from "@/components/issues/reactions"; // hooks import { usePublish } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; diff --git a/space/core/components/issues/peek-overview/layout.tsx b/space/core/components/issues/peek-overview/layout.tsx index 39f5d62162f..0e4d0b85f26 100644 --- a/space/core/components/issues/peek-overview/layout.tsx +++ b/space/core/components/issues/peek-overview/layout.tsx @@ -6,16 +6,17 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Dialog, Transition } from "@headlessui/react"; // components import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overview"; -// store -import { useIssue, useIssueDetails } from "@/hooks/store"; +// hooks +import { useIssueDetails } from "@/hooks/store"; type TIssuePeekOverview = { anchor: string; peekId: string; + handlePeekClose?: () => void; }; export const IssuePeekOverview: FC = observer((props) => { - const { anchor, peekId } = props; + const { anchor, peekId, handlePeekClose } = props; const router = useRouter(); const searchParams = useSearchParams(); // query params @@ -28,19 +29,22 @@ export const IssuePeekOverview: FC = observer((props) => { const [isModalPeekOpen, setIsModalPeekOpen] = useState(false); // store const issueDetailStore = useIssueDetails(); - const issueStore = useIssue(); const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; useEffect(() => { - if (anchor && peekId && issueStore.issues && issueStore.issues.length > 0) { - if (!issueDetails) { - issueDetailStore.fetchIssueDetails(anchor, peekId.toString()); - } + if (anchor && peekId) { + issueDetailStore.fetchIssueDetails(anchor, peekId.toString()); } - }, [anchor, issueDetailStore, issueDetails, peekId, issueStore.issues]); + }, [anchor, issueDetailStore, peekId]); const handleClose = () => { + // if close logic is passed down, call that instead of the below logic + if (handlePeekClose) { + handlePeekClose(); + return; + } + issueDetailStore.setPeekId(null); let queryParams: any = { board, diff --git a/space/core/components/issues/reactions/index.ts b/space/core/components/issues/reactions/index.ts new file mode 100644 index 00000000000..914579fa459 --- /dev/null +++ b/space/core/components/issues/reactions/index.ts @@ -0,0 +1,2 @@ +export * from "./issue-emoji-reactions"; +export * from "./issue-vote-reactions"; diff --git a/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/core/components/issues/reactions/issue-emoji-reactions.tsx similarity index 51% rename from space/core/components/issues/peek-overview/issue-emoji-reactions.tsx rename to space/core/components/issues/reactions/issue-emoji-reactions.tsx index d2a282acef9..fd78a558e1c 100644 --- a/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/core/components/issues/reactions/issue-emoji-reactions.tsx @@ -13,10 +13,12 @@ import { useIssueDetails, useUser } from "@/hooks/store"; type IssueEmojiReactionsProps = { anchor: string; + issueIdFromProps?: string; + size?: "md" | "sm"; }; export const IssueEmojiReactions: React.FC = observer((props) => { - const { anchor } = props; + const { anchor, issueIdFromProps, size = "md" } = props; // router const router = useRouter(); const pathName = usePathname(); @@ -31,11 +33,11 @@ export const IssueEmojiReactions: React.FC = observer( const issueDetailsStore = useIssueDetails(); const { data: user } = useUser(); - const issueId = issueDetailsStore.peekId; - const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; + const issueId = issueIdFromProps ?? issueDetailsStore.peekId; + const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? []; const groupedReactions = groupReactions(reactions, "reaction"); - const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id); + const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id); const handleAddReaction = (reactionHex: string) => { if (!issueId) return; @@ -48,13 +50,14 @@ export const IssueEmojiReactions: React.FC = observer( }; const handleReactionClick = (reactionHex: string) => { - const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex); + const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex); if (userReaction) handleRemoveReaction(reactionHex); else handleAddReaction(reactionHex); }; // derived values const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + const reactionDimensions = size === "sm" ? "h-6 px-2 py-1" : "h-full px-2 py-1"; return ( <> @@ -64,54 +67,52 @@ export const IssueEmojiReactions: React.FC = observer( else router.push(`/?next_path=${pathName}?${queryParam}`); }} selected={userReactions?.map((r) => r.reaction)} - size="md" + size={size} /> -
    - {Object.keys(groupedReactions || {}).map((reaction) => { - const reactions = groupedReactions?.[reaction] ?? []; - const REACTIONS_LIMIT = 1000; + {Object.keys(groupedReactions || {}).map((reaction) => { + const reactions = groupedReactions?.[reaction] ?? []; + const REACTIONS_LIMIT = 1000; - if (reactions.length > 0) - return ( - - {reactions - .map((r) => r.actor_detail.display_name) - .splice(0, REACTIONS_LIMIT) - .join(", ")} - {reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"} -
    - } + if (reactions.length > 0) + return ( + + {reactions + ?.map((r) => r?.actor_details?.display_name) + ?.splice(0, REACTIONS_LIMIT) + ?.join(", ")} + {reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"} +
    + } + > + - - ); - })} -
    + {groupedReactions?.[reaction].length}{" "} + + + + ); + })} ); }); diff --git a/space/core/components/issues/peek-overview/issue-vote-reactions.tsx b/space/core/components/issues/reactions/issue-vote-reactions.tsx similarity index 79% rename from space/core/components/issues/peek-overview/issue-vote-reactions.tsx rename to space/core/components/issues/reactions/issue-vote-reactions.tsx index 4e30e69cdd3..7134c05cf99 100644 --- a/space/core/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/core/components/issues/reactions/issue-vote-reactions.tsx @@ -13,10 +13,12 @@ import useIsInIframe from "@/hooks/use-is-in-iframe"; type TIssueVotes = { anchor: string; + issueIdFromProps?: string; + size?: "md" | "sm"; }; export const IssueVotes: React.FC = observer((props) => { - const { anchor } = props; + const { anchor, issueIdFromProps, size = "md" } = props; // states const [isSubmitting, setIsSubmitting] = useState(false); // router @@ -35,22 +37,22 @@ export const IssueVotes: React.FC = observer((props) => { const isInIframe = useIsInIframe(); - const issueId = issueDetailsStore.peekId; + const issueId = issueIdFromProps ?? issueDetailsStore.peekId; - const votes = issueId ? issueDetailsStore.details[issueId]?.votes : []; + const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? []; - const allUpVotes = votes?.filter((vote) => vote.vote === 1); - const allDownVotes = votes?.filter((vote) => vote.vote === -1); + const allUpVotes = votes.filter((vote) => vote.vote === 1); + const allDownVotes = votes.filter((vote) => vote.vote === -1); - const isUpVotedByUser = allUpVotes?.some((vote) => vote.actor === user?.id); - const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id); + const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id); + const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id); const handleVote = async (e: any, voteValue: 1 | -1) => { if (!issueId) return; setIsSubmitting(true); - const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue); + const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue); if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId); else { @@ -66,6 +68,7 @@ export const IssueVotes: React.FC = observer((props) => { // derived values const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels }); + const votingDimensions = size === "sm" ? "px-1 h-6 min-w-9" : "px-2 h-7"; return (
    @@ -76,7 +79,7 @@ export const IssueVotes: React.FC = observer((props) => { {allUpVotes.length > 0 ? ( <> {allUpVotes - .map((r) => r.actor_detail.display_name) + .map((r) => r.actor_details?.display_name) .splice(0, VOTES_LIMIT) .join(", ")} {allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"} @@ -96,7 +99,8 @@ export const IssueVotes: React.FC = observer((props) => { else router.push(`/?next_path=${pathName}?${queryParam}`); }} className={cn( - "flex items-center justify-center gap-x-1 overflow-hidden rounded border px-2 h-7 focus:outline-none", + "flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100", + votingDimensions, { "border-custom-primary-200 text-custom-primary-200": isUpVotedByUser, "border-custom-border-300": !isUpVotedByUser, @@ -116,7 +120,7 @@ export const IssueVotes: React.FC = observer((props) => { {allDownVotes.length > 0 ? ( <> {allDownVotes - .map((r) => r.actor_detail.display_name) + .map((r) => r.actor_details.display_name) .splice(0, VOTES_LIMIT) .join(", ")} {allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"} @@ -136,7 +140,8 @@ export const IssueVotes: React.FC = observer((props) => { else router.push(`/?next_path=${pathName}?${queryParam}`); }} className={cn( - "flex items-center justify-center gap-x-1 h-7 overflow-hidden rounded border px-2 focus:outline-none", + "flex items-center justify-center gap-x-1 overflow-hidden rounded border focus:outline-none bg-custom-background-100", + votingDimensions, { "border-red-600 text-red-600": isDownVotedByUser, "border-custom-border-300": !isDownVotedByUser, diff --git a/space/core/components/ui/not-found.tsx b/space/core/components/ui/not-found.tsx new file mode 100644 index 00000000000..a2535616d80 --- /dev/null +++ b/space/core/components/ui/not-found.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import Image from "next/image"; +// ui +// images +import Image404 from "@/public/404.svg"; + +export const PageNotFound = () => ( +
    +
    +
    +
    + 404- Page not found +
    +
    +

    Oops! Something went wrong.

    +

    + Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is + temporarily unavailable. +

    +
    +
    +
    +
    +); diff --git a/space/core/components/views/auth.tsx b/space/core/components/views/auth.tsx index fb68d8fbabf..2c5a8a2f4de 100644 --- a/space/core/components/views/auth.tsx +++ b/space/core/components/views/auth.tsx @@ -30,7 +30,7 @@ export const AuthView = observer(() => { />
    -
    +
    Plane logo diff --git a/space/core/constants/editor.ts b/space/core/constants/editor.ts index 698faf8cb4d..4c6bef2ccf3 100644 --- a/space/core/constants/editor.ts +++ b/space/core/constants/editor.ts @@ -19,12 +19,12 @@ import { Underline, } from "lucide-react"; // editor -import { EditorMenuItemNames } from "@plane/editor"; +import { TEditorCommands } from "@plane/editor"; type TEditorTypes = "lite" | "document"; export type ToolbarMenuItem = { - key: EditorMenuItemNames; + key: TEditorCommands; name: string; icon: LucideIcon; shortcut?: string[]; diff --git a/space/core/constants/issue.ts b/space/core/constants/issue.ts index 5d858a70e64..1d9ebbb19c6 100644 --- a/space/core/constants/issue.ts +++ b/space/core/constants/issue.ts @@ -76,3 +76,14 @@ export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilter if (currentIssuePriority) return currentIssuePriority; return undefined; }; + +export const ISSUE_PRIORITIES: { + key: TIssuePriorities; + title: string; +}[] = [ + { key: "urgent", title: "Urgent" }, + { key: "high", title: "High" }, + { key: "medium", title: "Medium" }, + { key: "low", title: "Low" }, + { key: "none", title: "None" }, +]; \ No newline at end of file diff --git a/space/core/hooks/store/index.ts b/space/core/hooks/store/index.ts index 3f82613d567..f6f46eccbc2 100644 --- a/space/core/hooks/store/index.ts +++ b/space/core/hooks/store/index.ts @@ -5,3 +5,8 @@ export * from "./use-user"; export * from "./use-user-profile"; export * from "./use-issue-details"; export * from "./use-issue-filter"; +export * from "./use-state"; +export * from "./use-label"; +export * from "./use-cycle"; +export * from "./use-module"; +export * from "./use-member"; diff --git a/space/core/hooks/store/use-cycle.ts b/space/core/hooks/store/use-cycle.ts new file mode 100644 index 00000000000..554a93c4664 --- /dev/null +++ b/space/core/hooks/store/use-cycle.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { ICycleStore } from "@/store/cycle.store"; + +export const useCycle = (): ICycleStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useCycle must be used within StoreProvider"); + return context.cycle; +}; diff --git a/space/core/hooks/store/use-issue.ts b/space/core/hooks/store/use-issue.ts index 641f05acfa2..1a9de1cb226 100644 --- a/space/core/hooks/store/use-issue.ts +++ b/space/core/hooks/store/use-issue.ts @@ -6,6 +6,6 @@ import { IIssueStore } from "@/store/issue.store"; export const useIssue = (): IIssueStore => { const context = useContext(StoreContext); - if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + if (context === undefined) throw new Error("useIssue must be used within StoreProvider"); return context.issue; }; diff --git a/space/core/hooks/store/use-label.ts b/space/core/hooks/store/use-label.ts new file mode 100644 index 00000000000..7786ba43b5b --- /dev/null +++ b/space/core/hooks/store/use-label.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IIssueLabelStore } from "@/store/label.store"; + +export const useLabel = (): IIssueLabelStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useLabel must be used within StoreProvider"); + return context.label; +}; diff --git a/space/core/hooks/store/use-member.ts b/space/core/hooks/store/use-member.ts new file mode 100644 index 00000000000..80aca3cfb6e --- /dev/null +++ b/space/core/hooks/store/use-member.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IIssueMemberStore } from "@/store/members.store"; + +export const useMember = (): IIssueMemberStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useMember must be used within StoreProvider"); + return context.member; +}; diff --git a/space/core/hooks/store/use-module.ts b/space/core/hooks/store/use-module.ts new file mode 100644 index 00000000000..1749ca9ab07 --- /dev/null +++ b/space/core/hooks/store/use-module.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IIssueModuleStore } from "@/store/module.store"; + +export const useModule = (): IIssueModuleStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useModule must be used within StoreProvider"); + return context.module; +}; diff --git a/space/core/hooks/store/use-state.ts b/space/core/hooks/store/use-state.ts new file mode 100644 index 00000000000..f3f45d47236 --- /dev/null +++ b/space/core/hooks/store/use-state.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IStateStore } from "@/store/state.store"; + +export const useStates = (): IStateStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useState must be used within StoreProvider"); + return context.state; +}; diff --git a/space/core/hooks/use-clipboard-write-permission.tsx b/space/core/hooks/use-clipboard-write-permission.tsx index 0cafbb7ef3e..1f89b829192 100644 --- a/space/core/hooks/use-clipboard-write-permission.tsx +++ b/space/core/hooks/use-clipboard-write-permission.tsx @@ -6,6 +6,7 @@ const useClipboardWritePermission = () => { useEffect(() => { const checkClipboardWriteAccess = () => { navigator.permissions + //eslint-disable-next-line no-undef .query({ name: "clipboard-write" as PermissionName }) .then((result) => { if (result.state === "granted") { diff --git a/space/core/hooks/use-intersection-observer.tsx b/space/core/hooks/use-intersection-observer.tsx new file mode 100644 index 00000000000..63ab31f37d9 --- /dev/null +++ b/space/core/hooks/use-intersection-observer.tsx @@ -0,0 +1,41 @@ +import { RefObject, useEffect } from "react"; + +export type UseIntersectionObserverProps = { + containerRef: RefObject | undefined; + elementRef: HTMLElement | null; + callback: () => void; + rootMargin?: string; +}; + +export const useIntersectionObserver = ( + containerRef: RefObject, + elementRef: HTMLElement | null, + callback: (() => void) | undefined, + rootMargin?: string +) => { + useEffect(() => { + if (elementRef) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[entries.length - 1].isIntersecting) { + callback && callback(); + } + }, + { + root: containerRef?.current, + rootMargin, + } + ); + observer.observe(elementRef); + return () => { + if (elementRef) { + // eslint-disable-next-line react-hooks/exhaustive-deps + observer.unobserve(elementRef); + } + }; + } + // When i am passing callback as a dependency, it is causing infinite loop, + // Please make sure you fix this eslint lint disable error with caution + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootMargin, callback, elementRef, containerRef.current]); +}; diff --git a/space/core/hooks/use-outside-click.tsx b/space/core/hooks/use-outside-click.tsx deleted file mode 100644 index f2bed415f77..00000000000 --- a/space/core/hooks/use-outside-click.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -const useOutSideClick = (ref: any, callback: any) => { - const handleClick = (e: any) => { - if (ref.current && !ref.current.contains(e.target)) { - callback(); - } - }; - - useEffect(() => { - document.addEventListener("click", handleClick); - - return () => { - document.removeEventListener("click", handleClick); - }; - }); -}; - -export default useOutSideClick; diff --git a/space/core/services/cycle.service.ts b/space/core/services/cycle.service.ts new file mode 100644 index 00000000000..6df75ebde12 --- /dev/null +++ b/space/core/services/cycle.service.ts @@ -0,0 +1,17 @@ +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "@/services/api.service"; +import { TPublicCycle } from "@/types/cycle"; + +export class CycleService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getCycles(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/cycles/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts index f864818126d..2f19b4f0801 100644 --- a/space/core/services/issue.service.ts +++ b/space/core/services/issue.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; // types -import { TIssuesResponse } from "@/types/issue"; +import { TIssuesResponse, IIssue } from "@/types/issue"; class IssueService extends APIService { constructor() { @@ -19,7 +19,7 @@ class IssueService extends APIService { }); } - async getIssueById(anchor: string, issueID: string): Promise { + async getIssueById(anchor: string, issueID: string): Promise { return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`) .then((response) => response?.data) .catch((error) => { diff --git a/space/core/services/label.service.ts b/space/core/services/label.service.ts new file mode 100644 index 00000000000..2a2ee5ad979 --- /dev/null +++ b/space/core/services/label.service.ts @@ -0,0 +1,17 @@ +import { IIssueLabel } from "@plane/types"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "./api.service"; + +export class LabelService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getLabels(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/labels/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/services/member.service.ts b/space/core/services/member.service.ts new file mode 100644 index 00000000000..02cd1f77620 --- /dev/null +++ b/space/core/services/member.service.ts @@ -0,0 +1,17 @@ +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "@/services/api.service"; +import { TPublicMember } from "@/types/member"; + +export class MemberService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getAnchorMembers(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/members/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/services/module.service.ts b/space/core/services/module.service.ts new file mode 100644 index 00000000000..f89202b6b6c --- /dev/null +++ b/space/core/services/module.service.ts @@ -0,0 +1,17 @@ +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "@/services/api.service"; +import { TPublicModule } from "@/types/modules"; + +export class ModuleService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getModules(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/modules/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/services/state.service.ts b/space/core/services/state.service.ts new file mode 100644 index 00000000000..153f965280d --- /dev/null +++ b/space/core/services/state.service.ts @@ -0,0 +1,17 @@ +import { IState } from "@plane/types"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "./api.service"; + +export class StateService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getStates(anchor: string): Promise { + return this.get(`/api/public/anchor/${anchor}/states/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/store/cycle.store.ts b/space/core/store/cycle.store.ts new file mode 100644 index 00000000000..a7310290bf4 --- /dev/null +++ b/space/core/store/cycle.store.ts @@ -0,0 +1,40 @@ +import { action, makeObservable, observable, runInAction } from "mobx"; +import { TPublicCycle } from "@/types/cycle"; +import { CycleService } from "../services/cycle.service"; +import { CoreRootStore } from "./root.store"; + +export interface ICycleStore { + // observables + cycles: TPublicCycle[] | undefined; + // computed actions + getCycleById: (cycleId: string | undefined) => TPublicCycle | undefined; + // fetch actions + fetchCycles: (anchor: string) => Promise; +} + +export class CycleStore implements ICycleStore { + cycles: TPublicCycle[] | undefined = undefined; + cycleService: CycleService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + cycles: observable, + // fetch action + fetchCycles: action, + }); + this.cycleService = new CycleService(); + this.rootStore = _rootStore; + } + + getCycleById = (cycleId: string | undefined) => this.cycles?.find((cycle) => cycle.id === cycleId); + + fetchCycles = async (anchor: string) => { + const cyclesResponse = await this.cycleService.getCycles(anchor); + runInAction(() => { + this.cycles = cyclesResponse; + }); + return cyclesResponse; + }; +} diff --git a/space/core/store/helpers/base-issues.store.ts b/space/core/store/helpers/base-issues.store.ts new file mode 100644 index 00000000000..004aa06c630 --- /dev/null +++ b/space/core/store/helpers/base-issues.store.ts @@ -0,0 +1,520 @@ +import concat from "lodash/concat"; +import get from "lodash/get"; +import set from "lodash/set"; +import uniq from "lodash/uniq"; +import update from "lodash/update"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane constants +import { ALL_ISSUES } from "@plane/constants"; +// types +import { + TIssueGroupByOptions, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + IssuePaginationOptions, + TIssues, + TIssuePaginationData, + TGroupedIssueCount, + TPaginationData, +} from "@plane/types"; +// services +import IssueService from "@/services/issue.service"; +import { IIssue, TIssuesResponse } from "@/types/issue"; +import { CoreRootStore } from "../root.store"; +// constants +// helpers + +export type TIssueDisplayFilterOptions = Exclude | "target_date"; + +export enum EIssueGroupedAction { + ADD = "ADD", + DELETE = "DELETE", + REORDER = "REORDER", +} + +export interface IBaseIssuesStore { + // observable + loader: Record; + // actions + addIssue(issues: IIssue[], shouldReplace?: boolean): void; + // helper methods + groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup + groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup + issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup + + // helper methods + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; + getPaginationData(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined; + getIssueLoader(groupId?: string, subGroupId?: string): TLoader; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +export const ISSUE_FILTER_DEFAULT_DATA: Record = { + project: "project_id", + cycle: "cycle_id", + module: "module_ids", + state: "state_id", + "state_detail.group": "state_group" as keyof IIssue, // state_detail.group is only being used for state_group display, + priority: "priority", + labels: "label_ids", + created_by: "created_by", + assignees: "assignee_ids", + target_date: "target_date", +}; + +export abstract class BaseIssuesStore implements IBaseIssuesStore { + loader: Record = {}; + groupedIssueIds: TIssues | undefined = undefined; + issuePaginationData: TIssuePaginationData = {}; + groupedIssueCount: TGroupedIssueCount = {}; + // + paginationOptions: IssuePaginationOptions | undefined = undefined; + + issueService; + // root store + rootIssueStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observable + loader: observable, + groupedIssueIds: observable, + issuePaginationData: observable, + groupedIssueCount: observable, + + paginationOptions: observable, + // action + storePreviousPaginationValues: action.bound, + + onfetchIssues: action.bound, + onfetchNexIssues: action.bound, + clear: action.bound, + setLoader: action.bound, + }); + this.rootIssueStore = _rootStore; + this.issueService = new IssueService(); + } + + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + if (!groupedIssueIds) return undefined; + + const allIssues = groupedIssueIds[ALL_ISSUES] ?? []; + if (allIssues && Array.isArray(allIssues)) { + return allIssues as string[]; + } + + if (groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) { + return (groupedIssueIds[groupId] ?? []) as string[]; + } + + if (groupId && subGroupId) { + return ((groupedIssueIds as TSubGroupedIssues)[groupId]?.[subGroupId] ?? []) as string[]; + } + + return undefined; + }; + + /** + * @description This method will add issues to the issuesMap + * @param {IIssue[]} issues + * @returns {void} + */ + addIssue = (issues: IIssue[], shouldReplace = false) => { + if (issues && issues.length <= 0) return; + runInAction(() => { + issues.forEach((issue) => { + if (!this.rootIssueStore.issueDetail.getIssueById(issue.id) || shouldReplace) + set(this.rootIssueStore.issueDetail.details, issue.id, issue); + }); + }); + }; + + /** + * Store the pagination data required for next subsequent issue pagination calls + * @param prevCursor cursor value of previous page + * @param nextCursor cursor value of next page + * @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages + * @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup + * @param subGroupId + */ + setPaginationData( + prevCursor: string, + nextCursor: string, + nextPageResults: boolean, + groupId?: string, + subGroupId?: string + ) { + const cursorObject = { + prevCursor, + nextCursor, + nextPageResults, + }; + + set(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)], cursorObject); + } + + /** + * Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined + * @param loaderValue + * @param groupId + * @param subGroupId + */ + setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) { + runInAction(() => { + set(this.loader, this.getGroupKey(groupId, subGroupId), loaderValue); + }); + } + + /** + * gets the Loader value of particular group/subgroup/ALL_ISSUES + */ + getIssueLoader = (groupId?: string, subGroupId?: string) => get(this.loader, this.getGroupKey(groupId, subGroupId)); + + /** + * gets the pagination data of particular group/subgroup/ALL_ISSUES + */ + getPaginationData = computedFn( + (groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined => + get(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)]) + ); + + /** + * gets the issue count of particular group/subgroup/ALL_ISSUES + * + * if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds + */ + getGroupIssueCount = computedFn( + ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ): number | undefined => { + if (isSubGroupCumulative && subGroupId) { + const groupIssuesKeys = Object.keys(this.groupedIssueCount); + let subGroupCumulativeCount = 0; + + for (const groupKey of groupIssuesKeys) { + if (groupKey.includes(`_${subGroupId}`)) subGroupCumulativeCount += this.groupedIssueCount[groupKey]; + } + + return subGroupCumulativeCount; + } + + return get(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)]); + } + ); + + /** + * This Method is called after fetching the first paginated issues + * + * This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined + * If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES + * @param issuesResponse Paginated Response received from the API + * @param options Pagination options + * @param workspaceSlug + * @param projectId + * @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store + */ + onfetchIssues(issuesResponse: TIssuesResponse, options: IssuePaginationOptions) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount); + this.loader[this.getGroupKey()] = undefined; + }); + + // store Pagination options for next subsequent calls and data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, options); + } + + /** + * This Method is called on the subsequent pagination calls after the first initial call + * + * This method updates the appropriate issue list based on if groupId or subgroupIds are Passed + * @param issuesResponse Paginated Response received from the API + * @param groupId + * @param subGroupId + */ + onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId); + this.loader[this.getGroupKey(groupId, subGroupId)] = undefined; + }); + + // store Pagination data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId); + } + + /** + * Method called to clear out the current store + */ + clear(shouldClearPaginationOptions = true) { + runInAction(() => { + this.groupedIssueIds = undefined; + this.issuePaginationData = {}; + this.groupedIssueCount = {}; + if (shouldClearPaginationOptions) { + this.paginationOptions = undefined; + } + }); + } + + /** + * This method processes the issueResponse to provide data that can be used to update the store + * @param issueResponse + * @returns issueList, list of issue Data + * @returns groupedIssues, grouped issue Ids + * @returns groupedIssueCount, object containing issue counts of individual groups + */ + processIssueResponse(issueResponse: TIssuesResponse): { + issueList: IIssue[]; + groupedIssues: TIssues; + groupedIssueCount: TGroupedIssueCount; + } { + const issueResult = issueResponse?.results; + + // if undefined return empty objects + if (!issueResult) + return { + issueList: [], + groupedIssues: {}, + groupedIssueCount: {}, + }; + + //if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES + if (Array.isArray(issueResult)) { + return { + issueList: issueResult, + groupedIssues: { + [ALL_ISSUES]: issueResult.map((issue) => issue.id), + }, + groupedIssueCount: { + [ALL_ISSUES]: issueResponse.total_count, + }, + }; + } + + const issueList: IIssue[] = []; + const groupedIssues: TGroupedIssues | TSubGroupedIssues = {}; + const groupedIssueCount: TGroupedIssueCount = {}; + + // update total issue count to ALL_ISSUES + set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count); + + // loop through all the groupIds from issue Result + for (const groupId in issueResult) { + const groupIssuesObject = issueResult[groupId]; + const groupIssueResult = groupIssuesObject?.results; + + // if groupIssueResult is undefined then continue the loop + if (!groupIssueResult) continue; + + // set grouped Issue count of the current groupId + set(groupedIssueCount, [groupId], groupIssuesObject.total_results); + + // if groupIssueResult, the it is not subGrouped + if (Array.isArray(groupIssueResult)) { + // add the result to issueList + issueList.push(...groupIssueResult); + // set the issue Ids to the groupId path + set( + groupedIssues, + [groupId], + groupIssueResult.map((issue) => issue.id) + ); + continue; + } + + // loop through all the subGroupIds from issue Result + for (const subGroupId in groupIssueResult) { + const subGroupIssuesObject = groupIssueResult[subGroupId]; + const subGroupIssueResult = subGroupIssuesObject?.results; + + // if subGroupIssueResult is undefined then continue the loop + if (!subGroupIssueResult) continue; + + // set sub grouped Issue count of the current groupId + set(groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results); + + if (Array.isArray(subGroupIssueResult)) { + // add the result to issueList + issueList.push(...subGroupIssueResult); + // set the issue Ids to the [groupId, subGroupId] path + set( + groupedIssues, + [groupId, subGroupId], + subGroupIssueResult.map((issue) => issue.id) + ); + + continue; + } + } + } + + return { issueList, groupedIssues, groupedIssueCount }; + } + + /** + * This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts + * @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups + * @param groupedIssueCount Object the contains the issue count of each groups + * @param groupId groupId string + * @param subGroupId subGroupId string + * @returns updates the store with the values + */ + updateGroupedIssueIds( + groupedIssues: TIssues, + groupedIssueCount: TGroupedIssueCount, + groupId?: string, + subGroupId?: string + ) { + // if groupId exists and groupedIssues has ALL_ISSUES as a group, + // then it's an individual group/subgroup pagination + if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) { + const issueGroup = groupedIssues[ALL_ISSUES]; + const issueGroupCount = groupedIssueCount[ALL_ISSUES]; + const issuesPath = [groupId]; + // issuesPath is the path for the issue List in the Grouped Issue List + // issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination + if (subGroupId) issuesPath.push(subGroupId); + + // update the issue Count of the particular group/subGroup + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueGroupCount); + + // update the issue list in the issuePath + this.updateIssueGroup(issueGroup, issuesPath); + return; + } + + // if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination + // update total issue count as ALL_ISSUES count in `groupedIssueCount` object + set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]); + + // loop through the groups of groupedIssues. + for (const groupId in groupedIssues) { + const issueGroup = groupedIssues[groupId]; + const issueGroupCount = groupedIssueCount[groupId]; + + // update the groupId's issue count + set(this.groupedIssueCount, [groupId], issueGroupCount); + + // This updates the group issue list in the store, if the issueGroup is a string + const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]); + // if issueGroup is indeed a string, continue + if (storeUpdated) continue; + + // if issueGroup is not a string, loop through the sub group Issues + for (const subGroupId in issueGroup) { + const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId]; + const issueSubGroupCount = groupedIssueCount[this.getGroupKey(groupId, subGroupId)]; + + // update the subGroupId's issue count + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueSubGroupCount); + // This updates the subgroup issue list in the store + this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]); + } + } + } + + /** + * This Method is used to update the issue Id list at the particular issuePath + * @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped + * @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list + * @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath + */ + updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean { + if (!groupedIssueIds) return true; + + // if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath + if (groupedIssueIds && Array.isArray(groupedIssueIds)) { + update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) => + uniq(concat(issueIds, groupedIssueIds as string[])) + ); + // return true to indicate the store has been updated + return true; + } + + // return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues + return false; + } + + /** + * This method is used to update the count of the issues at the path with the increment + * @param path issuePath, corresponding key is to be incremented + * @param increment + */ + updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) { + const updateKeys = Object.keys(accumulatedUpdatesForCount); + for (const updateKey of updateKeys) { + const update = accumulatedUpdatesForCount[updateKey]; + if (!update) continue; + + const increment = update === EIssueGroupedAction.ADD ? 1 : -1; + // get current count at the key + const issueCount = get(this.groupedIssueCount, updateKey) ?? 0; + // update the count at the key + set(this.groupedIssueCount, updateKey, issueCount + increment); + } + } + + /** + * This Method is called to store the pagination options and paginated data from response + * @param issuesResponse issue list response + * @param options pagination options to be stored for next page call + * @param groupId + * @param subGroupId + */ + storePreviousPaginationValues = ( + issuesResponse: TIssuesResponse, + options?: IssuePaginationOptions, + groupId?: string, + subGroupId?: string + ) => { + if (options) this.paginationOptions = options; + + this.setPaginationData( + issuesResponse.prev_cursor, + issuesResponse.next_cursor, + issuesResponse.next_page_results, + groupId, + subGroupId + ); + }; + + /** + * returns, + * A compound key, if both groupId & subGroupId are defined + * groupId, only if groupId is defined + * ALL_ISSUES, if both groupId & subGroupId are not defined + * @param groupId + * @param subGroupId + * @returns + */ + getGroupKey = (groupId?: string, subGroupId?: string) => { + if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`; + + if (groupId) return groupId; + + return ALL_ISSUES; + }; +} diff --git a/space/core/store/helpers/filter.helpers.ts b/space/core/store/helpers/filter.helpers.ts new file mode 100644 index 00000000000..fd949efefd9 --- /dev/null +++ b/space/core/store/helpers/filter.helpers.ts @@ -0,0 +1,73 @@ +import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants"; +import { IssuePaginationOptions, TIssueParams } from "@plane/types"; + +/** + * This Method is used to construct the url params along with paginated values + * @param filterParams params generated from filters + * @param options pagination options + * @param cursor cursor if exists + * @param groupId groupId if to fetch By group + * @param subGroupId groupId if to fetch By sub group + * @returns + */ +export const getPaginationParams = ( + filterParams: Partial> | undefined, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId?: string, + subGroupId?: string +) => { + // if cursor exists, use the cursor. If it doesn't exist construct the cursor based on per page count + const pageCursor = cursor ? cursor : groupId ? `${options.perPageCount}:1:0` : `${options.perPageCount}:0:0`; + + // pagination params + const paginationParams: Partial> = { + ...filterParams, + cursor: pageCursor, + per_page: options.perPageCount.toString(), + }; + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.groupedBy) { + paginationParams.group_by = EIssueGroupByToServerOptions[options.groupedBy]; + } + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.subGroupedBy) { + paginationParams.sub_group_by = EIssueGroupByToServerOptions[options.subGroupedBy]; + } + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.orderBy) { + paginationParams.order_by = options.orderBy; + } + + // If before and after dates are sent from option to filter by then, add them to filter the options + if (options.after && options.before) { + paginationParams["target_date"] = `${options.after};after,${options.before};before`; + } + + // If groupId is passed down, add a filter param for that group Id + if (groupId) { + const groupBy = paginationParams["group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["group_by"]; + + if (groupBy) { + const groupByFilterOption = EServerGroupByToFilterOptions[groupBy]; + paginationParams[groupByFilterOption] = groupId; + } + } + + // If subGroupId is passed down, add a filter param for that subGroup Id + if (subGroupId) { + const subGroupBy = paginationParams["sub_group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["sub_group_by"]; + + if (subGroupBy) { + const subGroupByFilterOption = EServerGroupByToFilterOptions[subGroupBy]; + paginationParams[subGroupByFilterOption] = subGroupId; + } + } + + return paginationParams; +}; diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts index 03ba4bd8675..8b4710b17b5 100644 --- a/space/core/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -1,4 +1,7 @@ +import isEmpty from "lodash/isEmpty"; +import set from "lodash/set"; import { makeObservable, observable, action, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; // services import IssueService from "@/services/issue.service"; @@ -16,7 +19,10 @@ export interface IIssueDetailStore { details: { [key: string]: IIssue; }; + // computed actions + getIsIssuePeeked: (issueID: string) => boolean; // actions + getIssueById: (issueId: string) => IIssue | undefined; setPeekId: (issueID: string | null) => void; setPeekMode: (mode: IPeekMode) => void; // issue actions @@ -87,6 +93,38 @@ export class IssueDetailStore implements IIssueDetailStore { this.peekMode = mode; }; + getIsIssuePeeked = (issueID: string) => this.peekId === issueID; + + /** + * @description This method will return the issue from the issuesMap + * @param {string} issueId + * @returns {IIssue | undefined} + */ + getIssueById = computedFn((issueId: string) => { + if (!issueId || isEmpty(this.details) || !this.details[issueId]) return undefined; + return this.details[issueId]; + }); + + /** + * Retrieves issue from API + * @param anchorId ] + * @param issueId + * @returns + */ + fetchIssueById = async (anchorId: string, issueId: string) => { + try { + const issueDetails = await this.issueService.getIssueById(anchorId, issueId); + + runInAction(() => { + set(this.details, [issueId], issueDetails); + }); + + return issueDetails; + } catch (e) { + console.error(`Error fetching issue details for issueId ${issueId}: `, e); + } + }; + /** * @description fetc * @param {string} anchor @@ -97,7 +135,7 @@ export class IssueDetailStore implements IIssueDetailStore { this.loader = true; this.error = null; - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const issueDetails = await this.fetchIssueById(anchor, issueID); const commentsResponse = await this.issueService.getIssueComments(anchor, issueID); if (issueDetails) { @@ -119,17 +157,11 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueComment = async (anchor: string, issueID: string, data: any) => { try { - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const issueDetails = this.getIssueById(issueID); const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data); if (issueDetails) { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...issueDetails, - comments: [...this.details[issueID].comments, issueCommentResponse], - }, - }; + set(this.details, [issueID, "comments"], [...(issueDetails?.comments ?? []), issueCommentResponse]); }); } return issueCommentResponse; @@ -267,21 +299,17 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: [ - ...this.details[issueID].reactions, - { - id: uuidv4(), - issue: issueID, - reaction: reactionHex, - actor_detail: this.rootStore.user.currentActor, - }, - ], - }, - }; + set( + this.details, + [issueID, "reaction_items"], + [ + ...this.details[issueID].reaction_items, + { + reaction: reactionHex, + actor_details: this.rootStore.user.currentActor, + }, + ] + ); }); await this.issueService.createIssueReaction(anchor, issueID, { @@ -291,31 +319,19 @@ export class IssueDetailStore implements IIssueDetailStore { console.log("Failed to add issue vote"); const issueReactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: issueReactions, - }, - }; + set(this.details, [issueID, "reaction_items"], issueReactions); }); } }; removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { - const newReactions = this.details[issueID].reactions.filter( - (_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.data?.id) + const newReactions = this.details[issueID].reaction_items.filter( + (_r) => !(_r.reaction === reactionHex && _r.actor_details.id === this.rootStore.user.data?.id) ); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: newReactions, - }, - }; + set(this.details, [issueID, "reaction_items"], newReactions); }); await this.issueService.deleteIssueReaction(anchor, issueID, reactionHex); @@ -323,13 +339,7 @@ export class IssueDetailStore implements IIssueDetailStore { console.log("Failed to remove issue reaction"); const reactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: reactions, - }, - }; + set(this.details, [issueID, "reaction_items"], reactions); }); } }; @@ -341,25 +351,19 @@ export class IssueDetailStore implements IIssueDetailStore { 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, - workspace: workspaceSlug, + actor_details: this.rootStore.user.currentActor, vote: data.vote, }; - const filteredVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + const filteredVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); try { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: [...filteredVotes, newVote], - }, - }; + runInAction(() => { + set(this.details, [issueID, "vote_items"], [...filteredVotes, newVote]); + }); }); await this.issueService.createIssueVote(anchor, issueID, data); @@ -368,29 +372,19 @@ export class IssueDetailStore implements IIssueDetailStore { const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: issueVotes, - }, - }; + set(this.details, [issueID, "vote_items"], issueVotes); }); } }; removeIssueVote = async (anchor: string, issueID: string) => { - const newVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + const newVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); try { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: newVotes, - }, - }; + set(this.details, [issueID, "vote_items"], newVotes); }); await this.issueService.deleteIssueVote(anchor, issueID); @@ -399,13 +393,7 @@ export class IssueDetailStore implements IIssueDetailStore { const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: issueVotes, - }, - }; + set(this.details, [issueID, "vote_items"], issueVotes); }); } }; diff --git a/space/core/store/issue-filters.store.ts b/space/core/store/issue-filters.store.ts index 9e236167136..0c589dc4d3b 100644 --- a/space/core/store/issue-filters.store.ts +++ b/space/core/store/issue-filters.store.ts @@ -3,6 +3,8 @@ import isEqual from "lodash/isEqual"; import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// plane types +import { IssuePaginationOptions, TIssueParams } from "@plane/types"; // constants import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // store @@ -15,6 +17,7 @@ import { TIssueQueryFiltersParams, TIssueFilterKeys, } from "@/types/issue"; +import { getPaginationParams } from "./helpers/filter.helpers"; export interface IIssueFilterStore { // observables @@ -27,13 +30,20 @@ export interface IIssueFilterStore { getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined; // actions updateLayoutOptions: (layout: TIssueLayoutOptions) => void; - initIssueFilters: (anchor: string, filters: TIssueFilters) => void; + initIssueFilters: (anchor: string, filters: TIssueFilters, shouldFetchIssues?: boolean) => void; updateIssueFilters: ( anchor: string, filterKind: K, filterKey: keyof TIssueFilters[K], filters: TIssueFilters[K][typeof filterKey] ) => Promise; + getFilterParams: ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; } export class IssueFilterStore implements IIssueFilterStore { @@ -114,14 +124,27 @@ export class IssueFilterStore implements IIssueFilterStore { // actions updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options); - initIssueFilters = async (anchor: string, initFilters: TIssueFilters) => { + initIssueFilters = async (anchor: string, initFilters: TIssueFilters, shouldFetchIssues: boolean = false) => { if (this.filters === undefined) runInAction(() => (this.filters = {})); if (this.filters && initFilters) set(this.filters, [anchor], initFilters); - const appliedFilters = this.getAppliedFilters(anchor); - await this.store.issue.fetchPublicIssues(anchor, appliedFilters); + if (shouldFetchIssues) await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); }; + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.getAppliedFilters(anchor); + const paginationParams = getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + updateIssueFilters = async ( anchor: string, filterKind: K, @@ -135,7 +158,6 @@ export class IssueFilterStore implements IIssueFilterStore { if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue); }); - const appliedFilters = this.getAppliedFilters(anchor); - await this.store.issue.fetchPublicIssues(anchor, appliedFilters); + if (filterKey !== "layout") await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); }; } diff --git a/space/core/store/issue.store.ts b/space/core/store/issue.store.ts index 80f5f26bdf0..ca5154df7a3 100644 --- a/space/core/store/issue.store.ts +++ b/space/core/store/issue.store.ts @@ -1,62 +1,38 @@ -import { observable, action, makeObservable, runInAction } from "mobx"; -import { computedFn } from "mobx-utils"; +import { action, makeObservable, runInAction } from "mobx"; // types -import { IStateLite } from "@plane/types"; +import { IssuePaginationOptions, TLoader } from "@plane/types"; // services import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types -import { IIssue, IIssueLabel } from "@/types/issue"; +import { BaseIssuesStore, IBaseIssuesStore } from "./helpers/base-issues.store"; -export interface IIssueStore { - loader: boolean; - error: any; - // observables - issues: IIssue[]; - states: IStateLite[]; - labels: IIssueLabel[]; - // filter observables - filteredStates: string[]; - filteredLabels: string[]; - filteredPriorities: string[]; +export interface IIssueStore extends IBaseIssuesStore { // actions - fetchPublicIssues: (anchor: string, params: any) => Promise; - // helpers - getCountOfIssuesByState: (stateID: string) => number; - getFilteredIssuesByState: (stateID: string) => IIssue[]; + fetchPublicIssues: ( + anchor: string, + loadType: TLoader, + options: IssuePaginationOptions, + isExistingPaginationOptions?: boolean + ) => Promise; + fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise; + fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise; } -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[] = []; +export class IssueStore extends BaseIssuesStore implements IIssueStore { // root store rootStore: CoreRootStore; // services issueService: IssueService; constructor(_rootStore: CoreRootStore) { + super(_rootStore); 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, + fetchNextPublicIssues: action, + fetchPublicIssuesWithExistingPagination: action, }); this.rootStore = _rootStore; @@ -68,45 +44,69 @@ export class IssueStore implements IIssueStore { * @param {string} anchor * @param params */ - fetchPublicIssues = async (anchor: string, params: any) => { + fetchPublicIssues = async ( + anchor: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions, + isExistingPaginationOptions: boolean = false + ) => { try { + // set loader and clear store runInAction(() => { - this.loader = true; - this.error = null; + this.setLoader(loadType); }); + this.clear(!isExistingPaginationOptions); + + const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined); 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; - }); - } + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options); } catch (error) { - this.loader = false; - this.error = error; + this.setLoader(undefined); 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 - ); + fetchNextPublicIssues = async (anchor: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; + try { + // set Loader + this.setLoader("pagination", groupId, subGroupId); + + // get params from stored pagination options + const params = this.rootStore.issueFilter.getFilterParams( + this.paginationOptions, + anchor, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueService.fetchPublicIssues(anchor, params); + + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); + throw error; + } + }; /** - * @description get array of issues under a particular state - * @param {string} stateID - * @returns {IIssue[]} + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns */ - getFilteredIssuesByState = computedFn( - (stateID: string) => this.issues?.filter((issue) => issue.state == stateID) || [] - ); + fetchPublicIssuesWithExistingPagination = async (anchor: string, loadType: TLoader = "mutation") => { + if (!this.paginationOptions) return; + return await this.fetchPublicIssues(anchor, loadType, this.paginationOptions, true); + }; } diff --git a/space/core/store/label.store.ts b/space/core/store/label.store.ts new file mode 100644 index 00000000000..e705aa4d2a4 --- /dev/null +++ b/space/core/store/label.store.ts @@ -0,0 +1,63 @@ +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { IIssueLabel } from "@plane/types"; +import { LabelService } from "@/services/label.service"; +import { CoreRootStore } from "./root.store"; + +export interface IIssueLabelStore { + // observables + labels: IIssueLabel[] | undefined; + // computed actions + getLabelById: (labelId: string | undefined) => IIssueLabel | undefined; + getLabelsByIds: (labelIds: string[]) => IIssueLabel[]; + // fetch actions + fetchLabels: (anchor: string) => Promise; +} + +export class LabelStore implements IIssueLabelStore { + labelMap: Record = {}; + labelService: LabelService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + labelMap: observable, + // computed + labels: computed, + // fetch action + fetchLabels: action, + }); + this.labelService = new LabelService(); + this.rootStore = _rootStore; + } + + get labels() { + return Object.values(this.labelMap); + } + + getLabelById = (labelId: string | undefined) => (labelId ? this.labelMap[labelId] : undefined); + + getLabelsByIds = (labelIds: string[]) => { + const currLabels = []; + for (const labelId of labelIds) { + const label = this.getLabelById(labelId); + if (label) { + currLabels.push(label); + } + } + + return currLabels; + }; + + fetchLabels = async (anchor: string) => { + const labelsResponse = await this.labelService.getLabels(anchor); + runInAction(() => { + this.labelMap = {}; + for (const label of labelsResponse) { + set(this.labelMap, [label.id], label); + } + }); + return labelsResponse; + }; +} diff --git a/space/core/store/members.store.ts b/space/core/store/members.store.ts new file mode 100644 index 00000000000..3de021e2c7e --- /dev/null +++ b/space/core/store/members.store.ts @@ -0,0 +1,68 @@ +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { TPublicMember } from "@/types/member"; +import { MemberService } from "../services/member.service"; +import { CoreRootStore } from "./root.store"; + +export interface IIssueMemberStore { + // observables + members: TPublicMember[] | undefined; + // computed actions + getMemberById: (memberId: string | undefined) => TPublicMember | undefined; + getMembersByIds: (memberIds: string[]) => TPublicMember[]; + // fetch actions + fetchMembers: (anchor: string) => Promise; +} + +export class MemberStore implements IIssueMemberStore { + memberMap: Record = {}; + memberService: MemberService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + memberMap: observable, + // computed + members: computed, + // fetch action + fetchMembers: action, + }); + this.memberService = new MemberService(); + this.rootStore = _rootStore; + } + + get members() { + return Object.values(this.memberMap); + } + + getMemberById = (memberId: string | undefined) => (memberId ? this.memberMap[memberId] : undefined); + + getMembersByIds = (memberIds: string[]) => { + const currMembers = []; + for (const memberId of memberIds) { + const member = this.getMemberById(memberId); + if (member) { + currMembers.push(member); + } + } + + return currMembers; + }; + + fetchMembers = async (anchor: string) => { + try { + const membersResponse = await this.memberService.getAnchorMembers(anchor); + runInAction(() => { + this.memberMap = {}; + for (const member of membersResponse) { + set(this.memberMap, [member.member], member); + } + }); + return membersResponse; + } catch (error) { + console.error("Failed to fetch members:", error); + return []; + } + }; +} diff --git a/space/core/store/module.store.ts b/space/core/store/module.store.ts new file mode 100644 index 00000000000..6da1ab1f80e --- /dev/null +++ b/space/core/store/module.store.ts @@ -0,0 +1,68 @@ +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { TPublicModule } from "@/types/modules"; +import { ModuleService } from "../services/module.service"; +import { CoreRootStore } from "./root.store"; + +export interface IIssueModuleStore { + // observables + modules: TPublicModule[] | undefined; + // computed actions + getModuleById: (moduleId: string | undefined) => TPublicModule | undefined; + getModulesByIds: (moduleIds: string[]) => TPublicModule[]; + // fetch actions + fetchModules: (anchor: string) => Promise; +} + +export class ModuleStore implements IIssueModuleStore { + moduleMap: Record = {}; + moduleService: ModuleService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + moduleMap: observable, + // computed + modules: computed, + // fetch action + fetchModules: action, + }); + this.moduleService = new ModuleService(); + this.rootStore = _rootStore; + } + + get modules() { + return Object.values(this.moduleMap); + } + + getModuleById = (moduleId: string | undefined) => (moduleId ? this.moduleMap[moduleId] : undefined); + + getModulesByIds = (moduleIds: string[]) => { + const currModules = []; + for (const moduleId of moduleIds) { + const issueModule = this.getModuleById(moduleId); + if (issueModule) { + currModules.push(issueModule); + } + } + + return currModules; + }; + + fetchModules = async (anchor: string) => { + try { + const modulesResponse = await this.moduleService.getModules(anchor); + runInAction(() => { + this.moduleMap = {}; + for (const issueModule of modulesResponse) { + set(this.moduleMap, [issueModule.id], issueModule); + } + }); + return modulesResponse; + } catch (error) { + console.error("Failed to fetch members:", error); + return []; + } + }; +} diff --git a/space/core/store/root.store.ts b/space/core/store/root.store.ts index acd8c3b595e..de43001d2c9 100644 --- a/space/core/store/root.store.ts +++ b/space/core/store/root.store.ts @@ -4,9 +4,14 @@ import { IInstanceStore, InstanceStore } from "@/store/instance.store"; import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store"; import { IssueStore, IIssueStore } from "@/store/issue.store"; import { IUserStore, UserStore } from "@/store/user.store"; +import { CycleStore, ICycleStore } from "./cycle.store"; import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; +import { IIssueLabelStore, LabelStore } from "./label.store"; +import { IIssueMemberStore, MemberStore } from "./members.store"; import { IMentionsStore, MentionsStore } from "./mentions.store"; +import { IIssueModuleStore, ModuleStore } from "./module.store"; import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; +import { IStateStore, StateStore } from "./state.store"; enableStaticRendering(typeof window === "undefined"); @@ -16,6 +21,11 @@ export class CoreRootStore { issue: IIssueStore; issueDetail: IIssueDetailStore; mentionStore: IMentionsStore; + state: IStateStore; + label: IIssueLabelStore; + module: IIssueModuleStore; + member: IIssueMemberStore; + cycle: ICycleStore; issueFilter: IIssueFilterStore; publishList: IPublishListStore; @@ -25,6 +35,11 @@ export class CoreRootStore { this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); + this.module = new ModuleStore(this); + this.member = new MemberStore(this); + this.cycle = new CycleStore(this); this.issueFilter = new IssueFilterStore(this); this.publishList = new PublishListStore(this); } @@ -43,6 +58,11 @@ export class CoreRootStore { this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); + this.module = new ModuleStore(this); + this.member = new MemberStore(this); + this.cycle = new CycleStore(this); this.issueFilter = new IssueFilterStore(this); this.publishList = new PublishListStore(this); } diff --git a/space/core/store/state.store.ts b/space/core/store/state.store.ts new file mode 100644 index 00000000000..aff22a22a88 --- /dev/null +++ b/space/core/store/state.store.ts @@ -0,0 +1,51 @@ +import clone from "lodash/clone"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { IState } from "@plane/types"; +import { sortStates } from "@/helpers/state.helper"; +import { StateService } from "@/services/state.service"; +import { CoreRootStore } from "./root.store"; + +export interface IStateStore { + // observables + states: IState[] | undefined; + //computed + sortedStates: IState[] | undefined; + // computed actions + getStateById: (stateId: string | undefined) => IState | undefined; + // fetch actions + fetchStates: (anchor: string) => Promise; +} + +export class StateStore implements IStateStore { + states: IState[] | undefined = undefined; + stateService: StateService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + states: observable, + // computed + sortedStates: computed, + // fetch action + fetchStates: action, + }); + this.stateService = new StateService(); + this.rootStore = _rootStore; + } + + get sortedStates() { + if (!this.states) return; + return sortStates(clone(this.states)); + } + + getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId); + + fetchStates = async (anchor: string) => { + const statesResponse = await this.stateService.getStates(anchor); + runInAction(() => { + this.states = statesResponse; + }); + return statesResponse; + }; +} diff --git a/space/core/types/cycle.d.ts b/space/core/types/cycle.d.ts new file mode 100644 index 00000000000..edf8f31a8a1 --- /dev/null +++ b/space/core/types/cycle.d.ts @@ -0,0 +1,5 @@ +export type TPublicCycle = { + id: string; + name: string; + status: string; +}; diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts index b9676810efb..79c6257d5af 100644 --- a/space/core/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -1,4 +1,4 @@ -import { IStateLite, IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types"; +import { IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types"; export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; export type TIssueLayoutOptions = { @@ -33,31 +33,67 @@ export type TIssueQueryFilters = Partial; export type TIssueQueryFiltersParams = Partial>; -export type TIssuesResponse = { - states: IStateLite[]; - labels: IIssueLabel[]; - issues: IIssue[]; -}; - export interface IIssue - extends Pick { + extends Pick< + TIssue, + | "description_html" + | "created_at" + | "updated_at" + | "created_by" + | "id" + | "name" + | "priority" + | "state_id" + | "project_id" + | "sequence_id" + | "sort_order" + | "start_date" + | "target_date" + | "cycle_id" + | "module_ids" + | "label_ids" + | "assignee_ids" + | "attachment_count" + | "sub_issues_count" + | "link_count" + | "estimate_point" + > { comments: Comment[]; - label_details: any; - project: string; - project_detail: any; - reactions: IIssueReaction[]; - state: string; - state_detail: { - id: string; - name: string; - group: TIssueGroupKey; - color: string; - }; - votes: IVote[]; + reaction_items: IIssueReaction[]; + vote_items: IVote[]; } export type IPeekMode = "side" | "modal" | "full"; +type TIssueResponseResults = + | IIssue[] + | { + [key: string]: { + results: + | IIssue[] + | { + [key: string]: { + results: IIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +}; + export interface IIssueLabel { id: string; name: string; @@ -66,12 +102,8 @@ export interface IIssueLabel { } export interface IVote { - issue: string; vote: -1 | 1; - workspace: string; - project: string; - actor: string; - actor_detail: ActorDetail; + actor_details: ActorDetail; } export interface Comment { @@ -102,9 +134,7 @@ export interface Comment { } export interface IIssueReaction { - actor_detail: ActorDetail; - id: string; - issue: string; + actor_details: ActorDetail; reaction: string; } @@ -112,8 +142,8 @@ export interface ActorDetail { avatar?: string; display_name?: string; first_name?: string; - id?: string; is_bot?: boolean; + id?: string; last_name?: string; } diff --git a/space/core/types/member.d.ts b/space/core/types/member.d.ts new file mode 100644 index 00000000000..721ccd98fc5 --- /dev/null +++ b/space/core/types/member.d.ts @@ -0,0 +1,10 @@ +export type TPublicMember = { + id: string; + member: string; + member__avatar: string; + member__first_name: string; + member__last_name: string; + member__display_name: string; + project: string; + workspace: string; +}; diff --git a/space/core/types/modules.d.ts b/space/core/types/modules.d.ts new file mode 100644 index 00000000000..8bc35ce6ff8 --- /dev/null +++ b/space/core/types/modules.d.ts @@ -0,0 +1,4 @@ +export type TPublicModule = { + id: string; + name: string; +}; diff --git a/space/ee/components/issue-layouts/root.tsx b/space/ee/components/issue-layouts/root.tsx new file mode 100644 index 00000000000..d785c5c11c2 --- /dev/null +++ b/space/ee/components/issue-layouts/root.tsx @@ -0,0 +1 @@ +export * from "ce/components/issue-layouts/root"; diff --git a/space/ee/components/navbar/index.tsx b/space/ee/components/navbar/index.tsx new file mode 100644 index 00000000000..960fa250745 --- /dev/null +++ b/space/ee/components/navbar/index.tsx @@ -0,0 +1 @@ +export * from "ce/components/navbar"; diff --git a/space/ee/hooks/store/index.ts b/space/ee/hooks/store/index.ts new file mode 100644 index 00000000000..6ce80b4fb5a --- /dev/null +++ b/space/ee/hooks/store/index.ts @@ -0,0 +1 @@ +export * from "ce/hooks/store"; diff --git a/space/helpers/emoji.helper.tsx b/space/helpers/emoji.helper.tsx index d5f9d1b5a5b..1619d6c0d1b 100644 --- a/space/helpers/emoji.helper.tsx +++ b/space/helpers/emoji.helper.tsx @@ -17,19 +17,16 @@ export const renderEmoji = ( else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); }; -export const groupReactions: (reactions: any[], key: string) => { [key: string]: any[] } = ( - reactions: any, - key: string -) => { +export const groupReactions = (reactions: T[], key: string) => { const groupedReactions = reactions.reduce( - (acc: any, reaction: any) => { + (acc: { [key: string]: T[] }, reaction: any) => { if (!acc[reaction[key]]) { acc[reaction[key]] = []; } acc[reaction[key]].push(reaction); return acc; }, - {} as { [key: string]: any[] } + {} as { [key: string]: T[] } ); return groupedReactions; diff --git a/space/helpers/state.helper.ts b/space/helpers/state.helper.ts new file mode 100644 index 00000000000..81bffdef960 --- /dev/null +++ b/space/helpers/state.helper.ts @@ -0,0 +1,13 @@ +import { IState } from "@plane/types"; +import { STATE_GROUPS } from "@/constants/state"; + +export const sortStates = (states: IState[]) => { + if (!states || states.length === 0) return; + + return states.sort((stateA, stateB) => { + if (stateA.group === stateB.group) { + return stateA.sequence - stateB.sequence; + } + return Object.keys(STATE_GROUPS).indexOf(stateA.group) - Object.keys(STATE_GROUPS).indexOf(stateB.group); + }); +}; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index f6319bc7507..5c704c44c36 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -50,9 +50,31 @@ export const checkEmailValidity = (email: string): boolean => { return isEmailValid; }; -export const isEmptyHtmlString = (htmlString: string) => { +export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => { // Remove HTML tags using regex - const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: ["img"] }); + const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags }); // Trim the string and check if it's empty return cleanText.trim() === ""; }; + +/** + * @description this function returns whether a comment is empty or not by checking for the following conditions- + * 1. If comment is undefined + * 2. If comment is an empty string + * 3. If comment is "

    " + * @param {string | undefined} comment + * @returns {boolean} + */ +export const isCommentEmpty = (comment: string | undefined): boolean => { + // return true if comment is undefined + if (!comment) return true; + return ( + comment?.trim() === "" || + comment === "

    " || + isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"]) + ); +}; + +export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); + +export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); diff --git a/space/package.json b/space/package.json index c59f35589bc..99422406c02 100644 --- a/space/package.json +++ b/space/package.json @@ -1,13 +1,14 @@ { "name": "space", - "version": "0.22.0", + "version": "0.23.1", "private": true, "scripts": { "dev": "turbo run develop", "develop": "next dev -p 3002", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint . --ext .ts,.tsx", + "lint:errors": "eslint . --ext .ts,.tsx --quiet", "export": "next export" }, "dependencies": { @@ -17,11 +18,12 @@ "@emotion/styled": "^11.11.0", "@headlessui/react": "^1.7.13", "@mui/material": "^5.14.1", + "@plane/constants": "*", "@plane/editor": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^8", - "axios": "^1.3.4", + "@sentry/nextjs": "^8.32.0", + "axios": "^1.7.4", "clsx": "^2.0.0", "date-fns": "^3.6.0", "dompurify": "^3.0.11", @@ -33,21 +35,22 @@ "mobx": "^6.10.0", "mobx-react": "^9.1.1", "mobx-utils": "^6.0.8", - "next": "^14.2.3", + "next": "^14.2.12", "next-themes": "^0.2.1", "nprogress": "^0.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", - "react-hook-form": "^7.38.0", + "react-hook-form": "7.51.5", "react-popper": "^2.3.0", "swr": "^2.2.2", "tailwind-merge": "^2.0.0", - "typescript": "4.9.5", "uuid": "^9.0.0", "zxcvbn": "^4.4.2" }, "devDependencies": { + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", "@types/dompurify": "^3.0.5", "@types/js-cookie": "^3.0.3", "@types/lodash": "^4.17.1", @@ -58,8 +61,7 @@ "@types/uuid": "^9.0.1", "@types/zxcvbn": "^4.4.4", "@typescript-eslint/eslint-plugin": "^5.48.2", - "eslint-config-custom": "*", "tailwind-config-custom": "*", - "tsconfig": "*" + "typescript": "5.3.3" } } diff --git a/space/public/onboarding/onboarding-pages.svg b/space/public/onboarding/onboarding-pages.svg deleted file mode 100644 index 5ed5d44c2d2..00000000000 --- a/space/public/onboarding/onboarding-pages.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/space/public/onboarding/profile-setup-dark.svg b/space/public/onboarding/profile-setup-dark.svg deleted file mode 100644 index 69cceb716eb..00000000000 --- a/space/public/onboarding/profile-setup-dark.svg +++ /dev/null @@ -1,448 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/space/public/onboarding/profile-setup-light.svg b/space/public/onboarding/profile-setup-light.svg deleted file mode 100644 index 1a290780dc0..00000000000 --- a/space/public/onboarding/profile-setup-light.svg +++ /dev/null @@ -1,407 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/space/public/onboarding/profile-setup.svg b/space/public/onboarding/profile-setup.svg deleted file mode 100644 index 3364031fb1b..00000000000 --- a/space/public/onboarding/profile-setup.svg +++ /dev/null @@ -1,222 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/space/styles/globals.css b/space/styles/globals.css index 0b41d84811b..511f6ad1fdf 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -354,3 +354,90 @@ body { .disable-autofill-style:-webkit-autofill:active { -webkit-background-clip: text; } + + +@-moz-document url-prefix() { + * { + scrollbar-width: none; + } + .vertical-scrollbar, + .horizontal-scrollbar { + scrollbar-width: initial; + scrollbar-color: rgba(96, 100, 108, 0.1) transparent; + } + .vertical-scrollbar:hover, + .horizontal-scrollbar:hover { + scrollbar-color: rgba(96, 100, 108, 0.25) transparent; + } + .vertical-scrollbar:active, + .horizontal-scrollbar:active { + scrollbar-color: rgba(96, 100, 108, 0.7) transparent; + } +} + +.vertical-scrollbar { + overflow-y: auto; +} +.horizontal-scrollbar { + overflow-x: auto; +} +.vertical-scrollbar::-webkit-scrollbar, +.horizontal-scrollbar::-webkit-scrollbar { + display: block; +} +.vertical-scrollbar::-webkit-scrollbar-track, +.horizontal-scrollbar::-webkit-scrollbar-track { + background-color: transparent; + border-radius: 9999px; +} +.vertical-scrollbar::-webkit-scrollbar-thumb, +.horizontal-scrollbar::-webkit-scrollbar-thumb { + background-clip: padding-box; + background-color: rgba(96, 100, 108, 0.1); + border-radius: 9999px; +} +.vertical-scrollbar:hover::-webkit-scrollbar-thumb, +.horizontal-scrollbar:hover::-webkit-scrollbar-thumb { + background-color: rgba(96, 100, 108, 0.25); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:hover, +.horizontal-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(96, 100, 108, 0.5); +} +.vertical-scrollbar::-webkit-scrollbar-thumb:active, +.horizontal-scrollbar::-webkit-scrollbar-thumb:active { + background-color: rgba(96, 100, 108, 0.7); +} +.vertical-scrollbar::-webkit-scrollbar-corner, +.horizontal-scrollbar::-webkit-scrollbar-corner { + background-color: transparent; +} +.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { + margin-top: 44px; +} + +/* scrollbar sm size */ +.scrollbar-sm::-webkit-scrollbar { + height: 12px; + width: 12px; +} +.scrollbar-sm::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar md size */ +.scrollbar-md::-webkit-scrollbar { + height: 14px; + width: 14px; +} +.scrollbar-md::-webkit-scrollbar-thumb { + border: 3px solid rgba(0, 0, 0, 0); +} +/* scrollbar lg size */ + +.scrollbar-lg::-webkit-scrollbar { + height: 16px; + width: 16px; +} +.scrollbar-lg::-webkit-scrollbar-thumb { + border: 4px solid rgba(0, 0, 0, 0); +} diff --git a/space/tsconfig.json b/space/tsconfig.json index 849d224efc0..978f65219ec 100644 --- a/space/tsconfig.json +++ b/space/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "tsconfig/nextjs.json", + "extends": "@plane/typescript-config/nextjs.json", "plugins": [ { "name": "next" diff --git a/turbo.json b/turbo.json index 0325c3b2b4c..edfcd6771c9 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,7 @@ "NEXT_PUBLIC_SPACE_BASE_URL", "NEXT_PUBLIC_SPACE_BASE_PATH", "NEXT_PUBLIC_WEB_BASE_URL", + "NEXT_PUBLIC_LIVE_BASE_URL", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", "NEXT_PUBLIC_ENABLE_SESSION_RECORDER", @@ -26,32 +27,21 @@ ], "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/.env.example b/web/.env.example index 8e5b0f48295..ad5ac4173d3 100644 --- a/web/.env.example +++ b/web/.env.example @@ -5,3 +5,6 @@ NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" NEXT_PUBLIC_SPACE_BASE_URL="" NEXT_PUBLIC_SPACE_BASE_PATH="/spaces" + +NEXT_PUBLIC_LIVE_BASE_URL="" +NEXT_PUBLIC_LIVE_BASE_PATH="/live" \ No newline at end of file diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 57d39bcfad1..afa7562fd7f 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,52 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ module.exports = { root: true, - extends: ["custom"], + extends: ["@plane/eslint-config/next.js"], parser: "@typescript-eslint/parser", - settings: { - "import/resolver": { - typescript: {}, - node: { - moduleDirectory: ["node_modules", "."], - }, - }, - }, - 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: "@headlessui/**", - group: "external", - position: "after", - }, - { - pattern: "@plane/**", - group: "external", - position: "after", - }, - { - pattern: "@/**", - group: "internal", - } - ], - pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], - alphabetize: { - order: "asc", - caseInsensitive: true, - }, - }, - ], + parserOptions: { + project: true, }, }; diff --git a/web/Dockerfile.web b/web/Dockerfile.web index eceb6c0790d..d7d924d7a47 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -39,6 +39,12 @@ ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH +ARG NEXT_PUBLIC_LIVE_BASE_URL="" +ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL + +ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live" +ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH + ARG NEXT_PUBLIC_SPACE_BASE_URL="" ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL @@ -77,6 +83,12 @@ ENV NEXT_PUBLIC_ADMIN_BASE_URL=$NEXT_PUBLIC_ADMIN_BASE_URL ARG NEXT_PUBLIC_ADMIN_BASE_PATH="/god-mode" ENV NEXT_PUBLIC_ADMIN_BASE_PATH=$NEXT_PUBLIC_ADMIN_BASE_PATH +ARG NEXT_PUBLIC_LIVE_BASE_URL="" +ENV NEXT_PUBLIC_LIVE_BASE_URL=$NEXT_PUBLIC_LIVE_BASE_URL + +ARG NEXT_PUBLIC_LIVE_BASE_PATH="/live" +ENV NEXT_PUBLIC_LIVE_BASE_PATH=$NEXT_PUBLIC_LIVE_BASE_PATH + ARG NEXT_PUBLIC_SPACE_BASE_URL="" ENV NEXT_PUBLIC_SPACE_BASE_URL=$NEXT_PUBLIC_SPACE_BASE_URL diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx index f6565f415d9..72dae40b1e0 100644 --- a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -2,28 +2,27 @@ import { observer } from "mobx-react"; // ui -import { Crown } from "lucide-react"; -import { Breadcrumbs, ContrastIcon } from "@plane/ui"; +import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui"; +// components import { BreadcrumbLink } from "@/components/common"; -// icons +// plane web components +import { UpgradeBadge } from "@/plane-web/components/workspace"; export const WorkspaceActiveCycleHeader = observer(() => ( -
    -
    -
    - - } - /> - } - /> - - -
    -
    -
    +
    + + + } + /> + } + /> + + + +
    )); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx index dc503dd6daa..4aa66e2a433 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/header.tsx @@ -6,7 +6,7 @@ import { useSearchParams } from "next/navigation"; // icons import { BarChart2, PanelRight } from "lucide-react"; // ui -import { Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; // helpers @@ -36,38 +36,32 @@ export const WorkspaceAnalyticsHeader = observer(() => { }, [toggleWorkspaceAnalyticsSidebar, workspaceAnalyticsSidebarCollapsed]); return ( - <> -
    -
    -
    - - } /> - } - /> - - {analytics_tab === "custom" && ( - - )} -
    -
    -
    - +
    + + + } />} + /> + + {analytics_tab === "custom" ? ( + + ) : ( + <> + )} + +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx index 240993a24dc..b66c0d19ee7 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { Tab } from "@headlessui/react"; // components +import { Header, EHeaderVariant } from "@plane/ui"; import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; @@ -34,24 +35,26 @@ const AnalyticsPage = observer(() => { {workspaceProjectIds.length > 0 || loader ? (
    - - {ANALYTICS_TABS.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - +
    + + {ANALYTICS_TABS.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + +
    diff --git a/web/app/[workspaceSlug]/(projects)/header.tsx b/web/app/[workspaceSlug]/(projects)/header.tsx index 9e55858b68b..642f9a0e9ff 100644 --- a/web/app/[workspaceSlug]/(projects)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/header.tsx @@ -2,16 +2,16 @@ import Image from "next/image"; import { useTheme } from "next-themes"; -import { Home, Zap } from "lucide-react"; +import { Home } from "lucide-react"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; // ui -import { Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; // constants -import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "@/constants/event-tracker"; +import { GITHUB_REDIRECTED } from "@/constants/event-tracker"; // hooks import { useEventTracker } from "@/hooks/store"; @@ -22,8 +22,8 @@ export const WorkspaceDashboardHeader = () => { return ( <> - + + ); }; diff --git a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx index 49303d3614c..b8b80b4d279 100644 --- a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx @@ -6,9 +6,7 @@ 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 index 0e29a84d1bc..1c2036efd31 100644 --- a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/notifications/page.tsx @@ -1,27 +1,42 @@ "use client"; +import { useEffect } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // components import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; import { InboxContentRoot } from "@/components/inbox"; import { IssuePeekOverview } from "@/components/issues"; // constants +import { EmptyStateType } from "@/constants/empty-state"; import { ENotificationLoader, ENotificationQueryParamType } from "@/constants/notification"; // hooks -import { useUser, useWorkspace, useWorkspaceNotifications } from "@/hooks/store"; +import { useIssueDetail, useUserPermissions, useWorkspace, useWorkspaceNotifications } from "@/hooks/store"; +import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; const WorkspaceDashboardPage = observer(() => { + const { workspaceSlug } = useParams(); // hooks const { currentWorkspace } = useWorkspace(); - const { currentSelectedNotification, notificationIdsByWorkspaceId, getNotifications } = useWorkspaceNotifications(); const { - membership: { fetchUserProjectInfo }, - } = useUser(); + currentSelectedNotificationId, + setCurrentSelectedNotificationId, + notificationLiteByNotificationId, + notificationIdsByWorkspaceId, + getNotifications, + } = useWorkspaceNotifications(); + const { fetchUserProjectInfo } = useUserPermissions(); + const { setPeekIssue } = useIssueDetail(); // derived values - const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Notifications` : undefined; - const { workspace_slug, project_id, issue_id, is_inbox_issue } = currentSelectedNotification; + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Inbox` : undefined; + const { workspace_slug, project_id, issue_id, is_inbox_issue } = + notificationLiteByNotificationId(currentSelectedNotificationId); + + // fetching workspace issue properties + useWorkspaceIssueProperties(workspaceSlug); // fetch workspace notifications const notificationMutation = @@ -47,29 +62,50 @@ const WorkspaceDashboardPage = observer(() => { workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null ); + // clearing up the selected notifications when unmounting the page + useEffect( + () => () => { + setCurrentSelectedNotificationId(undefined); + setPeekIssue(undefined); + }, + [setCurrentSelectedNotificationId, setPeekIssue] + ); + return ( <>
    - {is_inbox_issue === true && workspace_slug && project_id && issue_id ? ( + {!currentSelectedNotificationId ? ( +
    + +
    + ) : ( <> - {projectMemberInfoLoader ? ( -
    - -
    + {is_inbox_issue === true && workspace_slug && project_id && issue_id ? ( + <> + {projectMemberInfoLoader ? ( +
    + +
    + ) : ( + {}} + isMobileSidebar={false} + workspaceSlug={workspace_slug} + projectId={project_id} + inboxIssueId={issue_id} + isNotificationEmbed + embedRemoveCurrentNotification={() => setCurrentSelectedNotificationId(undefined)} + /> + )} + ) : ( - {}} - isMobileSidebar={false} - workspaceSlug={workspace_slug} - projectId={project_id} - inboxIssueId={issue_id} - isNotificationEmbed + setCurrentSelectedNotificationId(undefined)} /> )} - ) : ( - )}
    diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx index cae273fd4cd..bf1f88d15b4 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -2,16 +2,15 @@ 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 { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/profile"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks -import { useUser } from "@/hooks/store"; +import { useUserPermissions } from "@/hooks/store"; +// plane-web constants +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const PER_PAGE = 100; @@ -21,13 +20,7 @@ const ProfileActivityPage = observer(() => { const [totalPages, setTotalPages] = useState(0); const [resultsCount, setResultsCount] = useState(0); // router - - const { userId } = useParams(); - // store hooks - const { data: currentUser } = useUser(); - const { - membership: { currentWorkspaceRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); const updateTotalPages = (count: number) => setTotalPages(count); @@ -47,8 +40,10 @@ const ProfileActivityPage = observer(() => { /> ); - const canDownloadActivity = - currentUser?.id === userId && !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + const canDownloadActivity = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index f39ebfc44f0..13a944c8819 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -6,87 +6,106 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ChevronDown, PanelRight } from "lucide-react"; -import { Breadcrumbs, CustomMenu } from "@plane/ui"; +import { IUserProfileProjectSegregation } from "@plane/types"; +import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui"; import { BreadcrumbLink } from "@/components/common"; // components +import { ProfileIssuesFilter } from "@/components/profile"; import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile"; import { cn } from "@/helpers/common.helper"; -import { useAppTheme, useUser } from "@/hooks/store"; +import { useAppTheme, useUser, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; type TUserProfileHeader = { + userProjectsData: IUserProfileProjectSegregation | undefined; type?: string | undefined; + showProfileIssuesFilter?: boolean; }; export const UserProfileHeader: FC = observer((props) => { - const { type = undefined } = props; + const { userProjectsData, type = undefined, showProfileIssuesFilter } = props; // router const { workspaceSlug, userId } = useParams(); // store hooks const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); - const { - membership: { currentWorkspaceRole }, - } = useUser(); + const { data: currentUser } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values - const AUTHORIZED_ROLES = [20, 15, 10]; + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); - if (!currentWorkspaceRole) return null; + if (!workspaceUserInfo) return null; - const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole); const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + const userName = `${userProjectsData?.user_data?.first_name} ${userProjectsData?.user_data?.last_name}`; + + const isCurrentUser = currentUser?.id === userId; + + const breadcrumbLabel = `${isCurrentUser ? "Your" : userName} Work`; + return ( -
    -
    -
    - - } - /> - -
    - - {type} - -
    - } - customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" - closeOnSelect - > - <> - {tabsList.map((tab) => ( - - - {tab.label} - - - ))} - - -
    + } + /> + + + +
    {showProfileIssuesFilter && }
    +
    + + {type} + +
    + } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + <> + {tabsList.map((tab) => ( + + + {tab.label} + + + ))} + +
    -
    -
    + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx index b37fa1ec31e..f31ff959dce 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx @@ -2,36 +2,52 @@ import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; +import useSWR from "swr"; // components import { AppHeader, ContentWrapper } from "@/components/core"; import { ProfileSidebar } from "@/components/profile"; // constants +import { USER_PROFILE_PROJECT_SEGREGATION } from "@/constants/fetch-keys"; import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks -import { useUser } from "@/hooks/store"; +import { useUserPermissions } from "@/hooks/store"; +import useSize from "@/hooks/use-window-size"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // local components +import { UserService } from "@/services/user.service"; import { UserProfileHeader } from "./header"; import { ProfileIssuesMobileHeader } from "./mobile-header"; import { ProfileNavbar } from "./navbar"; +const userService = new UserService(); + 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(); + const { allowPermissions } = useUserPermissions(); + // derived values + const isAuthorized = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const windowSize = useSize(); + const isSmallerScreen = windowSize[0] >= 768; + + const { data: userProjectsData } = useSWR( + workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, + workspaceSlug && userId + ? () => userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString()) + : null + ); // 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"); @@ -43,25 +59,36 @@ const UseProfileLayout: React.FC = observer((props) => { <> {/* 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. +
    +
    + + } + mobileHeader={isIssuesTab && } + /> + +
    +
    + + {isAuthorized || !isAuthorizedPath ? ( +
    {children}
    + ) : ( +
    + You do not have the permission to access this page. +
    + )}
    - )} -
    - + {!isSmallerScreen && } +
    +
    - + {isSmallerScreen && } +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index f29e6ff284d..b963ca147c9 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -114,8 +114,13 @@ export const ProfileIssuesMobileHeader = observer(() => { maxHeight={"md"} className="flex flex-grow justify-center text-sm text-custom-text-200" placement="bottom-start" - customButton={Layout} - customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + customButton={ +
    + Layout + +
    + } + customButtonClassName="flex flex-center text-custom-text-200 text-sm" closeOnSelect > {ISSUE_LAYOUTS.map((layout, index) => { @@ -139,10 +144,10 @@ export const ProfileIssuesMobileHeader = observer(() => { title="Filters" placement="bottom-end" menuButton={ - +
    Filters - - + +
    } isFiltersApplied={isIssueFilterActive(issueFilters)} > @@ -165,10 +170,10 @@ export const ProfileIssuesMobileHeader = observer(() => { title="Display" placement="bottom-end" menuButton={ - +
    Display - - + +
    } > = (props) => { - const { isAuthorized, showProfileIssuesFilter } = props; + const { isAuthorized } = props; const { workspaceSlug, userId } = useParams(); -const pathname = usePathname(); + const pathname = usePathname(); const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; return ( -
    +
    {tabsList.map((tab) => ( ))}
    - {showProfileIssuesFilter && } -
    + ); }; diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx index 7d99317d723..480e30aed37 100644 --- a/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; // types import { IUserStateDistribution, TStateGroups } from "@plane/types"; // components +import { ContentWrapper } from "@plane/ui"; import { PageHead } from "@/components/core"; import { ProfileActivity, @@ -39,8 +40,8 @@ export default function ProfileOverviewPage() { return ( <> - -
    + +
    @@ -48,7 +49,7 @@ export default function ProfileOverviewPage() {
    -
    + ); } diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 5f6db17a8ac..6da6d77495b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -4,7 +4,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // ui -import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui"; +import { ArchiveIcon, Breadcrumbs, Tooltip, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; // constants @@ -16,8 +16,8 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; type TProps = { - activeTab: 'issues' | 'cycles' | 'modules'; -} + activeTab: "issues" | "cycles" | "modules"; +}; export const ProjectArchivesHeader: FC = observer((props: TProps) => { const { activeTab } = props; @@ -26,24 +26,20 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => { const { workspaceSlug, projectId } = useParams(); // store hooks const { - issuesFilter: { issueFilters }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.ARCHIVED); const { currentProjectDetails, loader } = useProject(); // hooks const { isMobile } = usePlatformOS(); - const issueCount = currentProjectDetails - ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.archived_sub_issues - ? currentProjectDetails.archived_issues - currentProjectDetails.archived_sub_issues - : currentProjectDetails.archived_issues - : undefined; + const issueCount = getGroupIssueCount(undefined, undefined, false); const activeTabBreadcrumbDetail = PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST]; return ( -
    -
    +
    +
    = observer((props: TProps) => { ) : null}
    -
    -
    + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx index 74dbb76491a..8573795b5a2 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx @@ -24,7 +24,7 @@ const ArchivedIssueDetailsPage = observer(() => { const { getProjectById } = useProject(); - const { isLoading, data: swrArchivedIssueDetails } = useSWR( + const { isLoading } = useSWR( workspaceSlug && projectId && archivedIssueId ? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}` : null, @@ -40,7 +40,7 @@ const ArchivedIssueDetailsPage = observer(() => { if (!issue) return <>; - const issueLoader = !issue || isLoading ? true : false; + const issueLoader = !issue || isLoading; return ( <> @@ -65,7 +65,6 @@ const ArchivedIssueDetailsPage = observer(() => {
    {workspaceSlug && projectId && archivedIssueId && ( { ); return ( -
    -
    -
    - - - - - ) - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - } - /> - -
    -
    - -
    +
    + + + + + + ) + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + /> + + + + + +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index 5563debeec2..a1f7071a449 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -2,11 +2,11 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import useSWR from "swr"; // components import { EmptyState } from "@/components/common"; import { PageHead } from "@/components/core"; import { CycleDetailsSidebar } from "@/components/cycles"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; import { CycleLayoutRoot } from "@/components/issues/issue-layouts"; // constants // import { EIssuesStoreType } from "@/constants/issue"; @@ -24,18 +24,17 @@ const CycleDetailPage = observer(() => { const router = useAppRouter(); const { workspaceSlug, projectId, cycleId } = useParams(); // store hooks - const { fetchCycleDetails, getCycleById } = useCycle(); + const { getCycleById, loader } = useCycle(); const { getProjectById } = useProject(); // const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); // hooks const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); - // fetching cycle details - const { error } = useSWR( - workspaceSlug && projectId && cycleId ? `CYCLE_DETAILS_${cycleId.toString()}` : null, - workspaceSlug && projectId && cycleId - ? () => fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString()) - : null - ); + + useCyclesDetails({ + workspaceSlug: workspaceSlug.toString(), + projectId: projectId.toString(), + cycleId: cycleId.toString(), + }); // derived values const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined; @@ -52,7 +51,7 @@ const CycleDetailPage = observer(() => { return ( <> - {error ? ( + {!cycle && !loader ? ( { {cycleId && !isSidebarCollapsed && (
    - +
    )}
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 2b7f541fb9e..902f15cde84 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -9,7 +9,7 @@ import { ArrowRight, PanelRight } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui -import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; +import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { BreadcrumbLink, Logo } from "@/components/common"; @@ -21,7 +21,6 @@ import { EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, } from "@/constants/issue"; -import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; import { isIssueFilterActive } from "@/helpers/filter.helper"; @@ -34,13 +33,14 @@ import { useMember, useProject, useProjectState, - useUser, useIssues, useCommandPalette, + useUserPermissions, } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { // router @@ -81,9 +81,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { const { currentProjectCycleIds, getCycleById } = useCycle(); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); const { currentProjectDetails, loader } = useProject(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); @@ -91,6 +88,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { project: { projectMemberIds }, } = useMember(); const { isMobile } = usePlatformOS(); + const { allowPermissions } = useUserPermissions(); const activeLayout = issueFilters?.displayFilters?.layout; @@ -149,8 +147,10 @@ export const CycleIssuesHeader: React.FC = observer(() => { // derived values const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const isCompletedCycle = cycleDetails?.status?.toLocaleLowerCase() === "completed"; - const canUserCreateIssue = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); const issuesCount = getGroupIssueCount(undefined, undefined, false); @@ -161,8 +161,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} /> -
    -
    +
    +
    { />
    +
    +
    { {!isCompletedCycle && ( )} @@ -315,8 +318,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { > -
    -
    + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx index f38fe94f29f..b2e157ee7b3 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx @@ -4,15 +4,15 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // ui -import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; +import { Breadcrumbs, Button, ContrastIcon, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { CyclesViewHeader } from "@/components/cycles"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks -import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; +import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +// constants +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const CyclesListHeader: FC = observer(() => { // router @@ -21,44 +21,42 @@ export const CyclesListHeader: FC = observer(() => { // store hooks const { toggleCreateCycleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); const { currentProjectDetails, loader } = useProject(); - const canUserCreateCycle = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const canUserCreateCycle = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); return ( -
    -
    -
    - - - - - ) - } - /> - } - /> - } />} - /> - -
    -
    - {canUserCreateCycle && currentProjectDetails && ( -
    +
    + + + + + + ) + } + /> + } + /> + } />} + /> + + + {canUserCreateCycle && currentProjectDetails ? ( + -
    + + ) : ( + <> )} -
    + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx index 5c046d95c12..5b1793d5d1f 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx @@ -6,6 +6,7 @@ import { useParams } from "next/navigation"; // types import { TCycleFilters } from "@plane/types"; // components +import { Header, EHeaderVariant } from "@plane/ui"; import { PageHead } from "@/components/core"; import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles"; import { EmptyState } from "@/components/empty-state"; @@ -81,13 +82,13 @@ const ProjectCyclesPage = observer(() => { ) : ( <> {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( -
    +
    clearAllFilters(projectId.toString())} handleRemoveFilter={handleRemoveFilter} /> -
    + )} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx index 6268823d0cc..924b830cff8 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx @@ -23,7 +23,7 @@ const ProjectDraftIssuesPage = observer(() => { <>
    -
    +
    -
    - )} -
    + +
    + ) : ( + <> + )} + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx index 0a2ecd17d4a..36aa37e302c 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/inbox/page.tsx @@ -36,7 +36,7 @@ const ProjectInboxPage = observer(() => { ); // derived values - const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox"; + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Intake` : "Plane - Intake"; const currentNavigationTab = navigationTab ? navigationTab === "open" diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx index 27b87509051..5b68ae688ed 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx @@ -32,11 +32,7 @@ const IssueDetailsPage = observer(() => { const { getProjectById } = useProject(); const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme(); // fetching issue details - const { - isLoading, - data: swrIssueDetails, - error, - } = useSWR( + const { isLoading, error } = useSWR( workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null, workspaceSlug && projectId && issueId ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString()) @@ -45,7 +41,7 @@ const IssueDetailsPage = observer(() => { // derived values const issue = getIssueById(issueId?.toString() || "") || undefined; const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined; - const issueLoader = !issue || isLoading ? true : false; + const issueLoader = !issue || isLoading; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; useEffect(() => { @@ -69,7 +65,7 @@ const IssueDetailsPage = observer(() => { router.push(`/${workspaceSlug}/projects/${projectId}/issues`), @@ -95,7 +91,6 @@ const IssueDetailsPage = observer(() => { projectId && issueId && ( { @@ -20,17 +17,15 @@ export const ProjectIssueDetailsHeader = observer(() => { const { workspaceSlug, projectId, issueId } = useParams(); // store hooks const { currentProjectDetails, loader } = useProject(); - const { issueDetailSidebarCollapsed, toggleIssueDetailSidebar } = useAppTheme(); const { issue: { getIssueById }, } = useIssueDetail(); // derived values const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined; - const isSidebarCollapsed = issueDetailSidebarCollapsed; return ( -
    -
    +
    +
    { />
    -
    - - -
    + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx index 9fccc44883f..e61b9fefdfd 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx @@ -1,250 +1,131 @@ "use client"; -import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons import { Briefcase, Circle, ExternalLink } from "lucide-react"; -// types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui -import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; +import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; -import { BreadcrumbLink, Logo } from "@/components/common"; -import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; +import { BreadcrumbLink, CountChip, Logo } from "@/components/common"; // constants -import { - EIssueFilterType, - EIssuesStoreType, - EIssueLayoutTypes, - ISSUE_DISPLAY_FILTERS_BY_LAYOUT, -} from "@/constants/issue"; -import { EUserProjectRoles } from "@/constants/project"; +import HeaderFilters from "@/components/issues/filters"; +import { EIssuesStoreType } from "@/constants/issue"; // helpers import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper"; -import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks -import { - useEventTracker, - useLabel, - useProject, - useProjectState, - useUser, - useMember, - useCommandPalette, -} from "@/hooks/store"; +import { useEventTracker, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; import { useIssues } from "@/hooks/store/use-issues"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ProjectIssuesHeader = observer(() => { - // states - const [analyticsModal, setAnalyticsModal] = useState(false); // router const router = useAppRouter(); const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; // store hooks const { - project: { projectMemberIds }, - } = useMember(); - const { - issuesFilter: { issueFilters, updateFilters }, issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.PROJECT); + + const { currentProjectDetails, loader } = useProject(); + const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); - const { currentProjectDetails, loader } = useProject(); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); + const { allowPermissions } = useUserPermissions(); const { isMobile } = usePlatformOS(); - const activeLayout = issueFilters?.displayFilters?.layout; - - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); - }, - [workspaceSlug, projectId, issueFilters, updateFilters] - ); - - const handleLayoutChange = useCallback( - (layout: EIssueLayoutTypes) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); - }, - [workspaceSlug, projectId, updateFilters] - ); - - const handleDisplayFilters = useCallback( - (updatedDisplayFilter: Partial) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); - }, - [workspaceSlug, projectId, updateFilters] - ); - - const handleDisplayProperties = useCallback( - (property: Partial) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); - }, - [workspaceSlug, projectId, updateFilters] - ); const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH; const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`; - const canUserCreateIssue = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const issuesCount = getGroupIssueCount(undefined, undefined, false); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); return ( - <> - setAnalyticsModal(false)} - projectDetails={currentProjectDetails ?? undefined} - /> - -
    -
    -
    - router.back()} isLoading={loader}> - - - - ) - ) : ( - - +
    + +
    + router.back()} isLoading={loader}> + + ) - } - /> - } - /> + ) : ( + + + + ) + } + /> + } + /> - } />} - /> - - {issuesCount && issuesCount > 0 ? ( - 1 ? "issues" : "issue"} in this project`} - position="bottom" - > - - {issuesCount} - - - ) : null} -
    - {currentProjectDetails?.anchor && ( - } />} + /> + + {issuesCount && issuesCount > 0 ? ( + 1 ? "issues" : "issue"} in this project`} + position="bottom" > - - Public - - - )} + + + ) : null}
    -
    - handleLayoutChange(layout)} - selectedLayout={activeLayout} + {currentProjectDetails?.anchor ? ( + + + Public + + + ) : ( + <> + )} + + +
    + - - - - - -
    - - {canUserCreateIssue && ( - <> - - - + {canUserCreateIssue ? ( + + ) : ( + <> )} -
    - + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx index 5d6adcfbe99..a1255b206c2 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx @@ -106,7 +106,12 @@ export const ProjectIssuesMobileHeader = observer(() => { maxHeight={"md"} className="flex flex-grow justify-center text-sm text-custom-text-200" placement="bottom-start" - customButton={Layout} + customButton={ +
    + Layout + +
    + } customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" closeOnSelect > diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx index c504aa6459c..d35f3146549 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx @@ -69,7 +69,7 @@ const ModuleIssuesPage = observer(() => { {moduleId && !isSidebarCollapsed && (
    = ({ moduleId }) => { // router @@ -83,9 +83,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const { projectModuleIds, getModuleById } = useModule(); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); const { currentProjectDetails, loader } = useProject(); const { projectLabels } = useLabel(); const { projectStates } = useProjectState(); @@ -149,8 +147,10 @@ export const ModuleIssuesHeader: React.FC = observer(() => { // derived values const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; - const canUserCreateIssue = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); const issuesCount = getGroupIssueCount(undefined, undefined, false); @@ -161,169 +161,164 @@ export const ModuleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} /> -
    -
    -
    - - - - - - - ) - } - /> - - + + + + + - ... - + icon={ + currentProjectDetails && ( + + + + ) + } + /> - } - /> - } - /> - } - /> - - -
    -

    {moduleDetails?.name && moduleDetails.name}

    - {issuesCount && issuesCount > 0 ? ( - 1 ? "issues" : "issue" - } in this module`} - position="bottom" - > - - {issuesCount} - - - ) : null} -
    - - } - className="ml-1.5 flex-shrink-0" - placement="bottom-start" + - {projectModuleIds?.map((moduleId) => )} - - } - /> -
    -
    -
    -
    - handleLayoutChange(layout)} - selectedLayout={activeLayout} - /> - - + + } + /> + } /> - - - + + +
    +

    {moduleDetails?.name && moduleDetails.name}

    + {issuesCount && issuesCount > 0 ? ( + 1 ? "issues" : "issue" + } in this module`} + position="bottom" + > + + {issuesCount} + + + ) : null} +
    + } - displayFilters={issueFilters?.displayFilters ?? {}} - handleDisplayFiltersUpdate={handleDisplayFilters} - displayProperties={issueFilters?.displayProperties ?? {}} - handleDisplayPropertiesUpdate={handleDisplayProperties} - ignoreGroupedFilters={["module"]} - cycleViewDisabled={!currentProjectDetails?.cycle_view} - moduleViewDisabled={!currentProjectDetails?.module_view} - /> -
    -
    - - {canUserCreateIssue && ( - <> - - - - )} - +
    -
    -
    + + {canUserCreateIssue ? ( + <> + + + + ) : ( + <> + )} + + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx index 87747e29ff5..5190dccedc1 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx @@ -3,15 +3,15 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // ui -import { Breadcrumbs, Button, DiceIcon } from "@plane/ui"; +import { Breadcrumbs, Button, DiceIcon, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { ModuleViewHeader } from "@/components/modules"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks -import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; +import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +// constants +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ModulesListHeader: React.FC = observer(() => { // router @@ -20,18 +20,19 @@ export const ModulesListHeader: React.FC = observer(() => { // store hooks const { toggleCreateModuleModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); // auth - const canUserCreateModule = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const canUserCreateModule = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); return ( -
    -
    +
    +
    { />
    -
    -
    + + - {canUserCreateModule && ( + {canUserCreateModule ? ( + ) : ( + <> )} -
    -
    + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx index 7a530ea1439..12301c9d44d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx @@ -1,7 +1,8 @@ "use client"; import { observer } from "mobx-react"; -import { CustomMenu } from "@plane/ui"; +import { ChevronDown } from "lucide-react"; +import { CustomMenu, Row } from "@plane/ui"; import { MODULE_VIEW_LAYOUTS } from "@/constants/module"; import { useModuleFilter, useProject } from "@/hooks/store"; @@ -10,12 +11,16 @@ export const ModulesListMobileHeader = observer(() => { const { updateDisplayFilters } = useModuleFilter(); return ( -
    +
    Layout} + customButton={ + + Layout + + } customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm" closeOnSelect > diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx index f8b474d948b..9417016e385 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx @@ -57,19 +57,17 @@ const ProjectModulesPage = observer(() => {
    {(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && ( -
    - clearAllFilters(`${projectId}`)} - handleRemoveFilter={handleRemoveFilter} - handleDisplayFiltersUpdate={(val) => { - if (!projectId) return; - updateDisplayFilters(projectId.toString(), val); - }} - alwaysAllowEditing - /> -
    + clearAllFilters(`${projectId}`)} + handleRemoveFilter={handleRemoveFilter} + handleDisplayFiltersUpdate={(val) => { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + alwaysAllowEditing + /> )}
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 8c96f2bcf80..e9debb2bcf4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -64,7 +64,7 @@ const PageDetailsPage = observer(() => { <>
    -
    +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index cf72be3f291..44ba6d7f5f0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -7,16 +7,19 @@ import { FileText } from "lucide-react"; // types import { TLogoProps } from "@plane/types"; // ui -import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, setToast } from "@plane/ui"; +import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; +import { PageEditInformationPopover } from "@/components/pages"; // helpers import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; +import { getPageName } from "@/helpers/page.helper"; // hooks -import { usePage, useProject } from "@/hooks/store"; +import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages"; +import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions"; export interface IPagesHeaderProps { showButton?: boolean; @@ -29,11 +32,17 @@ export const PageDetailsHeader = observer(() => { const [isOpen, setIsOpen] = useState(false); // store hooks const { currentProjectDetails, loader } = useProject(); - const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? ""); + const page = usePage(pageId?.toString() ?? ""); + const { name, logo_props, updatePageLogo, owned_by } = page; + const { allowPermissions } = useUserPermissions(); + const { data: currentUser } = useUser(); // use platform - const { platform } = usePlatformOS(); - // derived values - const isMac = platform === "MacOS"; + const { isMobile } = usePlatformOS(); + + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + const isOwner = owned_by === currentUser?.id; + + const isEditable = isAdmin || isOwner; const handlePageLogoUpdate = async (data: TLogoProps) => { if (data) { @@ -55,9 +64,11 @@ export const PageDetailsHeader = observer(() => { } }; + const pageTitle = getPageName(name); + return ( -
    -
    +
    +
    { setIsOpen(val)} - className="flex items-center justify-center" - buttonClassName="flex items-center justify-center" - label={ - <> - {logo_props?.in_use ? ( - - ) : ( - - )} - - } - onChange={(val) => { - let logoValue = {}; +
  1. +
    +
    +
    + setIsOpen(val)} + className="flex items-center justify-center" + buttonClassName="flex items-center justify-center" + label={ + <> + {logo_props?.in_use ? ( + + ) : ( + + )} + + } + onChange={(val) => { + let logoValue = {}; - if (val?.type === "emoji") - logoValue = { - value: convertHexEmojiToDecimal(val.value.unified), - url: val.value.imageUrl, - }; - else if (val?.type === "icon") logoValue = val.value; + if (val?.type === "emoji") + logoValue = { + value: convertHexEmojiToDecimal(val.value.unified), + url: val.value.imageUrl, + }; + else if (val?.type === "icon") logoValue = val.value; - handlePageLogoUpdate({ - in_use: val?.type, - [val?.type]: logoValue, - }).finally(() => setIsOpen(false)); - }} - defaultIconColor={ - logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined - } - defaultOpen={ - logo_props?.in_use && logo_props?.in_use === "emoji" - ? EmojiIconPickerTypes.EMOJI - : EmojiIconPickerTypes.ICON - } - /> - } - /> + handlePageLogoUpdate({ + in_use: val?.type, + [val?.type]: logoValue, + }).finally(() => setIsOpen(false)); + }} + defaultIconColor={ + logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined + } + defaultOpen={ + logo_props?.in_use && logo_props?.in_use === "emoji" + ? EmojiIconPickerTypes.EMOJI + : EmojiIconPickerTypes.ICON + } + disabled={!isEditable} + /> +
    + +
    + {pageTitle} +
    +
    +
    +
    +
  2. } />
    -
    - - {isContentEditable && ( - - )} -
    + + + + + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index 87a137224d1..f98bd9db47c 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -1,37 +1,68 @@ "use client"; +import { useState } from "react"; import { observer } from "mobx-react"; -import { useParams, useSearchParams } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { FileText } from "lucide-react"; -// ui -import { Breadcrumbs, Button } from "@plane/ui"; +// plane types +import { TPage } from "@plane/types"; +// plane ui +import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; // helpers import { BreadcrumbLink, Logo } from "@/components/common"; // constants import { EPageAccess } from "@/constants/page"; -import { EUserProjectRoles } from "@/constants/project"; // hooks -import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store"; +import { useEventTracker, useProject, useProjectPages, useUserPermissions } from "@/hooks/store"; +// plane web hooks +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const PagesListHeader = observer(() => { + // states + const [isCreatingPage, setIsCreatingPage] = useState(false); // router + const router = useRouter(); const { workspaceSlug } = useParams(); const searchParams = useSearchParams(); const pageType = searchParams.get("type"); // store hooks - const { toggleCreatePageModal } = useCommandPalette(); - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); + const { createPage } = useProjectPages(); const { setTrackElement } = useEventTracker(); + // auth + const canUserCreatePage = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + EUserPermissionsLevel.PROJECT + ); + // handle page create + const handleCreatePage = async () => { + setIsCreatingPage(true); + setTrackElement("Project pages page"); - const canUserCreatePage = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const payload: Partial = { + access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC, + }; + + await createPage(payload) + .then((res) => { + const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`; + router.push(pageId); + }) + .catch((err) => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err?.data?.error || "Page could not be created. Please try again.", + }) + ) + .finally(() => setIsCreatingPage(false)); + }; return ( -
    -
    +
    +
    { />
    -
    - {canUserCreatePage && ( -
    - -
    + + ) : ( + <> )} -
    + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index b3ac8980f85..4171e1f332d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -6,7 +6,10 @@ import { useParams, useSearchParams } from "next/navigation"; import { TPageNavigationTabs } from "@plane/types"; // components import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; import { PagesListRoot, PagesListView } from "@/components/pages"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; // hooks import { useProject } from "@/hooks/store"; @@ -16,7 +19,7 @@ const ProjectPagesPage = observer(() => { const type = searchParams.get("type"); const { workspaceSlug, projectId } = useParams(); // store hooks - const { getProjectById } = useProject(); + const { getProjectById, currentProjectDetails } = useProject(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; @@ -29,6 +32,17 @@ const ProjectPagesPage = observer(() => { }; if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.page_view === false) + return ( +
    + +
    + ); return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx index 1676b25aa1c..141cc39feda 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx @@ -7,22 +7,23 @@ import { IProject } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { PageHead } from "@/components/core"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks -import { useProject, useUser } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const AutomationSettingsPage = observer(() => { // router const { workspaceSlug, projectId } = useParams(); // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentProjectDetails: projectDetails, updateProject } = useProject(); + // derived values + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + const handleChange = async (formData: Partial) => { if (!workspaceSlug || !projectId || !projectDetails) return; @@ -36,15 +37,18 @@ const AutomationSettingsPage = observer(() => { }; // derived values - const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + return ( <> -
    -
    -

    Automations

    +
    +
    +

    Automations

    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx index f292bd6d995..14f7707672b 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx @@ -3,30 +3,40 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EstimateRoot } from "@/components/estimates"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks -import { useUser, useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const EstimatesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); - const { - membership: { currentProjectRole }, - } = useUser(); + // store const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values - const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); if (!workspaceSlug || !projectId) return <>; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + return ( <> -
    - +
    +
    ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx index 9317003c7a0..05bde8c9e1a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx @@ -2,48 +2,42 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import useSWR from "swr"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectFeaturesList } from "@/components/project"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks -import { useProject, useUser } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const FeaturesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); // store - const { - membership: { fetchUserProjectInfo }, - } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); - // fetch the project details - const { data: memberDetails } = useSWR( - workspaceSlug && projectId ? `PROJECT_MEMBERS_ME_${workspaceSlug}_${projectId}` : null, - workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug.toString(), projectId.toString()) : null - ); // derived values - const isAdmin = memberDetails?.role === EUserProjectRoles.ADMIN; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined; + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); if (!workspaceSlug || !projectId) return null; + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + return ( <> -
    -
    -

    Features

    -
    +
    ); }); -export default FeaturesSettingsPage; \ No newline at end of file +export default FeaturesSettingsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index be9e3781a3d..041d2f34e1e 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -5,30 +5,28 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // ui import { Settings } from "lucide-react"; -import { Breadcrumbs, CustomMenu } from "@plane/ui"; +import { Breadcrumbs, CustomMenu, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; // constants -import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; // hooks -import { useProject, useUser } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +// plane web constants +import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; +import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ProjectSettingHeader: FC = observer(() => { // router const router = useAppRouter(); const { workspaceSlug, projectId } = useParams(); // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); const { currentProjectDetails, loader } = useProject(); - if (currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER) return null; - return ( -
    -
    +
    +
    @@ -70,16 +68,24 @@ export const ProjectSettingHeader: FC = observer(() => { placement="bottom-start" closeOnSelect > - {PROJECT_SETTINGS_LINKS.map((item) => ( - router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} - > - {item.label} - - ))} + {PROJECT_SETTINGS_LINKS.map( + (item) => + allowPermissions( + item.access, + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ) && ( + router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} + > + {item.label} + + ) + )} -
    -
    +
    +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx index 192d7147f70..2705ff4901d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx @@ -5,17 +5,28 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectSettingsLabelList } from "@/components/labels"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const LabelsSettingsPage = observer(() => { + // store hooks const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; const scrollableContainerRef = useRef(null); + // derived values + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + // Enable Auto Scroll for Labels list useEffect(() => { const element = scrollableContainerRef.current; @@ -29,10 +40,14 @@ const LabelsSettingsPage = observer(() => { ); }, [scrollableContainerRef?.current]); + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + return ( <> -
    +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx index ac14c5019c4..e532e743af3 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx @@ -1,18 +1,8 @@ "use client"; import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -// ui -import { Button, LayersIcon } from "@plane/ui"; // components -import { NotAuthorizedView } from "@/components/auth-screens"; -import { AppHeader, ContentWrapper } from "@/components/core"; -// constants -import { EUserProjectRoles } from "@/constants/project"; -// hooks -import { useUser } from "@/hooks/store"; +import { AppHeader } from "@/components/core"; // local components import { ProjectSettingHeader } from "./header"; import { ProjectSettingsSidebar } from "./sidebar"; @@ -21,48 +11,23 @@ export interface IProjectSettingLayout { children: ReactNode; } -const ProjectSettingLayout: FC = observer((props) => { +const ProjectSettingLayout: FC = (props) => { const { children } = props; - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); - - const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER; - - if (restrictViewSettings) { - return ( - - - - } - /> - ); - } - return ( <> } /> - -
    -
    - -
    -
    +
    +
    + +
    +
    +
    {children}
    - +
    ); -}); +}; export default ProjectSettingLayout; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx index af1c82e12aa..0d05187b189 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx @@ -2,21 +2,32 @@ import { observer } from "mobx-react"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const MembersSettingsPage = observer(() => { // store const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } return ( <> -
    +
    diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx index cc5cfc855d5..e21532c643a 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/page.tsx @@ -15,7 +15,8 @@ import { ProjectDetailsFormLoader, } from "@/components/project"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const GeneralSettingsPage = observer(() => { // states @@ -25,6 +26,8 @@ const GeneralSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); // store hooks const { currentProjectDetails, fetchProjectDetails } = useProject(); + const { allowPermissions } = useUserPermissions(); + // api call to fetch project details // TODO: removed this API if not necessary const { isLoading } = useSWR( @@ -32,7 +35,13 @@ const GeneralSettingsPage = observer(() => { workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null ); // derived values - const isAdmin = currentProjectDetails?.member_role === 20; + const isAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ); + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined; // const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network); // const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network")); @@ -57,7 +66,7 @@ const GeneralSettingsPage = observer(() => { )} -
    +
    {currentProjectDetails && workspaceSlug && projectId && !isLoading ? ( { )} - {isAdmin && ( + {isAdmin && currentProjectDetails && ( <> { +export const ProjectSettingsSidebar = observer(() => { const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); // mobx store - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions, projectUserInfo } = useUserPermissions(); - const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST; + // derived values + const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role; if (!currentProjectRole) { return ( -
    +
    SETTINGS @@ -36,23 +39,26 @@ export const ProjectSettingsSidebar = () => { } return ( -
    +
    SETTINGS
    {PROJECT_SETTINGS_LINKS.map( (link) => - projectMemberInfo >= link.access && ( + allowPermissions( + link.access, + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ) && ( -
    {link.label} -
    + ) )} @@ -60,4 +66,4 @@ export const ProjectSettingsSidebar = () => {
    ); -}; +}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx index 6c030a1fdc2..af9bf5618e4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx @@ -1,26 +1,42 @@ "use client"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; -import { ProjectSettingStateList } from "@/components/states"; +import { ProjectStateRoot } from "@/components/project-states"; // hook -import { useProject } from "@/hooks/store"; +import { useProject, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const StatesSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); // store const { currentProjectDetails } = useProject(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined; + // derived values + const canPerformProjectMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + + if (workspaceUserInfo && !canPerformProjectMemberActions) { + return ; + } + return ( <> -
    -
    -

    States

    -
    - +
    +

    States

    + {workspaceSlug && projectId && ( + + )} ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 7647633bcfd..71f2d8df425 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -4,11 +4,11 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { Earth, Lock } from "lucide-react"; +import { Layers, Lock } from "lucide-react"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui -import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon, Tooltip } from "@plane/ui"; +import { Breadcrumbs, Button, CustomMenu, Tooltip, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; @@ -19,10 +19,10 @@ import { EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, } from "@/constants/issue"; -import { EUserProjectRoles } from "@/constants/project"; import { EViewAccess } from "@/constants/views"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; +import { getPublishViewLink } from "@/helpers/project-views.helpers"; import { truncateText } from "@/helpers/string.helper"; // hooks import { @@ -34,8 +34,9 @@ import { useProject, useProjectState, useProjectView, - useUser, + useUserPermissions, } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; export const ProjectViewIssuesHeader: React.FC = observer(() => { // router @@ -46,9 +47,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { } = useIssues(EIssuesStoreType.PROJECT_VIEW); const { setTrackElement } = useEventTracker(); const { toggleCreateIssueModal } = useCommandPalette(); - const { - membership: { currentProjectRole }, - } = useUser(); + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails, loader } = useProject(); const { projectViewIds, getViewById } = useProjectView(); const { projectStates } = useProjectState(); @@ -130,12 +130,15 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const viewDetails = viewId ? getViewById(viewId.toString()) : null; - const canUserCreateIssue = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const publishLink = getPublishViewLink(viewDetails?.anchor); return ( -
    -
    +
    + { } + icon={} /> } /> @@ -172,7 +175,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { {viewDetails?.logo_props?.in_use ? ( ) : ( - + )} {viewDetails?.name && truncateText(viewDetails.name, 40)} @@ -194,7 +197,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { {view?.logo_props?.in_use ? ( ) : ( - + )} {truncateText(view.name, 40)} @@ -206,14 +209,32 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { /> -
    - - {viewDetails?.access === EViewAccess.PUBLIC ? : } - -
    -
    -
    - {!viewDetails?.is_locked && ( + {viewDetails?.access === EViewAccess.PRIVATE ? ( +
    + + + +
    + ) : ( + <> + )} + + {viewDetails?.anchor && publishLink ? ( + + + Live + + ) : ( + <> + )} + + + {!viewDetails?.is_locked ? ( <> { /> + ) : ( + <> )} - {canUserCreateIssue && ( + {canUserCreateIssue ? ( + ) : ( + <> )} -
    -
    + + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index fae58221a86..2fd2d652479 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -1,108 +1,59 @@ "use client"; -import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { Layers } from "lucide-react"; // ui -import { TViewFilterProps } from "@plane/types"; -import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui"; +import { Breadcrumbs, Button, Header } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { ViewListHeader } from "@/components/views"; -import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; -// constants -import { EUserProjectRoles } from "@/constants/project"; -import { EViewAccess } from "@/constants/views"; -// helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useCommandPalette, useProject, useProjectView, useUser } from "@/hooks/store"; +import { useCommandPalette, useProject } from "@/hooks/store"; export const ProjectViewsHeader = observer(() => { // router const { workspaceSlug } = useParams(); // store hooks const { toggleCreateViewModal } = useCommandPalette(); - const { - membership: { currentProjectRole }, - } = useUser(); const { currentProjectDetails, loader } = useProject(); - const { filters, updateFilters, clearAllFilters } = useProjectView(); - - const handleRemoveFilter = useCallback( - (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { - let newValues = filters.filters?.[key]; - - if (key === "favorites") { - newValues = !!value; - } - if (Array.isArray(newValues)) { - if (!value) newValues = []; - else newValues = newValues.filter((val) => val !== value) as string[]; - } - - updateFilters("filters", { [key]: newValues }); - }, - [filters.filters, updateFilters] - ); - - const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; - - const canUserCreateView = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); return ( <> -
    -
    +
    + + + + + + ) + } + /> + } + /> + } />} + /> + + + +
    - - - - - ) - } - /> - } - /> - } /> - } - /> - +
    -
    -
    - - {canUserCreateView && ( -
    - -
    - )} -
    -
    - {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 index 69493402d8c..3143612c29e 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx @@ -3,11 +3,12 @@ import { AppHeader, ContentWrapper } from "@/components/core"; // local components import { ProjectViewsHeader } from "./header"; +import { ViewMobileHeader } from "./mobile-header"; export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) { return ( <> - } /> + } mobileHeader={} /> {children} ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx new file mode 100644 index 00000000000..608ed5dff17 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { observer } from "mobx-react"; +// icons +import { ChevronDown, ListFilter } from "lucide-react"; +// components +import { Row } from "@plane/ui"; +import { FiltersDropdown } from "@/components/issues/issue-layouts"; +import { ViewFiltersSelection } from "@/components/views/filters/filter-selection"; +import { ViewOrderByDropdown } from "@/components/views/filters/order-by"; +// hooks +import { useMember, useProjectView } from "@/hooks/store"; + +export const ViewMobileHeader = observer(() => { + // store hooks + const { filters, updateFilters } = useProjectView(); + const { + project: { projectMemberIds }, + } = useMember(); + + return ( + <> +
    + + { + if (val.key) updateFilters("sortKey", val.key); + if (val.order) updateFilters("sortBy", val.order); + }} + isMobile + /> + +
    + } + title="Filters" + placement="bottom-end" + isFiltersApplied={false} + menuButton={ + + Filters + + + } + > + + +
    +
    + + ); +}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx index 25daf594c59..92fedb453e6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx @@ -1,21 +1,29 @@ "use client"; +import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { TViewFilterProps } from "@plane/types"; +import { Header, EHeaderVariant } from "@plane/ui"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; import { ProjectViewsList } from "@/components/views"; -// constants +import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; import { EmptyStateType } from "@/constants/empty-state"; +// constants +import { EViewAccess } from "@/constants/views"; +// helpers +import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useProjectView } from "@/hooks/store"; const ProjectViewsPage = observer(() => { // router const { workspaceSlug, projectId } = useParams(); // store const { getProjectById, currentProjectDetails } = useProject(); + const { filters, updateFilters, clearAllFilters } = useProjectView(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Views` : undefined; @@ -32,10 +40,38 @@ const ProjectViewsPage = observer(() => { />
    ); + const handleRemoveFilter = useCallback( + (key: keyof TViewFilterProps, value: string | EViewAccess | null) => { + let newValues = filters.filters?.[key]; + + if (key === "favorites") { + newValues = !!value; + } + if (Array.isArray(newValues)) { + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value) as string[]; + } + + updateFilters("filters", { [key]: newValues }); + }, + [filters.filters, updateFilters] + ); + + const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; return ( <> + {isFiltersApplied && ( +
    + +
    + )} ); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx new file mode 100644 index 00000000000..a308e197897 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { ReactNode } from "react"; +// components +import { AppHeader, ContentWrapper } from "@/components/core"; +// local components +import { ProjectsListHeader } from "@/plane-web/components/projects/header"; +import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; +export default function ProjectListLayout({ children }: { children: ReactNode }) { + return ( + <> + } mobileHeader={} /> + {children} + + ); +} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx new file mode 100644 index 00000000000..ac6e5c3cd92 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx @@ -0,0 +1,4 @@ +import { ProjectPageRoot } from "@/plane-web/components/projects/page"; + +const ProjectsPage = () => ; +export default ProjectsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx deleted file mode 100644 index 04c69778199..00000000000 --- a/web/app/[workspaceSlug]/(projects)/projects/(list)/header.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"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"; -// ui -import { Breadcrumbs, Button } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common"; -import { FiltersDropdown } from "@/components/issues"; -import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; -// helpers -import { cn } from "@/helpers/common.helper"; -import { calculateTotalFilters } from "@/helpers/filter.helper"; -// hooks -import { useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; - -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 { setTrackElement } = useEventTracker(); - const { - membership: { currentWorkspaceRole }, - } = useUser(); - const { - currentWorkspaceDisplayFilters: displayFilters, - currentWorkspaceFilters: filters, - updateFilters, - updateDisplayFilters, - searchQuery, - updateSearchQuery, - } = useProjectFilter(); - const { - workspace: { workspaceMemberIds }, - } = useMember(); - // outside click detector hook - useOutsideClickDetector(inputRef, () => { - if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); - }); - // auth - const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; - - const handleFilters = useCallback( - (key: keyof TProjectFilters, value: string | string[]) => { - if (!workspaceSlug) return; - let newValues = filters?.[key] ?? []; - if (Array.isArray(value)) { - if (key === "created_at" && newValues.find((v) => v.includes("custom"))) newValues = []; - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else { - if (key === "created_at") newValues = [value]; - else newValues.push(value); - } - } - - updateFilters(workspaceSlug.toString(), { [key]: newValues }); - }, - [filters, updateFilters, workspaceSlug] - ); - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else setIsSearchOpen(false); - } - }; - - useEffect(() => { - if (searchQuery.trim() !== "") setIsSearchOpen(true); - }, [searchQuery]); - - const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0; - - return ( -
    -
    -
    - - } />} - /> - -
    -
    -
    -
    - {!isSearchOpen && ( - - )} -
    - - updateSearchQuery(e.target.value)} - onKeyDown={handleInputKeyDown} - /> - {isSearchOpen && ( - - )} -
    -
    -
    - { - if (!workspaceSlug || val === displayFilters?.order_by) return; - updateDisplayFilters(workspaceSlug.toString(), { - order_by: val, - }); - }} - /> - } - title="Filters" - placement="bottom-end" - isFiltersApplied={isFiltersApplied} - > - { - if (!workspaceSlug) return; - updateDisplayFilters(workspaceSlug.toString(), val); - }} - memberIds={workspaceMemberIds ?? undefined} - /> - -
    - {isAuthorizedUser && ( - - )} -
    -
    - ); -}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx index 259c412dcb7..a308e197897 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx @@ -4,9 +4,8 @@ import { ReactNode } from "react"; // components import { AppHeader, ContentWrapper } from "@/components/core"; // local components -import { ProjectsListHeader } from "./header"; -import { ProjectsListMobileHeader } from "./mobile-header"; - +import { ProjectsListHeader } from "@/plane-web/components/projects/header"; +import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header"; export default function ProjectListLayout({ children }: { children: ReactNode }) { return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx index 40e7f30a2a1..ac6e5c3cd92 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx @@ -1,84 +1,4 @@ -"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 { ProjectAppliedFiltersList, ProjectCardList } from "@/components/project"; -// helpers -import { calculateTotalFilters } from "@/helpers/filter.helper"; -// hooks -import { useProject, useProjectFilter, useWorkspace } from "@/hooks/store"; - -const ProjectsPage = observer(() => { - // store - const { workspaceSlug } = useParams(); - const { currentWorkspace } = useWorkspace(); - const { totalProjectIds, filteredProjectIds } = useProject(); - const { - currentWorkspaceFilters, - currentWorkspaceAppliedDisplayFilters, - clearAllFilters, - clearAllAppliedDisplayFilters, - updateFilters, - updateDisplayFilters, - } = useProjectFilter(); - // derived values - const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; - - const handleRemoveFilter = useCallback( - (key: keyof TProjectFilters, value: string | null) => { - if (!workspaceSlug) return; - let newValues = currentWorkspaceFilters?.[key] ?? []; - - if (!value) newValues = []; - else newValues = newValues.filter((val) => val !== value); - - updateFilters(workspaceSlug.toString(), { [key]: newValues }); - }, - [currentWorkspaceFilters, updateFilters, workspaceSlug] - ); - - const handleRemoveDisplayFilter = useCallback( - (key: TProjectAppliedDisplayFilterKeys) => { - if (!workspaceSlug) return; - updateDisplayFilters(workspaceSlug.toString(), { [key]: false }); - }, - [updateDisplayFilters, workspaceSlug] - ); - - const handleClearAllFilters = useCallback(() => { - if (!workspaceSlug) return; - clearAllFilters(workspaceSlug.toString()); - clearAllAppliedDisplayFilters(workspaceSlug.toString()); - }, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]); - - return ( - <> - -
    - {(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 || - currentWorkspaceAppliedDisplayFilters?.length !== 0) && ( -
    - -
    - )} - -
    - - ); -}); +import { ProjectPageRoot } from "@/plane-web/components/projects/page"; +const ProjectsPage = () => ; export default ProjectsPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx index 906fee32845..fd2ed9669b2 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx @@ -8,15 +8,16 @@ import useSWR from "swr"; import { Button } from "@plane/ui"; // component import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; 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"; +import { useUserPermissions, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // services import { APITokenService } from "@/services/api_token.service"; @@ -28,28 +29,22 @@ const ApiTokensPage = observer(() => { // router const { workspaceSlug } = useParams(); // store hooks - const { - membership: { currentWorkspaceRole }, - } = useUser(); const { currentWorkspace } = useWorkspace(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; - - const { data: tokens } = useSWR(workspaceSlug && isAdmin ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () => - workspaceSlug && isAdmin ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null + const { data: tokens } = useSWR( + workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null, + () => + workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null ); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - API Tokens` : undefined; - if (!isAdmin) - return ( - <> - -
    -

    You are not authorized to access this page.

    -
    - - ); + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } if (!tokens) { return ; @@ -59,10 +54,10 @@ const ApiTokensPage = observer(() => { <> setIsCreateTokenModalOpen(false)} /> -
    +
    {tokens.length > 0 ? ( <> -
    +

    API tokens

    - -
    -
    -
    + ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx index 59fd4d2c754..dc3f3aafce7 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx @@ -2,40 +2,41 @@ import { observer } from "mobx-react"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks -import { useUser, useWorkspace } from "@/hooks/store"; +import { useUserPermissions, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const ExportsPage = observer(() => { // store hooks - const { - membership: { currentWorkspaceRole }, - } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { currentWorkspace } = useWorkspace(); // derived values - const hasPageAccess = - currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole); + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Exports` : undefined; - if (!hasPageAccess) - return ( - <> - -
    -

    You are not authorized to access this page.

    -
    - - ); + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } return ( <> -
    -
    +
    +

    Exports

    @@ -44,4 +45,4 @@ const ExportsPage = observer(() => { ); }); -export default ExportsPage; \ No newline at end of file +export default ExportsPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/settings/header.tsx index 5407f79bcd7..d98db1a65b4 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/header.tsx @@ -4,7 +4,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Settings } from "lucide-react"; // ui -import { Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common"; // hooks @@ -14,24 +14,22 @@ export const WorkspaceSettingHeader: FC = observer(() => { const { currentWorkspace, loader } = useWorkspace(); return ( -
    -
    -
    - - } - /> - } - /> - } /> - -
    -
    -
    +
    + + + } + /> + } + /> + } /> + + +
    ); }); diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx index 34231303301..dc0815751a4 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx @@ -4,20 +4,17 @@ import { observer } from "mobx-react"; // components import { PageHead } from "@/components/core"; import IntegrationGuide from "@/components/integration/guide"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks -import { useUser, useWorkspace } from "@/hooks/store"; +import { useUserPermissions, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const ImportsPage = observer(() => { // store hooks - const { - membership: { currentWorkspaceRole }, - } = useUser(); const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); // derived values - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; if (!isAdmin) @@ -33,8 +30,8 @@ const ImportsPage = observer(() => { return ( <> -
    -
    +
    +

    Imports

    diff --git a/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx index 7db73cbe658..290eb24ca3d 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/integrations/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; @@ -8,9 +8,9 @@ import { SingleIntegrationCard } from "@/components/integration"; import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; // constants import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks -import { useUser, useWorkspace } from "@/hooks/store"; +import { useUserPermissions, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // services import { IntegrationService } from "@/services/integrations"; @@ -20,20 +20,18 @@ const WorkspaceIntegrationsPage = observer(() => { // router const { workspaceSlug } = useParams(); // store hooks - const { - membership: { currentWorkspaceRole }, - } = useUser(); const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); // derived values - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; if (!isAdmin) return ( <> -
    +

    You are not authorized to access this page.

    @@ -46,7 +44,7 @@ const WorkspaceIntegrationsPage = observer(() => { return ( <> -
    +
    {appIntegrations ? ( @@ -62,4 +60,4 @@ const WorkspaceIntegrationsPage = observer(() => { ); }); -export default WorkspaceIntegrationsPage; \ No newline at end of file +export default WorkspaceIntegrationsPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx index bd04e2d6eeb..9bd133cc147 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/layout.tsx @@ -1,8 +1,14 @@ "use client"; -import { ReactNode } from "react"; +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; // components -import { AppHeader, ContentWrapper } from "@/components/core"; +import { NotAuthorizedView } from "@/components/auth-screens"; +import { AppHeader } from "@/components/core"; +// hooks +import { useUserPermissions } from "@/hooks/store"; +// plane web constants +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; // local components import { WorkspaceSettingHeader } from "./header"; import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs"; @@ -12,25 +18,36 @@ export interface IWorkspaceSettingLayout { children: ReactNode; } -export default function WorkspaceSettingLayout(props: IWorkspaceSettingLayout) { +const WorkspaceSettingLayout: FC = observer((props) => { const { children } = props; + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + + // derived values + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + return ( <> } /> - -
    -
    - -
    -
    - -
    - {children} + +
    + {workspaceUserInfo && !isWorkspaceAdmin ? ( + + ) : ( + <> +
    + +
    +
    +
    + {children} +
    -
    -
    - + + )} +
    ); -} +}); + +export default WorkspaceSettingLayout; diff --git a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index 2899c5ed571..ab23261d914 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -9,15 +9,17 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace"; // constants import { MEMBER_INVITED } from "@/constants/event-tracker"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers +import { cn } from "@/helpers/common.helper"; import { getUserRole } from "@/helpers/user.helper"; // hooks -import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store"; +import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const WorkspaceMembersSettingsPage = observer(() => { // states @@ -26,15 +28,20 @@ const WorkspaceMembersSettingsPage = observer(() => { // router const { workspaceSlug } = useParams(); // store hooks + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { captureEvent } = useEventTracker(); - const { - membership: { currentWorkspaceRole }, - } = useUser(); const { workspace: { inviteMembersToWorkspace }, } = useMember(); const { currentWorkspace } = useWorkspace(); + // derived values + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformWorkspaceMemberActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + const handleWorkspaceInvite = (data: IWorkspaceBulkInviteFormData) => { if (!workspaceSlug) return; @@ -45,7 +52,7 @@ const WorkspaceMembersSettingsPage = observer(() => { emails: [ ...data.emails.map((email) => ({ email: email.email, - role: getUserRole(email.role), + role: getUserRole(email.role as unknown as EUserPermissions), })), ], project_id: undefined, @@ -63,7 +70,7 @@ const WorkspaceMembersSettingsPage = observer(() => { emails: [ ...data.emails.map((email) => ({ email: email.email, - role: getUserRole(email.role), + role: getUserRole(email.role as unknown as EUserPermissions), })), ], project_id: undefined, @@ -79,10 +86,13 @@ const WorkspaceMembersSettingsPage = observer(() => { }; // derived values - const hasAddMemberPermission = - currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined; + // if user is not authorized to view this page + if (workspaceUserInfo && !canPerformWorkspaceMemberActions) { + return ; + } + return ( <> @@ -91,8 +101,12 @@ const WorkspaceMembersSettingsPage = observer(() => { onClose={() => setInviteModal(false)} onSubmit={handleWorkspaceInvite} /> -
    -
    +
    +

    Members

    @@ -104,13 +118,13 @@ const WorkspaceMembersSettingsPage = observer(() => { onChange={(e) => setSearchQuery(e.target.value)} />
    - {hasAddMemberPermission && ( + {canPerformWorkspaceAdminActions && ( )}
    - +
    ); diff --git a/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx index 4ffa0aff5b3..ee3035220c8 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/mobile-header-tabs.tsx @@ -1,27 +1,40 @@ +import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; -// constants -import { WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; // hooks +import { useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +// plane web constants +import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace"; +// plane web helpers +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; -export const MobileWorkspaceSettingsTabs = () => { +export const MobileWorkspaceSettingsTabs = observer(() => { const router = useAppRouter(); const { workspaceSlug } = useParams(); const pathname = usePathname(); + // mobx store + const { allowPermissions } = useUserPermissions(); + return (
    - {WORKSPACE_SETTINGS_LINKS.map((item, index) => ( -
    router.push(`/${workspaceSlug}${item.href}`)} - > - {item.label} -
    - ))} + {WORKSPACE_SETTINGS_LINKS.map( + (item, index) => + shouldRenderSettingLink(item.key) && + allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( +
    router.push(`/${workspaceSlug}${item.href}`)} + > + {item.label} +
    + ) + )}
    ); -}; +}); diff --git a/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx index 8dfd0d7c390..6bce5daa325 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/sidebar.tsx @@ -4,41 +4,40 @@ import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -// constants -import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/workspace"; +// components +import { SidebarNavItem } from "@/components/sidebar"; // hooks -import { useUser } from "@/hooks/store"; +import { useUserPermissions } from "@/hooks/store"; +// plane web constants +import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +import { WORKSPACE_SETTINGS_LINKS } from "@/plane-web/constants/workspace"; +// plane web helpers +import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; export const WorkspaceSettingsSidebar = observer(() => { // router const { workspaceSlug } = useParams(); const pathname = usePathname(); // mobx store - const { - membership: { currentWorkspaceRole }, - } = useUser(); - - const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; + const { allowPermissions } = useUserPermissions(); return ( -
    +
    SETTINGS
    {WORKSPACE_SETTINGS_LINKS.map( (link) => - workspaceMemberInfo >= link.access && ( + shouldRenderSettingLink(link.key) && + allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && ( - -
    - {link.label} -
    -
    + + {link.label} + ) )} diff --git a/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx index ce3e7a5ebee..4669f406494 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/[webhookId]/page.tsx @@ -12,7 +12,8 @@ import { LogoSpinner } from "@/components/common"; import { PageHead } from "@/components/core"; import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks"; // hooks -import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; +import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const WebhookDetailsPage = observer(() => { // states @@ -20,18 +21,17 @@ const WebhookDetailsPage = observer(() => { // router const { workspaceSlug, webhookId } = useParams(); // mobx store - const { - membership: { currentWorkspaceRole }, - } = useUser(); const { currentWebhook, fetchWebhookById, updateWebhook } = useWebhook(); const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); // TODO: fix this error // useEffect(() => { // if (isCreated !== "true") clearSecretKey(); // }, [clearSecretKey, isCreated]); - const isAdmin = currentWorkspaceRole === 20; + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhook` : undefined; useSWR( @@ -90,8 +90,8 @@ const WebhookDetailsPage = observer(() => { <> setDeleteWebhookModal(false)} /> -
    -
    +
    +
    await handleUpdateWebhook(data)} data={currentWebhook} />
    {currentWebhook && setDeleteWebhookModal(true)} />} @@ -100,4 +100,4 @@ const WebhookDetailsPage = observer(() => { ); }); -export default WebhookDetailsPage; \ No newline at end of file +export default WebhookDetailsPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx index 695f1f16b28..86c922f07fb 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx @@ -7,6 +7,7 @@ import useSWR from "swr"; // ui import { Button } from "@plane/ui"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; import { WebhookSettingsLoader } from "@/components/ui"; @@ -14,7 +15,8 @@ import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks"; // constants import { EmptyStateType } from "@/constants/empty-state"; // hooks -import { useUser, useWebhook, useWorkspace } from "@/hooks/store"; +import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; const WebhooksListPage = observer(() => { // states @@ -22,17 +24,16 @@ const WebhooksListPage = observer(() => { // router const { workspaceSlug } = useParams(); // mobx store - const { - membership: { currentWorkspaceRole }, - } = useUser(); + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook(); const { currentWorkspace } = useWorkspace(); - const isAdmin = currentWorkspaceRole === 20; + const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); useSWR( - workspaceSlug && isAdmin ? `WEBHOOKS_LIST_${workspaceSlug}` : null, - workspaceSlug && isAdmin ? () => fetchWebhooks(workspaceSlug.toString()) : null + workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, + workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null ); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhooks` : undefined; @@ -42,22 +43,16 @@ const WebhooksListPage = observer(() => { if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey(); }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); - if (!isAdmin) - return ( - <> - -
    -

    You are not authorized to access this page.

    -
    - - ); + if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { + return ; + } if (!webhooks) return ; return ( <> -
    +
    { /> {Object.keys(webhooks).length > 0 ? (
    -
    +
    Webhooks
    - )} -
    -
    + + + + ); }); diff --git a/web/app/accounts/forgot-password/page.tsx b/web/app/accounts/forgot-password/page.tsx index 0e203a1f882..91516c5b95e 100644 --- a/web/app/accounts/forgot-password/page.tsx +++ b/web/app/accounts/forgot-password/page.tsx @@ -104,7 +104,7 @@ export default function ForgotPasswordPage() { />
    -
    +
    Plane logo @@ -154,6 +154,7 @@ export default function ForgotPasswordPage() { hasError={Boolean(errors.email)} placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400" + autoComplete="on" disabled={resendTimerCode > 0} /> )} diff --git a/web/app/accounts/reset-password/page.tsx b/web/app/accounts/reset-password/page.tsx index 43f001abe9d..04d6e3115b7 100644 --- a/web/app/accounts/reset-password/page.tsx +++ b/web/app/accounts/reset-password/page.tsx @@ -116,7 +116,7 @@ export default function ResetPasswordPage() { />
    -
    +
    Plane logo @@ -153,6 +153,7 @@ export default function ResetPasswordPage() { //hasError={Boolean(errors.email)} placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400 cursor-not-allowed" + autoComplete="on" disabled />
    @@ -173,6 +174,7 @@ export default function ResetPasswordPage() { minLength={8} onFocus={() => setIsPasswordInputFocused(true)} onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" autoFocus /> {showPassword.password ? ( diff --git a/web/app/accounts/set-password/page.tsx b/web/app/accounts/set-password/page.tsx index e74b506e602..f3ac35b76f5 100644 --- a/web/app/accounts/set-password/page.tsx +++ b/web/app/accounts/set-password/page.tsx @@ -118,7 +118,7 @@ const SetPasswordPage = observer(() => { />
    -
    +
    Plane logo @@ -147,6 +147,7 @@ const SetPasswordPage = observer(() => { //hasError={Boolean(errors.email)} placeholder="name@company.com" className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 text-onboarding-text-400 cursor-not-allowed" + autoComplete="on" disabled />
    @@ -167,6 +168,7 @@ const SetPasswordPage = observer(() => { minLength={8} onFocus={() => setIsPasswordInputFocused(true)} onBlur={() => setIsPasswordInputFocused(false)} + autoComplete="on" autoFocus /> {showPassword.password ? ( diff --git a/web/app/invitations/page.tsx b/web/app/invitations/page.tsx index a56ff6ad421..580896bb569 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/invitations/page.tsx @@ -27,6 +27,9 @@ import { useEventTracker, useUser, useUserProfile, useWorkspace } from "@/hooks/ import { useAppRouter } from "@/hooks/use-app-router"; // services import { AuthenticationWrapper } from "@/lib/wrappers"; +// plane web constants +import { EUserPermissions } from "@/plane-web/constants/user-permissions"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; // images import emptyInvitation from "@/public/empty-state/invitation.svg"; @@ -88,7 +91,7 @@ const UserInvitationsPage = observer(() => { captureEvent(MEMBER_ACCEPTED, { member_id: invitation?.id, // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - role: getUserRole(invitation?.role!), + role: getUserRole((invitation?.role as unknown as EUserPermissions)!), project_id: undefined, accepted_from: "App", state: "SUCCESS", diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 231f4da2fa8..648f70357dc 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,20 +1,24 @@ -import { Metadata } from "next"; +import { Metadata, Viewport } 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"; +// meta data info +import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/meta"; +// helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; // 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.", + description: SITE_DESCRIPTION, 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", + description: + "Open-source project management tool to manage issues, sprints, and product roadmaps with peace of mind.", url: "https://app.plane.so/", }, keywords: @@ -24,6 +28,15 @@ export const metadata: Metadata = { }, }; +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + maximumScale: 1, + userScalable: false, + width: "device-width", + viewportFit: "cover", +}; + export default function RootLayout({ children }: { children: React.ReactNode }) { const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0"); @@ -31,16 +44,45 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - + {/* Meta info for PWA */} + + + + + + + + + + + + {/* preloading */} + + + + +
    -
    {children}
    +
    +
    {children}
    +
    {process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && ( @@ -51,7 +93,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {`(function(c,l,a,r,i,t,y){ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; - y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); + y=l.getElementsByTagName(r)[0];if(y){y.parentNode.insertBefore(t,y);} })(window, document, "clarity", "script", "${process.env.NEXT_PUBLIC_SESSION_RECORDER_KEY}");`} )} diff --git a/web/app/onboarding/page.tsx b/web/app/onboarding/page.tsx index b8cd881adff..6d915dfcbce 100644 --- a/web/app/onboarding/page.tsx +++ b/web/app/onboarding/page.tsx @@ -47,9 +47,12 @@ const OnboardingPage = observer(() => { user?.id && fetchWorkspaces(); }); // fetching user workspace invitations - const { isLoading: invitationsLoader, data: invitations } = useSWR("USER_WORKSPACE_INVITATIONS_LIST", () => { - if (user?.id) return workspaceService.userWorkspaceInvitations(); - }); + const { isLoading: invitationsLoader, data: invitations } = useSWR( + `USER_WORKSPACE_INVITATIONS_LIST_${user?.id}`, + () => { + if (user?.id) return workspaceService.userWorkspaceInvitations(); + } + ); // handle step change const stepChange = async (steps: Partial) => { if (!user) return; @@ -103,6 +106,16 @@ const OnboardingPage = observer(() => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [userLoader, workspaceListLoader]); + // If the user completes the profile setup and has workspaces (through invitations), then finish the onboarding. + useEffect(() => { + if (userLoader === false && profile && workspaceListLoader === false) { + const onboardingStep = profile.onboarding_step; + if (onboardingStep.profile_complete && !onboardingStep.workspace_create && workspacesList.length > 0) + finishOnboarding(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userLoader, profile, workspaceListLoader]); + useEffect(() => { const handleStepChange = async () => { if (!user) return; @@ -111,10 +124,10 @@ const OnboardingPage = observer(() => { if (!onboardingStep.profile_complete) setStep(EOnboardingSteps.PROFILE_SETUP); - // For Invited Users, they will skip all other steps. - if (totalSteps && totalSteps <= 2) return; - - if (onboardingStep.profile_complete && !(onboardingStep.workspace_join || onboardingStep.workspace_create)) { + if ( + onboardingStep.profile_complete && + !(onboardingStep.workspace_join || onboardingStep.workspace_create || workspacesList?.length > 0) + ) { setStep(EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN); } @@ -143,7 +156,7 @@ const OnboardingPage = observer(() => { /> ) : step === EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN ? ( { />
    -
    +
    Plane logo diff --git a/web/app/profile/appearance/page.tsx b/web/app/profile/appearance/page.tsx index ef19a3342cf..775ff637b04 100644 --- a/web/app/profile/appearance/page.tsx +++ b/web/app/profile/appearance/page.tsx @@ -52,13 +52,8 @@ const ProfileAppearancePage = 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 - ); + if (theme?.theme === "custom" && theme?.palette) { + applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false); } else unsetCustomCssVariables(); }; diff --git a/web/app/profile/layout.tsx b/web/app/profile/layout.tsx index 1f1b1dff475..21bc6566d8e 100644 --- a/web/app/profile/layout.tsx +++ b/web/app/profile/layout.tsx @@ -19,7 +19,7 @@ export default function ProfileSettingsLayout(props: Props) { <> -
    +
    {children}
    diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index b2ca0b1281b..428be82728c 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -5,29 +5,20 @@ 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"; +// components 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"; +// constants 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 +import { FileService } from "@/services/file.service"; const defaultValues: Partial = { avatar: "", @@ -245,6 +236,7 @@ const ProfileSettingsPage = observer(() => { placeholder="Enter your first name" className={`w-full rounded-md ${errors.first_name ? "border-red-500" : ""}`} maxLength={24} + autoComplete="on" /> )} /> @@ -269,6 +261,7 @@ const ProfileSettingsPage = observer(() => { placeholder="Enter your last name" className="w-full rounded-md" maxLength={24} + autoComplete="on" /> )} /> @@ -296,6 +289,7 @@ const ProfileSettingsPage = observer(() => { className={`w-full cursor-not-allowed rounded-md !bg-custom-background-80 ${ errors.email ? "border-red-500" : "" }`} + autoComplete="on" disabled /> )} @@ -369,7 +363,7 @@ const ProfileSettingsPage = observer(() => { /> )} /> - {errors?.display_name && Please enter display name} + {errors?.display_name && {errors?.display_name?.message}}
    @@ -384,10 +378,9 @@ const ProfileSettingsPage = observer(() => { render={({ field: { value, onChange } }) => ( t.value === value)?.label ?? value : "Select a timezone"} + label={value ? (TIME_ZONES.find((t) => 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 @@ -424,8 +417,7 @@ const ProfileSettingsPage = observer(() => {
    - 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 + When deactivating an account, all of the data and resources within that account will be permanently removed and cannot be recovered.
    diff --git a/web/app/profile/sidebar.tsx b/web/app/profile/sidebar.tsx index 8d4cebbdefb..adb06863d52 100644 --- a/web/app/profile/sidebar.tsx +++ b/web/app/profile/sidebar.tsx @@ -6,13 +6,18 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; // icons import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; // ui import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +// components +import { SidebarNavItem } from "@/components/sidebar"; // constants import { PROFILE_ACTION_LINKS } from "@/constants/profile"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store"; -import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; const WORKSPACE_ACTION_LINKS = [ @@ -115,11 +120,11 @@ export const ProfileLayoutSidebar = observer(() => { )}
    -
    +
    {!sidebarCollapsed && ( -
    Your account
    +
    Your account
    )} -
    +
    {PROFILE_ACTION_LINKS.map((link) => { if (link.key === "change-password" && currentUser?.is_password_autoset) return null; @@ -132,28 +137,33 @@ export const ProfileLayoutSidebar = observer(() => { disabled={!sidebarCollapsed} isMobile={isMobile} > -
    - {} - {!sidebarCollapsed && link.label} -
    +
    + + {!sidebarCollapsed &&

    {link.label}

    } +
    + ); })}
    -
    +
    {!sidebarCollapsed && ( -
    Workspaces
    +
    Workspaces
    )} {workspacesList && workspacesList.length > 0 && ( -
    +
    {workspacesList.map((workspace) => ( { ))}
    )} -
    +
    {WORKSPACE_ACTION_LINKS.map((link) => ( 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 }); +const IntercomProvider = dynamic(() => import("@/lib/intercom-provider"), { ssr: false }); export interface IAppProvider { children: ReactNode; @@ -39,15 +41,15 @@ export const AppProvider: FC = (props) => { - - - + + + {children} - - - + + + diff --git a/web/app/sign-up/page.tsx b/web/app/sign-up/page.tsx index 8bd2e87991c..f08ccbae7b7 100644 --- a/web/app/sign-up/page.tsx +++ b/web/app/sign-up/page.tsx @@ -41,7 +41,7 @@ const SignInPage = observer(() => { />
    -
    +
    Plane logo diff --git a/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx index 3049c013af1..f19ffa809a0 100644 --- a/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx +++ b/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -3,10 +3,10 @@ import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; -// icons -import { Crown } from "lucide-react"; // ui -import { getButtonStyling } from "@plane/ui"; +import { ContentWrapper, getButtonStyling } from "@plane/ui"; +// components +import { ProIcon } from "@/components/common"; // constants import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common"; import { WORKSPACE_ACTIVE_CYCLES_DETAILS } from "@/constants/cycle"; @@ -24,7 +24,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => { const isDarkMode = userProfile?.theme.theme === "dark"; return ( -
    +
    { target="_blank" rel="noreferrer" > - + Upgrade
    @@ -81,7 +81,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
    {WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
    -
    +

    {item.title}

    @@ -89,6 +89,6 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => {
    ))}
    -
    +
    ); }); diff --git a/web/ce/components/cycles/active-cycle/index.ts b/web/ce/components/cycles/active-cycle/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/ce/components/cycles/active-cycle/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/cycles/active-cycle/root.tsx b/web/ce/components/cycles/active-cycle/root.tsx similarity index 59% rename from web/core/components/cycles/active-cycle/root.tsx rename to web/ce/components/cycles/active-cycle/root.tsx index 6294ae3cfac..a173cfda03a 100644 --- a/web/core/components/cycles/active-cycle/root.tsx +++ b/web/ce/components/cycles/active-cycle/root.tsx @@ -1,10 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import useSWR from "swr"; -// ui import { Disclosure } from "@headlessui/react"; -import { Loader } from "@plane/ui"; +// ui +import { Row } from "@plane/ui"; // components import { ActiveCycleProductivity, @@ -13,11 +12,12 @@ import { CycleListGroupHeader, CyclesListItem, } from "@/components/cycles"; +import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -// hooks import { useCycle } from "@/hooks/store"; +import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; interface IActiveCycleDetails { workspaceSlug: string; @@ -25,39 +25,27 @@ interface IActiveCycleDetails { } export const ActiveCycleRoot: React.FC = observer((props) => { - // props const { workspaceSlug, projectId } = props; - // store hooks - const { fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle(); - // derived values - const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; - // fetch active cycle details - const { isLoading } = useSWR( - workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, - workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null - ); - - // show loader if active cycle is loading - if (!activeCycle && isLoading) - return ( - - - - ); + const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle(); + const { + handleFiltersUpdate, + cycle: activeCycle, + cycleIssueDetails, + } = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId }); return ( <> {({ open }) => ( <> - + - {!activeCycle ? ( + {!currentProjectActiveCycle ? ( ) : ( -
    +
    {currentProjectActiveCycleId && ( = observer((props) = className="!border-b-transparent" /> )} -
    +
    - + - +
    -
    +
    )} diff --git a/web/ce/components/cycles/analytics-sidebar/index.ts b/web/ce/components/cycles/analytics-sidebar/index.ts new file mode 100644 index 00000000000..3ba38c61be5 --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/index.ts @@ -0,0 +1 @@ +export * from "./sidebar-chart"; diff --git a/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx b/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx new file mode 100644 index 00000000000..e5b69ef24b1 --- /dev/null +++ b/web/ce/components/cycles/analytics-sidebar/sidebar-chart.tsx @@ -0,0 +1,57 @@ +import { Fragment } from "react"; +import { TCycleDistribution, TCycleEstimateDistribution } from "@plane/types"; +import { Loader } from "@plane/ui"; +import ProgressChart from "@/components/core/sidebar/progress-chart"; + +type ProgressChartProps = { + chartDistributionData: TCycleEstimateDistribution | TCycleDistribution | undefined; + cycleStartDate: Date | undefined; + cycleEndDate: Date | undefined; + totalEstimatePoints: number; + totalIssues: number; + plotType: string; +}; +export const SidebarBaseChart = (props: ProgressChartProps) => { + const { chartDistributionData, cycleStartDate, cycleEndDate, totalEstimatePoints, totalIssues, plotType } = props; + const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + + return ( +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    + {cycleStartDate && cycleEndDate && completionChartDistributionData ? ( + + {plotType === "points" ? ( + + ) : ( + + )} + + ) : ( + + + + )} +
    + ); +}; diff --git a/web/ce/components/cycles/index.ts b/web/ce/components/cycles/index.ts new file mode 100644 index 00000000000..89934687567 --- /dev/null +++ b/web/ce/components/cycles/index.ts @@ -0,0 +1,2 @@ +export * from "./active-cycle"; +export * from "./analytics-sidebar"; diff --git a/web/ce/components/estimates/estimate-list-item-buttons.tsx b/web/ce/components/estimates/estimate-list-item-buttons.tsx index 6911754f8cc..72acb4dfc56 100644 --- a/web/ce/components/estimates/estimate-list-item-buttons.tsx +++ b/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -1,7 +1,9 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { Crown, Pen, Trash } from "lucide-react"; +import { Pen, Trash } from "lucide-react"; import { Tooltip } from "@plane/ui"; +// components +import { ProIcon } from "@/components/common"; type TEstimateListItem = { estimateId: string; @@ -22,7 +24,7 @@ export const EstimateListItemButtons: FC = observer((props) = tooltipContent={
    Upgrade
    - +
    } position="top" diff --git a/web/ce/components/global/index.ts b/web/ce/components/global/index.ts index 08b85c764c0..2d8930e19e1 100644 --- a/web/ce/components/global/index.ts +++ b/web/ce/components/global/index.ts @@ -1 +1,3 @@ export * from "./version-number"; +export * from "./product-updates"; +export * from "./product-updates-modal"; diff --git a/web/ce/components/global/product-updates-modal.tsx b/web/ce/components/global/product-updates-modal.tsx new file mode 100644 index 00000000000..da279583502 --- /dev/null +++ b/web/ce/components/global/product-updates-modal.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; + +export type ProductUpdatesModalProps = { + isOpen: boolean; + handleClose: () => void; +}; + +export const ProductUpdatesModal: FC = observer(() => <>); diff --git a/web/ce/components/global/product-updates.tsx b/web/ce/components/global/product-updates.tsx new file mode 100644 index 00000000000..700883574c0 --- /dev/null +++ b/web/ce/components/global/product-updates.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +// ui +import { CustomMenu } from "@plane/ui"; + +export type ProductUpdatesProps = { + setIsChangeLogOpen: (isOpen: boolean) => void; +}; + +export const ProductUpdates: FC = observer(() => ( + + + What's new + + +)); diff --git a/web/ce/components/issue-types/index.ts b/web/ce/components/issue-types/index.ts new file mode 100644 index 00000000000..11413e4c194 --- /dev/null +++ b/web/ce/components/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./values"; diff --git a/web/ce/components/issue-types/values/index.ts b/web/ce/components/issue-types/values/index.ts new file mode 100644 index 00000000000..635be6440d2 --- /dev/null +++ b/web/ce/components/issue-types/values/index.ts @@ -0,0 +1 @@ +export * from "./update"; diff --git a/web/ce/components/issue-types/values/update.tsx b/web/ce/components/issue-types/values/update.tsx new file mode 100644 index 00000000000..cff391d9ea9 --- /dev/null +++ b/web/ce/components/issue-types/values/update.tsx @@ -0,0 +1,9 @@ +export type TIssueAdditionalPropertyValuesUpdateProps = { + issueId: string; + issueTypeId: string; + projectId: string; + workspaceSlug: string; + isDisabled: boolean; +}; + +export const IssueAdditionalPropertyValuesUpdate: React.FC = () => <>; diff --git a/web/ce/components/issues/filters/applied-filters/index.ts b/web/ce/components/issues/filters/applied-filters/index.ts new file mode 100644 index 00000000000..592325823c5 --- /dev/null +++ b/web/ce/components/issues/filters/applied-filters/index.ts @@ -0,0 +1 @@ +export * from "./issue-types"; diff --git a/web/ce/components/issues/filters/applied-filters/issue-types.tsx b/web/ce/components/issues/filters/applied-filters/issue-types.tsx new file mode 100644 index 00000000000..fd2daf9c822 --- /dev/null +++ b/web/ce/components/issues/filters/applied-filters/issue-types.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { observer } from "mobx-react"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedIssueTypeFilters: React.FC = observer(() => null); diff --git a/web/ce/components/issues/filters/index.ts b/web/ce/components/issues/filters/index.ts new file mode 100644 index 00000000000..2cd80e3a7e5 --- /dev/null +++ b/web/ce/components/issues/filters/index.ts @@ -0,0 +1,2 @@ +export * from "./applied-filters"; +export * from "./issue-types"; diff --git a/web/ce/components/issues/filters/issue-types.tsx b/web/ce/components/issues/filters/issue-types.tsx new file mode 100644 index 00000000000..bc364c8f808 --- /dev/null +++ b/web/ce/components/issues/filters/issue-types.tsx @@ -0,0 +1,12 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterIssueTypes: React.FC = observer(() => null); diff --git a/web/ce/components/issues/index.ts b/web/ce/components/issues/index.ts index 7a5275abe18..97b57af4b0c 100644 --- a/web/ce/components/issues/index.ts +++ b/web/ce/components/issues/index.ts @@ -1 +1,6 @@ -export * from "./bulk-operations"; \ No newline at end of file +export * from "./bulk-operations"; +export * from "./worklog"; +export * from "./issue-modal"; +export * from "./issue-details"; +export * from "./quick-add"; +export * from "./filters"; diff --git a/web/ce/components/issues/issue-details/index.ts b/web/ce/components/issues/issue-details/index.ts new file mode 100644 index 00000000000..3979faf9488 --- /dev/null +++ b/web/ce/components/issues/issue-details/index.ts @@ -0,0 +1,4 @@ +export * from "./issue-identifier"; +export * from "./issue-properties-activity"; +export * from "./issue-type-switcher"; +export * from "./issue-type-activity"; diff --git a/web/ce/components/issues/issue-details/issue-identifier.tsx b/web/ce/components/issues/issue-details/issue-identifier.tsx new file mode 100644 index 00000000000..c461e88fa31 --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -0,0 +1,95 @@ +import { observer } from "mobx-react"; +// types +import { IIssueDisplayProperties } from "@plane/types"; +// ui +import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useIssueDetail, useProject } from "@/hooks/store"; + +type TIssueIdentifierBaseProps = { + projectId: string; + size?: "xs" | "sm" | "md" | "lg"; + textContainerClassName?: string; + displayProperties?: IIssueDisplayProperties | undefined; + enableClickToCopyIdentifier?: boolean; +}; + +type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & { + issueId: string; +}; + +type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & { + issueTypeId?: string | null; + projectIdentifier: string; + issueSequenceId: string | number; +}; + +export type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails; + +type TIdentifierTextProps = { + identifier: string; + enableClickToCopyIdentifier?: boolean; + textContainerClassName?: string; +}; + +export const IdentifierText: React.FC = (props) => { + const { identifier, enableClickToCopyIdentifier = false, textContainerClassName } = props; + // handlers + const handleCopyIssueIdentifier = () => { + if (enableClickToCopyIdentifier) { + navigator.clipboard.writeText(identifier).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Issue ID copied to clipboard", + }); + }); + } + }; + + return ( + + + {identifier} + + + ); +}; + +export const IssueIdentifier: React.FC = observer((props) => { + const { projectId, textContainerClassName, displayProperties, enableClickToCopyIdentifier = false } = props; + // store hooks + const { getProjectIdentifierById } = useProject(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + // Determine if the component is using store data or not + const isUsingStoreData = "issueId" in props; + // derived values + const issue = isUsingStoreData ? getIssueById(props.issueId) : null; + const projectIdentifier = isUsingStoreData ? getProjectIdentifierById(projectId) : props.projectIdentifier; + const issueSequenceId = isUsingStoreData ? issue?.sequence_id : props.issueSequenceId; + const shouldRenderIssueID = displayProperties ? displayProperties.key : true; + + if (!shouldRenderIssueID) return null; + + return ( +
    + +
    + ); +}); diff --git a/web/ce/components/issues/issue-details/issue-properties-activity/index.ts b/web/ce/components/issues/issue-details/issue-properties-activity/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-properties-activity/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx b/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx new file mode 100644 index 00000000000..cac7556764e --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx @@ -0,0 +1,8 @@ +import { FC } from "react"; + +type TIssueAdditionalPropertiesActivity = { + activityId: string; + ends: "top" | "bottom" | undefined; +}; + +export const IssueAdditionalPropertiesActivity: FC = () => <>; diff --git a/web/ce/components/issues/issue-details/issue-type-activity.tsx b/web/ce/components/issues/issue-details/issue-type-activity.tsx new file mode 100644 index 00000000000..8def50f48c1 --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-type-activity.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; + +export type TIssueTypeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; + +export const IssueTypeActivity: FC = observer(() => <>); diff --git a/web/ce/components/issues/issue-details/issue-type-switcher.tsx b/web/ce/components/issues/issue-details/issue-type-switcher.tsx new file mode 100644 index 00000000000..5d4adeb9558 --- /dev/null +++ b/web/ce/components/issues/issue-details/issue-type-switcher.tsx @@ -0,0 +1,24 @@ +import { observer } from "mobx-react"; +// store hooks +import { useIssueDetail } from "@/hooks/store"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues"; + +export type TIssueTypeSwitcherProps = { + issueId: string; + disabled: boolean; +}; + +export const IssueTypeSwitcher: React.FC = observer((props) => { + const { issueId } = props; + // store hooks + const { + issue: { getIssueById }, + } = useIssueDetail(); + // derived values + const issue = getIssueById(issueId); + + if (!issue || !issue.project_id) return <>; + + return ; +}); diff --git a/web/ce/components/issues/issue-modal/additional-properties.tsx b/web/ce/components/issues/issue-modal/additional-properties.tsx new file mode 100644 index 00000000000..228ab51e852 --- /dev/null +++ b/web/ce/components/issues/issue-modal/additional-properties.tsx @@ -0,0 +1,8 @@ +type TIssueAdditionalPropertiesProps = { + issueId: string | undefined; + issueTypeId: string | null; + projectId: string; + workspaceSlug: string; +}; + +export const IssueAdditionalProperties: React.FC = () => <>; diff --git a/web/ce/components/issues/issue-modal/index.ts b/web/ce/components/issues/issue-modal/index.ts new file mode 100644 index 00000000000..f2c8494163f --- /dev/null +++ b/web/ce/components/issues/issue-modal/index.ts @@ -0,0 +1,3 @@ +export * from "./provider"; +export * from "./issue-type-select"; +export * from "./additional-properties"; diff --git a/web/ce/components/issues/issue-modal/issue-type-select.tsx b/web/ce/components/issues/issue-modal/issue-type-select.tsx new file mode 100644 index 00000000000..9514cb78f5b --- /dev/null +++ b/web/ce/components/issues/issue-modal/issue-type-select.tsx @@ -0,0 +1,23 @@ +import { Control } from "react-hook-form"; +// types +import { TBulkIssueProperties, TIssue } from "@plane/types"; + +export type TIssueFields = TIssue & TBulkIssueProperties; + +export type TIssueTypeDropdownVariant = "xs" | "sm"; + +export type TIssueTypeSelectProps> = { + control: Control; + projectId: string | null; + disabled?: boolean; + variant?: TIssueTypeDropdownVariant; + placeholder?: string; + isRequired?: boolean; + renderChevron?: boolean; + dropDownContainerClassName?: string; + showMandatoryFieldInfo?: boolean; // Show info about mandatory fields + handleFormChange?: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const IssueTypeSelect = >(props: TIssueTypeSelectProps) => <>; diff --git a/web/ce/components/issues/issue-modal/provider.tsx b/web/ce/components/issues/issue-modal/provider.tsx new file mode 100644 index 00000000000..f387feb5a82 --- /dev/null +++ b/web/ce/components/issues/issue-modal/provider.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +// components +import { IssueModalContext } from "@/components/issues"; + +type TIssueModalProviderProps = { + children: React.ReactNode; +}; + +export const IssueModalProvider = observer((props: TIssueModalProviderProps) => { + const { children } = props; + return ( + {}, + issuePropertyValueErrors: {}, + setIssuePropertyValueErrors: () => {}, + getIssueTypeIdOnProjectChange: () => null, + getActiveAdditionalPropertiesLength: () => 0, + handlePropertyValuesValidation: () => true, + handleCreateUpdatePropertyValues: () => Promise.resolve(), + }} + > + {children} + + ); +}); diff --git a/web/ce/components/issues/quick-add/index.ts b/web/ce/components/issues/quick-add/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/ce/components/issues/quick-add/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/issues/quick-add/root.tsx b/web/ce/components/issues/quick-add/root.tsx new file mode 100644 index 00000000000..51880a6ba1b --- /dev/null +++ b/web/ce/components/issues/quick-add/root.tsx @@ -0,0 +1,75 @@ +import { FC, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import { UseFormRegister, UseFormSetFocus } from "react-hook-form"; +// plane helpers +import { useOutsideClickDetector } from "@plane/helpers"; +// types +import { TIssue } from "@plane/types"; +// components +import { + CalendarQuickAddIssueForm, + GanttQuickAddIssueForm, + KanbanQuickAddIssueForm, + ListQuickAddIssueForm, + SpreadsheetQuickAddIssueForm, + TQuickAddIssueForm, +} from "@/components/issues/issue-layouts"; +// constants +import { EIssueLayoutTypes } from "@/constants/issue"; +// hooks +import { useProject } from "@/hooks/store"; +import useKeypress from "@/hooks/use-keypress"; + +export type TQuickAddIssueFormRoot = { + isOpen: boolean; + layout: EIssueLayoutTypes; + prePopulatedData?: Partial; + projectId: string; + hasError?: boolean; + setFocus: UseFormSetFocus; + register: UseFormRegister; + onSubmit: () => void; + onClose: () => void; +}; + +export const QuickAddIssueFormRoot: FC = observer((props) => { + const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose } = props; + // store hooks + const { getProjectById } = useProject(); + // derived values + const projectDetail = getProjectById(projectId); + // refs + const ref = useRef(null); + // click detection + useKeypress("Escape", onClose); + useOutsideClickDetector(ref, onClose); + // set focus on name input + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + if (!projectDetail) return <>; + + const QUICK_ADD_ISSUE_FORMS: Record> = { + [EIssueLayoutTypes.LIST]: ListQuickAddIssueForm, + [EIssueLayoutTypes.KANBAN]: KanbanQuickAddIssueForm, + [EIssueLayoutTypes.CALENDAR]: CalendarQuickAddIssueForm, + [EIssueLayoutTypes.GANTT]: GanttQuickAddIssueForm, + [EIssueLayoutTypes.SPREADSHEET]: SpreadsheetQuickAddIssueForm, + }; + + const CurrentLayoutQuickAddIssueForm = QUICK_ADD_ISSUE_FORMS[layout] ?? null; + + if (!CurrentLayoutQuickAddIssueForm) return <>; + + return ( + + ); +}); diff --git a/web/ce/components/issues/worklog/activity/filter-root.tsx b/web/ce/components/issues/worklog/activity/filter-root.tsx new file mode 100644 index 00000000000..2d11ae34148 --- /dev/null +++ b/web/ce/components/issues/worklog/activity/filter-root.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { FC } from "react"; +// components +import { ActivityFilter } from "@/components/issues"; +// plane web constants +import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption } from "@/plane-web/constants/issues"; + +export type TActivityFilterRoot = { + selectedFilters: TActivityFilters[]; + toggleFilter: (filter: TActivityFilters) => void; + projectId: string; + isIntakeIssue?: boolean; +}; + +export const ActivityFilterRoot: FC = (props) => { + const { selectedFilters, toggleFilter } = props; + + const filters: TActivityFilterOption[] = Object.entries(ACTIVITY_FILTER_TYPE_OPTIONS).map(([key, value]) => { + const filterKey = key as TActivityFilters; + return { + key: filterKey, + label: value.label, + isSelected: selectedFilters.includes(filterKey), + onClick: () => toggleFilter(filterKey), + }; + }); + + return ; +}; diff --git a/web/ce/components/issues/worklog/activity/index.ts b/web/ce/components/issues/worklog/activity/index.ts new file mode 100644 index 00000000000..0c803acab8a --- /dev/null +++ b/web/ce/components/issues/worklog/activity/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./worklog-create-button"; + +export * from "./filter-root"; diff --git a/web/ce/components/issues/worklog/activity/root.tsx b/web/ce/components/issues/worklog/activity/root.tsx new file mode 100644 index 00000000000..0342999d3d0 --- /dev/null +++ b/web/ce/components/issues/worklog/activity/root.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { FC } from "react"; +import { TIssueActivityComment } from "@plane/types"; + +type TIssueActivityWorklog = { + workspaceSlug: string; + projectId: string; + issueId: string; + activityComment: TIssueActivityComment; + ends?: "top" | "bottom"; +}; + +export const IssueActivityWorklog: FC = () => <>; diff --git a/web/ce/components/issues/worklog/activity/worklog-create-button.tsx b/web/ce/components/issues/worklog/activity/worklog-create-button.tsx new file mode 100644 index 00000000000..3a57b53df49 --- /dev/null +++ b/web/ce/components/issues/worklog/activity/worklog-create-button.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { FC } from "react"; + +type TIssueActivityWorklogCreateButton = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +export const IssueActivityWorklogCreateButton: FC = () => <>; diff --git a/web/ce/components/issues/worklog/index.ts b/web/ce/components/issues/worklog/index.ts new file mode 100644 index 00000000000..c0ed33ebf8a --- /dev/null +++ b/web/ce/components/issues/worklog/index.ts @@ -0,0 +1,2 @@ +export * from "./property"; +export * from "./activity"; diff --git a/web/ce/components/issues/worklog/property/index.ts b/web/ce/components/issues/worklog/property/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/ce/components/issues/worklog/property/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/issues/worklog/property/root.tsx b/web/ce/components/issues/worklog/property/root.tsx new file mode 100644 index 00000000000..5ccc9ebaadd --- /dev/null +++ b/web/ce/components/issues/worklog/property/root.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { FC } from "react"; + +type TIssueWorklogProperty = { + workspaceSlug: string; + projectId: string; + issueId: string; + disabled: boolean; +}; + +export const IssueWorklogProperty: FC = () => <>; diff --git a/web/ce/components/pages/editor/ai/ask-pi-menu.tsx b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx new file mode 100644 index 00000000000..d3440ea4798 --- /dev/null +++ b/web/ce/components/pages/editor/ai/ask-pi-menu.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { CircleArrowUp, CornerDownRight, RefreshCcw, Sparkles } from "lucide-react"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { RichTextReadOnlyEditor } from "@/components/editor"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + handleInsertText: (insertOnNextLine: boolean) => void; + handleRegenerate: () => Promise; + isRegenerating: boolean; + response: string | undefined; +}; + +export const AskPiMenu: React.FC = (props) => { + const { handleInsertText, handleRegenerate, isRegenerating, response } = props; + // states + const [query, setQuery] = useState(""); + + return ( + <> +
    + + + + {response ? ( +
    + +
    + + + + + + + +
    +
    + ) : ( +

    Pi is answering...

    + )} +
    +
    +
    + + + + setQuery(e.target.value)} + placeholder="Tell Pi what to do..." + /> + + + +
    +
    + + ); +}; diff --git a/web/ce/components/pages/editor/ai/index.ts b/web/ce/components/pages/editor/ai/index.ts new file mode 100644 index 00000000000..d21eb63d70b --- /dev/null +++ b/web/ce/components/pages/editor/ai/index.ts @@ -0,0 +1,2 @@ +export * from "./ask-pi-menu"; +export * from "./menu"; diff --git a/web/ce/components/pages/editor/ai/menu.tsx b/web/ce/components/pages/editor/ai/menu.tsx new file mode 100644 index 00000000000..edbed725df9 --- /dev/null +++ b/web/ce/components/pages/editor/ai/menu.tsx @@ -0,0 +1,299 @@ +"use client"; + +import React, { RefObject, useEffect, useRef, useState } from "react"; +import { useParams } from "next/navigation"; +import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; +// plane editor +import { EditorRefApi } from "@plane/editor"; +// plane ui +import { Tooltip } from "@plane/ui"; +// components +import { RichTextReadOnlyEditor } from "@/components/editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +// plane web constants +import { AI_EDITOR_TASKS, LOADING_TEXTS } from "@/plane-web/constants/ai"; +// plane web services +import { AIService, TTaskPayload } from "@/services/ai.service"; +import { AskPiMenu } from "./ask-pi-menu"; +const aiService = new AIService(); + +type Props = { + editorRef: RefObject; + isOpen: boolean; + onClose: () => void; +}; + +const MENU_ITEMS: { + icon: LucideIcon; + key: AI_EDITOR_TASKS; + label: string; +}[] = [ + { + key: AI_EDITOR_TASKS.ASK_ANYTHING, + icon: Sparkles, + label: "Ask Pi", + }, +]; + +const TONES_LIST = [ + { + key: "default", + label: "Default", + casual_score: 5, + formal_score: 5, + }, + { + key: "professional", + label: "💼 Professional", + casual_score: 0, + formal_score: 10, + }, + { + key: "casual", + label: "😃 Casual", + casual_score: 10, + formal_score: 0, + }, +]; + +export const EditorAIMenu: React.FC = (props) => { + const { editorRef, isOpen, onClose } = props; + // states + const [activeTask, setActiveTask] = useState(null); + const [response, setResponse] = useState(undefined); + const [isRegenerating, setIsRegenerating] = useState(false); + // refs + const responseContainerRef = useRef(null); + // params + const { workspaceSlug } = useParams(); + const handleGenerateResponse = async (payload: TTaskPayload) => { + if (!workspaceSlug) return; + await aiService.performEditorTask(workspaceSlug.toString(), payload).then((res) => setResponse(res.response)); + }; + // handle task click + const handleClick = async (key: AI_EDITOR_TASKS) => { + const selection = editorRef.current?.getSelectedText(); + if (!selection || activeTask === key) return; + setActiveTask(key); + if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return; + setResponse(undefined); + setIsRegenerating(false); + await handleGenerateResponse({ + task: key, + text_input: selection, + }); + }; + // handle re-generate response + const handleRegenerate = async () => { + const selection = editorRef.current?.getSelectedText(); + if (!selection || !activeTask) return; + setIsRegenerating(true); + await handleGenerateResponse({ + task: activeTask, + text_input: selection, + }) + .then(() => + responseContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }) + ) + .finally(() => setIsRegenerating(false)); + }; + // handle re-generate response + const handleToneChange = async (key: string) => { + const selectedTone = TONES_LIST.find((t) => t.key === key); + const selection = editorRef.current?.getSelectedText(); + if (!selectedTone || !selection || !activeTask) return; + setResponse(undefined); + setIsRegenerating(false); + await handleGenerateResponse({ + casual_score: selectedTone.casual_score, + formal_score: selectedTone.formal_score, + task: activeTask, + text_input: selection, + }).then(() => + responseContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }) + ); + }; + // handle replace selected text with the response + const handleInsertText = (insertOnNextLine: boolean) => { + if (!response) return; + editorRef.current?.insertText(response, insertOnNextLine); + onClose(); + }; + + // reset on close + useEffect(() => { + if (!isOpen) { + setActiveTask(null); + setResponse(undefined); + } + }, [isOpen]); + + return ( +
    +
    +
    + {MENU_ITEMS.map((item) => { + const isActiveTask = activeTask === item.key; + + return ( + + ); + })} +
    +
    + {activeTask === AI_EDITOR_TASKS.ASK_ANYTHING ? ( + + ) : ( + <> +
    + + + + {response ? ( +
    + +
    + + + + + + + +
    +
    + ) : ( +

    + {activeTask ? LOADING_TEXTS[activeTask] : "Pi is writing"}... +

    + )} +
    +
    + {TONES_LIST.map((tone) => ( + + ))} +
    + + )} +
    +
    + {activeTask && ( +
    + + + +

    + By using this feature, you consent to sharing the message with a 3rd party service. +

    +
    + )} +
    + ); +}; diff --git a/web/ce/components/pages/editor/embed/index.ts b/web/ce/components/pages/editor/embed/index.ts index f30596cb00f..e16822834a5 100644 --- a/web/ce/components/pages/editor/embed/index.ts +++ b/web/ce/components/pages/editor/embed/index.ts @@ -1 +1 @@ -export * from "./issue-embed"; +export * from "./issue-embed-upgrade-card"; diff --git a/web/ce/components/pages/editor/embed/issue-embed.tsx b/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx similarity index 86% rename from web/ce/components/pages/editor/embed/issue-embed.tsx rename to web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx index dc06f4f9d85..ce3db73893e 100644 --- a/web/ce/components/pages/editor/embed/issue-embed.tsx +++ b/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx @@ -1,8 +1,9 @@ -import { Crown } from "lucide-react"; // ui import { Button } from "@plane/ui"; +// components +import { ProIcon } from "@/components/common"; -export const IssueEmbedCard: React.FC = (props) => ( +export const IssueEmbedUpgradeCard: React.FC = (props) => (
    = (props) => (
    - +
    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 index 12b3c5295ba..88b26fa277f 100644 --- a/web/ce/components/pages/editor/index.ts +++ b/web/ce/components/pages/editor/index.ts @@ -1 +1,2 @@ +export * from "./ai"; export * from "./embed"; diff --git a/web/ce/components/projects/create/attributes.tsx b/web/ce/components/projects/create/attributes.tsx new file mode 100644 index 00000000000..178275641c4 --- /dev/null +++ b/web/ce/components/projects/create/attributes.tsx @@ -0,0 +1,94 @@ +"use client"; +import { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { IProject } from "@plane/types"; +// ui +import { CustomSelect } from "@plane/ui"; +// components +import { MemberDropdown } from "@/components/dropdowns"; +// constants +import { NETWORK_CHOICES } from "@/constants/project"; +import { ETabIndices } from "@/constants/tab-indices"; +// helpers +import { getTabIndex } from "@/helpers/tab-indices.helper"; + +type Props = { + isMobile?: boolean; +}; + +const ProjectAttributes: FC = (props) => { + const { isMobile = false } = props; + const { control } = useFormContext(); + const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile); + return ( +
    + { + const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value); + + return ( +
    + + {currentNetwork ? ( + <> + + {currentNetwork.label} + + ) : ( + Select network + )} +
    + } + placement="bottom-start" + className="h-full" + buttonClassName="h-full" + noChevron + tabIndex={getIndex("network")} + > + {NETWORK_CHOICES.map((network) => ( + +
    + +
    +

    {network.label}

    +

    {network.description}

    +
    +
    +
    + ))} + +
    + ); + }} + /> + { + if (value === undefined || value === null || typeof value === "string") + return ( +
    + onChange(lead === value ? null : lead)} + placeholder="Lead" + multiple={false} + buttonVariant="border-with-text" + tabIndex={5} + /> +
    + ); + else return <>; + }} + /> +
    + ); +}; + +export default ProjectAttributes; diff --git a/web/ce/components/projects/create/root.tsx b/web/ce/components/projects/create/root.tsx new file mode 100644 index 00000000000..5d6741418da --- /dev/null +++ b/web/ce/components/projects/create/root.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState, FC } from "react"; +import { observer } from "mobx-react"; +import { FormProvider, useForm } from "react-hook-form"; +// ui +import { setToast, TOAST_TYPE } from "@plane/ui"; +// constants +import ProjectCommonAttributes from "@/components/project/create/common-attributes"; +import ProjectCreateHeader from "@/components/project/create/header"; +import ProjectCreateButtons from "@/components/project/create/project-create-buttons"; +import { PROJECT_CREATED } from "@/constants/event-tracker"; +import { PROJECT_UNSPLASH_COVERS } from "@/constants/project"; +// helpers +import { getRandomEmoji } from "@/helpers/emoji.helper"; +// hooks +import { useEventTracker, useProject } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { TProject } from "@/plane-web/types/projects"; +import ProjectAttributes from "./attributes"; + +type Props = { + setToFavorite?: boolean; + workspaceSlug: string; + onClose: () => void; + handleNextStep: (projectId: string) => void; + data?: Partial; +}; + +const defaultValues: Partial = { + cover_image: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)], + description: "", + logo_props: { + in_use: "emoji", + emoji: { + value: getRandomEmoji(), + }, + }, + identifier: "", + name: "", + network: 2, + project_lead: null, +}; + +export const CreateProjectForm: FC = observer((props) => { + const { setToFavorite, workspaceSlug, onClose, handleNextStep } = props; + // store + const { captureProjectEvent } = useEventTracker(); + const { addProjectToFavorites, createProject } = useProject(); + // states + const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); + // form info + const methods = useForm({ + defaultValues, + reValidateMode: "onChange", + }); + const { handleSubmit, reset, setValue } = methods; + const { isMobile } = usePlatformOS(); + const handleAddToFavorites = (projectId: string) => { + if (!workspaceSlug) return; + + addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Couldn't remove the project from favorites. Please try again.", + }); + }); + }; + + const onSubmit = async (formData: Partial) => { + // Upper case identifier + formData.identifier = formData.identifier?.toUpperCase(); + + return createProject(workspaceSlug.toString(), formData) + .then((res) => { + const newPayload = { + ...res, + state: "SUCCESS", + }; + captureProjectEvent({ + eventName: PROJECT_CREATED, + payload: newPayload, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Project created successfully.", + }); + if (setToFavorite) { + handleAddToFavorites(res.id); + } + handleNextStep(res.id); + }) + .catch((err) => { + Object.keys(err.data).map((key) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err.data[key], + }); + captureProjectEvent({ + eventName: PROJECT_CREATED, + payload: { + ...formData, + state: "FAILED", + }, + }); + }); + }); + }; + + const handleClose = () => { + onClose(); + setIsChangeInIdentifierRequired(true); + setTimeout(() => { + reset(); + }, 300); + }; + + return ( + + + +
    +
    + + +
    + + +
    + ); +}); diff --git a/web/ce/components/projects/header.tsx b/web/ce/components/projects/header.tsx new file mode 100644 index 00000000000..08871ec9b6c --- /dev/null +++ b/web/ce/components/projects/header.tsx @@ -0,0 +1,5 @@ +"use client"; + +import { ProjectsBaseHeader } from "@/components/project/header"; + +export const ProjectsListHeader = () => ; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx b/web/ce/components/projects/mobile-header.tsx similarity index 99% rename from web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx rename to web/ce/components/projects/mobile-header.tsx index cd8eb9dfe08..3804721600e 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(list)/mobile-header.tsx +++ b/web/ce/components/projects/mobile-header.tsx @@ -1,3 +1,4 @@ +"use client"; import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; @@ -23,7 +24,6 @@ export const ProjectsListMobileHeader = observer(() => { updateFilters, } = useProjectFilter(); - const { workspace: { workspaceMemberIds }, } = useMember(); diff --git a/web/ce/components/projects/page.tsx b/web/ce/components/projects/page.tsx new file mode 100644 index 00000000000..a44ab7df40c --- /dev/null +++ b/web/ce/components/projects/page.tsx @@ -0,0 +1,3 @@ +import Root from "@/components/project/root"; + +export const ProjectPageRoot = () => ; diff --git a/web/ce/components/projects/settings/useProjectColumns.tsx b/web/ce/components/projects/settings/useProjectColumns.tsx new file mode 100644 index 00000000000..8103e9eefaf --- /dev/null +++ b/web/ce/components/projects/settings/useProjectColumns.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { IWorkspaceMember } from "@plane/types"; +import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns"; +import { useUser, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; + +export interface RowData { + member: IWorkspaceMember; + role: EUserPermissions; +} + +export const useProjectColumns = () => { + // states + const [removeMemberModal, setRemoveMemberModal] = useState(null); + + const { workspaceSlug, projectId } = useParams(); + + const { data: currentUser } = useUser(); + const { allowPermissions, projectUserInfo } = useUserPermissions(); + + const currentProjectRole = + (projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()]?.role as unknown as EUserPermissions) ?? + EUserPermissions.GUEST; + + const getFormattedDate = (dateStr: string) => { + const date = new Date(dateStr); + + const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; + return date.toLocaleDateString("en-US", options); + }; + // derived values + const isAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug.toString(), + projectId.toString() + ); + + const columns = [ + { + key: "Full Name", + content: "Full name", + thClassName: "text-left", + tdRender: (rowData: RowData) => ( + + ), + }, + { + key: "Display Name", + content: "Display name", + tdRender: (rowData: RowData) =>
    {rowData.member.display_name}
    , + }, + + { + key: "Account Type", + content: "Account type", + tdRender: (rowData: RowData) => ( + + ), + }, + { + key: "Joining Date", + content: "Joining date", + tdRender: (rowData: RowData) =>
    {getFormattedDate(rowData?.member?.joining_date || "")}
    , + }, + ]; + return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal }; +}; diff --git a/web/ce/components/views/publish/index.ts b/web/ce/components/views/publish/index.ts new file mode 100644 index 00000000000..8c04a4e3d8e --- /dev/null +++ b/web/ce/components/views/publish/index.ts @@ -0,0 +1,2 @@ +export * from "./modal"; +export * from "./use-view-publish"; diff --git a/web/ce/components/views/publish/modal.tsx b/web/ce/components/views/publish/modal.tsx new file mode 100644 index 00000000000..0951de0930d --- /dev/null +++ b/web/ce/components/views/publish/modal.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { IProjectView } from "@plane/types"; + +type Props = { + isOpen: boolean; + view: IProjectView; + onClose: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const PublishViewModal = (props: Props) => <>; diff --git a/web/ce/components/views/publish/use-view-publish.tsx b/web/ce/components/views/publish/use-view-publish.tsx new file mode 100644 index 00000000000..687a79ed762 --- /dev/null +++ b/web/ce/components/views/publish/use-view-publish.tsx @@ -0,0 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useViewPublish = (isPublished: boolean, isAuthorized: boolean) => ({ + isPublishModalOpen: false, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setPublishModalOpen: (value: boolean) => {}, + publishContextMenu: undefined, +}); diff --git a/web/ce/components/workspace/billing/index.ts b/web/ce/components/workspace/billing/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/ce/components/workspace/billing/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/workspace/billing/root.tsx b/web/ce/components/workspace/billing/root.tsx new file mode 100644 index 00000000000..3a6d6c45cb4 --- /dev/null +++ b/web/ce/components/workspace/billing/root.tsx @@ -0,0 +1,23 @@ +// ui +import { Button } from "@plane/ui"; +// constants +import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common"; + +export const BillingRoot = () => ( +
    +
    +
    +

    Billing and Plans

    +
    +
    +
    +
    +

    Current plan

    +

    You are currently using the free plan

    + + + +
    +
    +
    +); diff --git a/web/ce/components/workspace/delete-workspace-section.tsx b/web/ce/components/workspace/delete-workspace-section.tsx new file mode 100644 index 00000000000..93836284f8f --- /dev/null +++ b/web/ce/components/workspace/delete-workspace-section.tsx @@ -0,0 +1,58 @@ +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +// types +import { IWorkspace } from "@plane/types"; +// ui +import { Button, Collapsible } from "@plane/ui"; +// components +import { DeleteWorkspaceModal } from "@/components/workspace"; + +type TDeleteWorkspace = { + workspace: IWorkspace | null; +}; + +export const DeleteWorkspaceSection: FC = observer((props) => { + const { workspace } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false); + + return ( + <> + setDeleteWorkspaceModal(false)} + /> +
    +
    + setIsOpen(!isOpen)} + className="w-full" + buttonClassName="flex w-full items-center justify-between py-4" + title={ + <> + Delete workspace + {isOpen ? : } + + } + > +
    + + When deleting a workspace, all of the data and resources within that workspace will be permanently + removed and cannot be recovered. + +
    + +
    +
    +
    +
    +
    + + ); +}); diff --git a/web/ce/components/workspace/edition-badge.tsx b/web/ce/components/workspace/edition-badge.tsx index 5b81bae7856..e1c3d1c1ddb 100644 --- a/web/ce/components/workspace/edition-badge.tsx +++ b/web/ce/components/workspace/edition-badge.tsx @@ -1,19 +1,35 @@ +import { useState } from "react"; import { observer } from "mobx-react"; // ui -import { Tooltip } from "@plane/ui"; +import { Button, Tooltip } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // assets import packageJson from "package.json"; +// local components +import { PaidPlanUpgradeModal } from "./upgrade"; export const WorkspaceEditionBadge = observer(() => { const { isMobile } = usePlatformOS(); + // states + const [isPaidPlanPurchaseModalOpen, setIsPaidPlanPurchaseModalOpen] = useState(false); return ( - -
    - Community -
    -
    + <> + setIsPaidPlanPurchaseModalOpen(false)} + /> + + + + ); }); diff --git a/web/ce/components/workspace/index.ts b/web/ce/components/workspace/index.ts index d373164c53e..94a16694754 100644 --- a/web/ce/components/workspace/index.ts +++ b/web/ce/components/workspace/index.ts @@ -1 +1,4 @@ export * from "./edition-badge"; +export * from "./upgrade-badge"; +export * from "./billing"; +export * from "./delete-workspace-section"; diff --git a/web/ce/components/workspace/settings/useMemberColumns.tsx b/web/ce/components/workspace/settings/useMemberColumns.tsx new file mode 100644 index 00000000000..64c92cf52ab --- /dev/null +++ b/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { AccountTypeColumn, NameColumn, RowData } from "@/components/workspace/settings/member-columns"; +import { useUser, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; + +export const useMemberColumns = () => { + // states + const [removeMemberModal, setRemoveMemberModal] = useState(null); + + const { workspaceSlug } = useParams(); + + const { data: currentUser } = useUser(); + const { allowPermissions } = useUserPermissions(); + + const getFormattedDate = (dateStr: string) => { + const date = new Date(dateStr); + + const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; + return date.toLocaleDateString("en-US", options); + }; + + // derived values + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + const columns = [ + { + key: "Full name", + content: "Full name", + thClassName: "text-left", + tdRender: (rowData: RowData) => ( + + ), + }, + + { + key: "Display name", + content: "Display name", + tdRender: (rowData: RowData) =>
    {rowData.member.display_name}
    , + }, + + { + key: "Email address", + content: "Email address", + tdRender: (rowData: RowData) =>
    {rowData.member.email}
    , + }, + + { + key: "Account type", + content: "Account type", + tdRender: (rowData: RowData) => , + }, + + { + key: "Authentication", + content: "Authentication", + tdRender: (rowData: RowData) => ( +
    {rowData.member.last_login_medium?.replace("-", " ")}
    + ), + }, + + { + key: "Joining date", + content: "Joining date", + tdRender: (rowData: RowData) =>
    {getFormattedDate(rowData?.member?.joining_date || "")}
    , + }, + ]; + return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; +}; diff --git a/web/ce/components/workspace/upgrade-badge.tsx b/web/ce/components/workspace/upgrade-badge.tsx new file mode 100644 index 00000000000..3fc3654cfba --- /dev/null +++ b/web/ce/components/workspace/upgrade-badge.tsx @@ -0,0 +1,27 @@ +import { FC } from "react"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TUpgradeBadge = { + className?: string; + size?: "sm" | "md"; +}; + +export const UpgradeBadge: FC = (props) => { + const { className, size = "sm" } = props; + + return ( +
    + Pro +
    + ); +}; diff --git a/web/ce/components/workspace/upgrade/index.tsx b/web/ce/components/workspace/upgrade/index.tsx new file mode 100644 index 00000000000..25115ef33c0 --- /dev/null +++ b/web/ce/components/workspace/upgrade/index.tsx @@ -0,0 +1,3 @@ +export * from "./pro-plan-upgrade"; +export * from "./one-plan-upgrade"; +export * from "./paid-plans-upgrade-modal"; diff --git a/web/ce/components/workspace/upgrade/one-plan-upgrade.tsx b/web/ce/components/workspace/upgrade/one-plan-upgrade.tsx new file mode 100644 index 00000000000..6476d207693 --- /dev/null +++ b/web/ce/components/workspace/upgrade/one-plan-upgrade.tsx @@ -0,0 +1,55 @@ +import { FC } from "react"; +import { CheckCircle } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; + +export type OnePlanUpgradeProps = { + features: string[]; + verticalFeatureList?: boolean; + extraFeatures?: string | React.ReactNode; +}; + +export const OnePlanUpgrade: FC = (props) => { + const { features, verticalFeatureList = false, extraFeatures } = props; + // env + const PLANE_ONE_PAYMENT_URL = "https://prime.plane.so/"; + + return ( +
    +
    +
    +
    Plane One
    +
    $799
    +
    for two years’ support and updates
    +
    + +
    +
    Everything in Free +
    +
      + {features.map((feature) => ( +
    • +

      + + {feature} +

      +
    • + ))} +
    + {extraFeatures &&
    {extraFeatures}
    } +
    +
    + ); +}; diff --git a/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx b/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx new file mode 100644 index 00000000000..0f6d0681eb5 --- /dev/null +++ b/web/ce/components/workspace/upgrade/paid-plans-upgrade-modal.tsx @@ -0,0 +1,113 @@ +import { FC } from "react"; +// types +import { CircleX } from "lucide-react"; +// services +import { EModalWidth, ModalCore } from "@plane/ui"; +// plane web components +import { cn } from "@/helpers/common.helper"; +// local components +import { OnePlanUpgrade } from "./one-plan-upgrade"; +import { ProPlanUpgrade } from "./pro-plan-upgrade"; + +const PRO_PLAN_FEATURES = [ + "More Cycles features", + "Full Time Tracking + Bulk Ops", + "Workflow manager", + "Automations", + "Popular integrations", + "Plane AI", +]; + +const ONE_PLAN_FEATURES = [ + "OIDC + SAML for SSO", + "Active Cycles", + "Real-time collab + public views and page", + "Link pages in issues and vice-versa", + "Time-tracking + limited bulk ops", + "Docker, Kubernetes and more", +]; + +const FREE_PLAN_UPGRADE_FEATURES = [ + "OIDC + SAML for SSO", + "Time tracking and bulk ops", + "Integrations", + "Public views and pages", +]; + +export type PaidPlanUpgradeModalProps = { + isOpen: boolean; + handleClose: () => void; +}; + +export const PaidPlanUpgradeModal: FC = (props) => { + const { isOpen, handleClose } = props; + + return ( + +
    +
    +
    +
    Upgrade to a paid plan and unlock missing features.
    +
    +

    + Active Cycles, time tracking, bulk ops, and other features are waiting for you on one of our paid plans. + Upgrade today to unlock features your teams need yesterday. +

    +
    + {/* Free plan details */} +
    +
    + + Your plan + +
    +
    +
    Free
    +
    $0 a user per month
    +
    +
    +
      + {FREE_PLAN_UPGRADE_FEATURES.map((feature) => ( +
    • +

      + + {feature} +

      +
    • + ))} +
    +
    +
    +
    + + +
    +
    +
    + ); +}; diff --git a/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx b/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx new file mode 100644 index 00000000000..814c43095d6 --- /dev/null +++ b/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { FC, useState } from "react"; +import { CheckCircle } from "lucide-react"; +import { Tab } from "@headlessui/react"; +// helpers +import { cn } from "@/helpers/common.helper"; + +export type ProPlanUpgradeProps = { + basePlan: "Free" | "One"; + features: string[]; + verticalFeatureList?: boolean; + extraFeatures?: string | React.ReactNode; +}; + +type TProPiceFrequency = "month" | "year"; + +type TProPlanPrice = { + key: string; + currency: string; + price: number; + recurring: TProPiceFrequency; +}; + +// constants +export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => { + const monthlyCost = monthlyPrice * 12; + const yearlyCost = yearlyPricePerMonth * 12; + const amountSaved = monthlyCost - yearlyCost; + const discountPercentage = (amountSaved / monthlyCost) * 100; + return Math.floor(discountPercentage); +}; + +const PRO_PLAN_PRICES: TProPlanPrice[] = [ + { key: "monthly", currency: "$", price: 8, recurring: "month" }, + { key: "yearly", currency: "$", price: 6, recurring: "year" }, +]; + +export const ProPlanUpgrade: FC = (props) => { + const { basePlan, features, verticalFeatureList = false, extraFeatures } = props; + // states + const [selectedPlan, setSelectedPlan] = useState("month"); + // derived + const monthlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "month")?.price ?? 0; + const yearlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "year")?.price ?? 0; + const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice); + // env + const PRO_PLAN_MONTHLY_PAYMENT_URL = "https://app.plane.so/upgrade/pro/self-hosted?plan=month"; + const PRO_PLAN_YEARLY_PAYMENT_URL = "https://app.plane.so/upgrade/pro/self-hosted?plan=year"; + + return ( +
    + +
    + + {PRO_PLAN_PRICES.map((price: TProPlanPrice) => ( + + cn( + "w-full rounded-lg py-1.5 text-sm font-medium leading-5", + selected + ? "bg-custom-background-100 text-custom-primary-300 shadow" + : "hover:bg-custom-primary-100/5 text-custom-text-300 hover:text-custom-text-200" + ) + } + onClick={() => setSelectedPlan(price.recurring)} + > + <> + {price.recurring === "month" && ("Monthly" as string)} + {price.recurring === "year" && ("Yearly" as string)} + {price.recurring === "year" && ( + + -{yearlyDiscount}% + + )} + + + ))} + +
    + + {PRO_PLAN_PRICES.map((price: TProPlanPrice) => ( + +
    +
    Plane Pro
    +
    + {price.currency} + {price.price} +
    +
    a user per month
    +
    + +
    +
    {`Everything in ${basePlan} +`}
    +
      + {features.map((feature) => ( +
    • +

      + + {feature} +

      +
    • + ))} +
    + {extraFeatures &&
    {extraFeatures}
    } +
    +
    + ))} +
    +
    +
    + ); +}; diff --git a/web/ce/constants/ai.ts b/web/ce/constants/ai.ts new file mode 100644 index 00000000000..c5c1b04fa9a --- /dev/null +++ b/web/ce/constants/ai.ts @@ -0,0 +1,9 @@ +export enum AI_EDITOR_TASKS { + ASK_ANYTHING = "ASK_ANYTHING", +} + +export const LOADING_TEXTS: { + [key in AI_EDITOR_TASKS]: string; +} = { + [AI_EDITOR_TASKS.ASK_ANYTHING]: "Pi is generating response", +}; diff --git a/web/ce/constants/issue.ts b/web/ce/constants/issue.ts deleted file mode 100644 index 68622c8feda..00000000000 --- a/web/ce/constants/issue.ts +++ /dev/null @@ -1 +0,0 @@ -export const ENABLE_BULK_OPERATIONS = false; diff --git a/web/ce/constants/issues.ts b/web/ce/constants/issues.ts new file mode 100644 index 00000000000..a139dc86a14 --- /dev/null +++ b/web/ce/constants/issues.ts @@ -0,0 +1,35 @@ +import { TIssueActivityComment } from "@plane/types"; + +export enum EActivityFilterType { + ACTIVITY = "ACTIVITY", + COMMENT = "COMMENT", +} + +export type TActivityFilters = EActivityFilterType; + +export const ACTIVITY_FILTER_TYPE_OPTIONS: Record = { + [EActivityFilterType.ACTIVITY]: { + label: "Updates", + }, + [EActivityFilterType.COMMENT]: { + label: "Comments", + }, +}; + +export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT]; + +export type TActivityFilterOption = { + key: EActivityFilterType; + label: string; + isSelected: boolean; + onClick: () => void; +}; + +export const filterActivityOnSelectedFilters = ( + activity: TIssueActivityComment[], + filter: TActivityFilters[] +): TIssueActivityComment[] => + activity.filter((activity) => filter.includes(activity.activity_type as TActivityFilters)); + +// boolean to decide if the local db cache is enabled +export const ENABLE_LOCAL_DB_CACHE = false; diff --git a/web/ce/constants/project/index.ts b/web/ce/constants/project/index.ts new file mode 100644 index 00000000000..dcf101b0c62 --- /dev/null +++ b/web/ce/constants/project/index.ts @@ -0,0 +1 @@ +export * from "./settings"; diff --git a/web/ce/constants/project/settings/features.tsx b/web/ce/constants/project/settings/features.tsx new file mode 100644 index 00000000000..3fdccc97940 --- /dev/null +++ b/web/ce/constants/project/settings/features.tsx @@ -0,0 +1,85 @@ +import { ReactNode } from "react"; +import { FileText, Layers, Timer } from "lucide-react"; +import { ContrastIcon, DiceIcon, Intake } from "@plane/ui"; + +export type TFeatureList = { + [key: string]: { + property: string; + title: string; + description: string; + icon: ReactNode; + isPro: boolean; + isEnabled: boolean; + }; +}; + +export type TProjectFeatures = { + [key: string]: { + title: string; + description: string; + featureList: TFeatureList; + }; +}; + +export const PROJECT_FEATURES_LIST: TProjectFeatures = { + project_features: { + title: "Projects and issues", + description: "Toggle these on or off this project.", + featureList: { + cycles: { + property: "cycle_view", + title: "Cycles", + description: "Timebox work as you see fit per project and change frequency from one period to the next.", + icon: , + isPro: false, + isEnabled: true, + }, + modules: { + property: "module_view", + title: "Modules", + description: "Group work into sub-project-like set-ups with their own leads and assignees.", + icon: , + isPro: false, + isEnabled: true, + }, + views: { + property: "issue_views_view", + title: "Views", + description: "Save sorts, filters, and display options for later or share them.", + icon: , + isPro: false, + isEnabled: true, + }, + pages: { + property: "page_view", + title: "Pages", + description: "Write anything like you write anything.", + icon: , + isPro: false, + isEnabled: true, + }, + inbox: { + property: "inbox_view", + title: "Intake", + description: "Consider and discuss issues before you add them to your project.", + icon: , + isPro: false, + isEnabled: true, + }, + }, + }, + project_others: { + title: "Work management", + description: "Available only on some plans as indicated by the label next to the feature below.", + featureList: { + is_time_tracking_enabled: { + property: "is_time_tracking_enabled", + title: "Time Tracking", + description: "Log time, see timesheets, and download full CSVs for your entire workspace.", + icon: , + isPro: true, + isEnabled: false, + }, + }, + }, +}; diff --git a/web/ce/constants/project/settings/index.ts b/web/ce/constants/project/settings/index.ts new file mode 100644 index 00000000000..a6a842e7be9 --- /dev/null +++ b/web/ce/constants/project/settings/index.ts @@ -0,0 +1,2 @@ +export * from "./features"; +export * from "./tabs"; diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts new file mode 100644 index 00000000000..4d9207cc60a --- /dev/null +++ b/web/ce/constants/project/settings/tabs.ts @@ -0,0 +1,82 @@ +// icons +import { SettingIcon } from "@/components/icons/attachment"; +// types +import { Props } from "@/components/icons/types"; +// constants +import { EUserPermissions } from "../../user-permissions"; + +export const PROJECT_SETTINGS = { + general: { + key: "general", + label: "General", + href: `/settings`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + Icon: SettingIcon, + }, + members: { + key: "members", + label: "Members", + href: `/settings/members`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + Icon: SettingIcon, + }, + features: { + key: "features", + label: "Features", + href: `/settings/features`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, + Icon: SettingIcon, + }, + states: { + key: "states", + label: "States", + href: `/settings/states`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, + Icon: SettingIcon, + }, + labels: { + key: "labels", + label: "Labels", + href: `/settings/labels`, + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, + Icon: SettingIcon, + }, + estimates: { + key: "estimates", + label: "Estimates", + href: `/settings/estimates`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, + Icon: SettingIcon, + }, + automations: { + key: "automations", + label: "Automations", + href: `/settings/automations`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, + Icon: SettingIcon, + }, +}; + +export const PROJECT_SETTINGS_LINKS: { + key: string; + label: string; + href: string; + access: EUserPermissions[]; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; +}[] = [ + PROJECT_SETTINGS["general"], + PROJECT_SETTINGS["members"], + PROJECT_SETTINGS["features"], + PROJECT_SETTINGS["states"], + PROJECT_SETTINGS["labels"], + PROJECT_SETTINGS["estimates"], + PROJECT_SETTINGS["automations"], +]; diff --git a/web/ce/constants/user-permissions/index.ts b/web/ce/constants/user-permissions/index.ts new file mode 100644 index 00000000000..e37a2aae929 --- /dev/null +++ b/web/ce/constants/user-permissions/index.ts @@ -0,0 +1,36 @@ +export enum EUserPermissionsLevel { + WORKSPACE = "WORKSPACE", + PROJECT = "PROJECT", +} +export type TUserPermissionsLevel = EUserPermissionsLevel; + +export enum EUserPermissions { + ADMIN = 20, + MEMBER = 15, + GUEST = 5, +} +export type TUserPermissions = EUserPermissions; + +export type TUserAllowedPermissionsObject = { + create: TUserPermissions[]; + update: TUserPermissions[]; + delete: TUserPermissions[]; + read: TUserPermissions[]; +}; +export type TUserAllowedPermissions = { + workspace: { + [key: string]: Partial; + }; + project: { + [key: string]: Partial; + }; +}; + +export const USER_ALLOWED_PERMISSIONS: TUserAllowedPermissions = { + workspace: { + dashboard: { + read: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], + }, + }, + project: {}, +}; diff --git a/web/ce/constants/workspace.ts b/web/ce/constants/workspace.ts new file mode 100644 index 00000000000..b976111b559 --- /dev/null +++ b/web/ce/constants/workspace.ts @@ -0,0 +1,72 @@ +// icons +import { SettingIcon } from "@/components/icons/attachment"; +import { Props } from "@/components/icons/types"; +import { EUserPermissions } from "./user-permissions"; +// constants + +export const WORKSPACE_SETTINGS = { + general: { + key: "general", + label: "General", + href: `/settings`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + Icon: SettingIcon, + }, + members: { + key: "members", + label: "Members", + href: `/settings/members`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + Icon: SettingIcon, + }, + "billing-and-plans": { + key: "billing-and-plans", + label: "Billing and plans", + href: `/settings/billing`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`, + Icon: SettingIcon, + }, + export: { + key: "export", + label: "Exports", + href: `/settings/exports`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`, + Icon: SettingIcon, + }, + webhooks: { + key: "webhooks", + label: "Webhooks", + href: `/settings/webhooks`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`, + Icon: SettingIcon, + }, + "api-tokens": { + key: "api-tokens", + label: "API tokens", + href: `/settings/api-tokens`, + access: [EUserPermissions.ADMIN], + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`, + Icon: SettingIcon, + }, +}; + +export const WORKSPACE_SETTINGS_LINKS: { + key: string; + label: string; + href: string; + access: EUserPermissions[]; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; +}[] = [ + WORKSPACE_SETTINGS["general"], + WORKSPACE_SETTINGS["members"], + WORKSPACE_SETTINGS["billing-and-plans"], + WORKSPACE_SETTINGS["export"], + WORKSPACE_SETTINGS["webhooks"], + WORKSPACE_SETTINGS["api-tokens"], +]; diff --git a/web/ce/helpers/issue-filter.helper.ts b/web/ce/helpers/issue-filter.helper.ts new file mode 100644 index 00000000000..e39259cd937 --- /dev/null +++ b/web/ce/helpers/issue-filter.helper.ts @@ -0,0 +1,18 @@ +// types +import { IIssueDisplayProperties } from "@plane/types"; + +export type TShouldRenderDisplayProperty = { + workspaceSlug: string; + projectId: string | undefined; + key: keyof IIssueDisplayProperties; +}; + +export const shouldRenderDisplayProperty = (props: TShouldRenderDisplayProperty) => { + const { key } = props; + switch (key) { + case "issue_type": + return false; + default: + return true; + } +}; diff --git a/web/ce/helpers/workspace.helper.ts b/web/ce/helpers/workspace.helper.ts new file mode 100644 index 00000000000..bc8c5c1b4f7 --- /dev/null +++ b/web/ce/helpers/workspace.helper.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const shouldRenderSettingLink = (settingKey: string) => true; diff --git a/web/ce/hooks/use-bulk-operation-status.ts b/web/ce/hooks/use-bulk-operation-status.ts new file mode 100644 index 00000000000..0bb6768101e --- /dev/null +++ b/web/ce/hooks/use-bulk-operation-status.ts @@ -0,0 +1 @@ +export const useBulkOperationStatus = () => false; diff --git a/web/ce/hooks/use-editor-flagging.ts b/web/ce/hooks/use-editor-flagging.ts new file mode 100644 index 00000000000..ddaee716507 --- /dev/null +++ b/web/ce/hooks/use-editor-flagging.ts @@ -0,0 +1,13 @@ +// editor +import { TExtensions } from "@plane/editor"; + +/** + * @description extensions disabled in various editors + */ +export const useEditorFlagging = (): { + documentEditor: TExtensions[]; + richTextEditor: TExtensions[]; +} => ({ + documentEditor: ["ai", "collaboration-cursor"], + richTextEditor: ["ai", "collaboration-cursor"], +}); diff --git a/web/ce/hooks/use-issue-embed.tsx b/web/ce/hooks/use-issue-embed.tsx new file mode 100644 index 00000000000..5d02d978fa2 --- /dev/null +++ b/web/ce/hooks/use-issue-embed.tsx @@ -0,0 +1,19 @@ +// editor +import { TEmbedConfig } from "@plane/editor"; +// types +import { TPageEmbedType } from "@plane/types"; +// plane web components +import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => { + const widgetCallback = () => ; + + const issueEmbedProps: TEmbedConfig["issue"] = { + widgetCallback, + }; + + return { + issueEmbedProps, + }; +}; diff --git a/web/ce/services/project/view.service.ts b/web/ce/services/project/view.service.ts index 07872394a39..6cb76222add 100644 --- a/web/ce/services/project/view.service.ts +++ b/web/ce/services/project/view.service.ts @@ -1,3 +1,4 @@ +import { TPublishViewSettings } from "@plane/types"; import { EViewAccess } from "@/constants/views"; import { API_BASE_URL } from "@/helpers/common.helper"; import { ViewService as CoreViewService } from "@/services/view.service"; @@ -21,4 +22,40 @@ export class ViewService extends CoreViewService { async unLockView(workspaceSlug: string, projectId: string, viewId: string) { return Promise.resolve(); } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise { + return Promise.resolve({}); + } + + async publishView( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + workspaceSlug: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + projectId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + viewId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + data: TPublishViewSettings + ): Promise { + return Promise.resolve(); + } + + async updatePublishedView( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + workspaceSlug: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + projectId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + viewId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + data: Partial + ): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise { + return Promise.resolve(); + } } diff --git a/web/core/store/issue/issue-details/activity.store.ts b/web/ce/store/issue/issue-details/activity.store.ts similarity index 83% rename from web/core/store/issue/issue-details/activity.store.ts rename to web/ce/store/issue/issue-details/activity.store.ts index dd4fe10aa24..27515835046 100644 --- a/web/core/store/issue/issue-details/activity.store.ts +++ b/web/ce/store/issue/issue-details/activity.store.ts @@ -1,14 +1,19 @@ +/* eslint-disable no-useless-catch */ + import concat from "lodash/concat"; import set from "lodash/set"; import sortBy from "lodash/sortBy"; import uniq from "lodash/uniq"; import update from "lodash/update"; import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; import { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap } from "@plane/types"; +// plane web constants +import { EActivityFilterType } from "@/plane-web/constants/issues"; +// plane web store types +import { RootStore } from "@/plane-web/store/root.store"; // services import { IssueActivityService } from "@/services/issue"; -// types -import { IIssueDetail } from "./root.store"; export type TActivityLoader = "fetch" | "mutate" | undefined; @@ -38,12 +43,11 @@ export class IssueActivityStore implements IIssueActivityStore { loader: TActivityLoader = "fetch"; activities: TIssueActivityIdMap = {}; activityMap: TIssueActivityMap = {}; - // root store - rootIssueDetailStore: IIssueDetail; + // services issueActivityService; - constructor(rootStore: IIssueDetail) { + constructor(protected store: RootStore) { makeObservable(this, { // observables loader: observable.ref, @@ -52,8 +56,6 @@ export class IssueActivityStore implements IIssueActivityStore { // actions fetchActivities: action, }); - // root store - this.rootIssueDetailStore = rootStore; // services this.issueActivityService = new IssueActivityService(); } @@ -69,50 +71,46 @@ export class IssueActivityStore implements IIssueActivityStore { return this.activityMap[activityId] ?? undefined; }; - getActivityCommentByIssueId = (issueId: string) => { + getActivityCommentByIssueId = computedFn((issueId: string) => { if (!issueId) return undefined; let activityComments: TIssueActivityComment[] = []; const activities = this.getActivitiesByIssueId(issueId) || []; - const comments = this.rootIssueDetailStore.comment.getCommentsByIssueId(issueId) || []; + const comments = this.store.issue.issueDetail.comment.getCommentsByIssueId(issueId) || []; activities.forEach((activityId) => { const activity = this.getActivityById(activityId); if (!activity) return; activityComments.push({ id: activity.id, - activity_type: "ACTIVITY", + activity_type: EActivityFilterType.ACTIVITY, created_at: activity.created_at, }); }); comments.forEach((commentId) => { - const comment = this.rootIssueDetailStore.comment.getCommentById(commentId); + const comment = this.store.issue.issueDetail.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ id: comment.id, - activity_type: "COMMENT", + activity_type: EActivityFilterType.COMMENT, created_at: comment.created_at, }); }); activityComments = sortBy(activityComments, "created_at"); - activityComments = activityComments.map((activityComment) => ({ - id: activityComment.id, - activity_type: activityComment.activity_type, - })); return activityComments; - }; + }); // actions - fetchActivities = async ( + public async fetchActivities( workspaceSlug: string, projectId: string, issueId: string, loaderType: TActivityLoader = "fetch" - ) => { + ) { try { this.loader = loaderType; @@ -140,7 +138,8 @@ export class IssueActivityStore implements IIssueActivityStore { return activities; } catch (error) { + this.loader = undefined; throw error; } - }; + } } diff --git a/web/ce/types/index.ts b/web/ce/types/index.ts new file mode 100644 index 00000000000..0d4b66523e9 --- /dev/null +++ b/web/ce/types/index.ts @@ -0,0 +1,2 @@ +export * from "./projects"; +export * from "./issue-types"; diff --git a/web/ce/types/issue-types/index.ts b/web/ce/types/issue-types/index.ts new file mode 100644 index 00000000000..7259fa35181 --- /dev/null +++ b/web/ce/types/issue-types/index.ts @@ -0,0 +1 @@ +export * from "./issue-property-values.d"; diff --git a/web/ce/types/issue-types/issue-property-values.d.ts b/web/ce/types/issue-types/issue-property-values.d.ts new file mode 100644 index 00000000000..e1d94dbc844 --- /dev/null +++ b/web/ce/types/issue-types/issue-property-values.d.ts @@ -0,0 +1,2 @@ +export type TIssuePropertyValues = object; +export type TIssuePropertyValueErrors = object; diff --git a/web/ce/types/projects/index.ts b/web/ce/types/projects/index.ts new file mode 100644 index 00000000000..244d8c4df33 --- /dev/null +++ b/web/ce/types/projects/index.ts @@ -0,0 +1 @@ +export * from "./projects"; diff --git a/web/ce/types/projects/projects.ts b/web/ce/types/projects/projects.ts new file mode 100644 index 00000000000..567c9488db7 --- /dev/null +++ b/web/ce/types/projects/projects.ts @@ -0,0 +1,3 @@ +import { IProject } from "@plane/types"; + +export type TProject = IProject; diff --git a/web/core/components/account/auth-forms/email.tsx b/web/core/components/account/auth-forms/email.tsx index 9acfcc5cc26..1e96ed3f2f5 100644 --- a/web/core/components/account/auth-forms/email.tsx +++ b/web/core/components/account/auth-forms/email.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, FormEvent, useMemo, useState } from "react"; +import { FC, FormEvent, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; @@ -22,7 +22,6 @@ export const AuthEmailForm: FC = observer((props) => { // states const [isSubmitting, setIsSubmitting] = useState(false); const [email, setEmail] = useState(defaultEmail); - const [isFocused, setFocused] = useState(false); const emailError = useMemo( () => (email && !checkEmailValidity(email) ? { email: "Email is invalid" } : undefined), @@ -41,6 +40,9 @@ export const AuthEmailForm: FC = observer((props) => { const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting; + const [isFocused, setIsFocused] = useState(true) + const inputRef = useRef(null); + return (
    @@ -52,6 +54,9 @@ export const AuthEmailForm: FC = observer((props) => { `relative flex items-center rounded-md bg-onboarding-background-200 border`, !isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100` )} + tabIndex={-1} + onFocus={() => {setIsFocused(true)}} + onBlur={() => {setIsFocused(false)}} > = observer((props) => { onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com" className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 autofill:bg-red-500 border-0 focus:bg-none active:bg-transparent`} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} + autoComplete="on" autoFocus + ref={inputRef} /> - {email.length > 0 && ( + {email.length > 0 && ( setEmail("")} + className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs" + onClick={() => { + setEmail(""); + inputRef.current?.focus(); + }} /> )}
    @@ -84,4 +92,4 @@ export const AuthEmailForm: FC = observer((props) => { ); -}); +}); \ No newline at end of file diff --git a/web/core/components/account/auth-forms/password.tsx b/web/core/components/account/auth-forms/password.tsx index a4c8b205d43..088dc31949f 100644 --- a/web/core/components/account/auth-forms/password.tsx +++ b/web/core/components/account/auth-forms/password.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // icons @@ -51,8 +51,10 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const { email, isSMTPConfigured, handleAuthStep, handleEmailClear, mode, nextPath } = props; // hooks const { captureEvent } = useEventTracker(); + // ref + const formRef = useRef(null); // states - const [csrfToken, setCsrfToken] = useState(undefined); + const [csrfPromise, setCsrfPromise] = useState | undefined>(undefined); const [passwordFormData, setPasswordFormData] = useState({ ...defaultValues, email }); const [showPassword, setShowPassword] = useState({ password: false, @@ -70,9 +72,11 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { setPasswordFormData((prev) => ({ ...prev, [key]: value })); useEffect(() => { - if (csrfToken === undefined) - authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token)); - }, [csrfToken]); + if (csrfPromise === undefined) { + const promise = authService.requestCSRFToken(); + setCsrfPromise(promise); + } + }, [csrfPromise]); const redirectToUniqueCodeSignIn = async () => { handleAuthStep(EAuthSteps.UNIQUE_CODE); @@ -115,6 +119,14 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { const confirmPassword = passwordFormData?.confirm_password ?? ""; const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length; + const handleCSRFToken = async () => { + if (!formRef || !formRef.current) return; + const token = await csrfPromise; + if (!token?.csrf_token) return; + const csrfElement = formRef.current.querySelector("input[name=csrfmiddlewaretoken]"); + csrfElement?.setAttribute("value", token?.csrf_token); + }; + return ( <> {isBannerMessage && mode === EAuthModes.SIGN_UP && ( @@ -132,11 +144,13 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
    )}
    { + onSubmit={async (event) => { event.preventDefault(); // Prevent form from submitting by default + await handleCSRFToken(); const isPasswordValid = mode === EAuthModes.SIGN_UP ? getPasswordStrength(passwordFormData.password) === E_PASSWORD_STRENGTH.STRENGTH_VALID @@ -144,14 +158,14 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { if (isPasswordValid) { setIsSubmitting(true); captureEvent(mode === EAuthModes.SIGN_IN ? SIGN_IN_WITH_PASSWORD : SIGN_UP_WITH_PASSWORD); - event.currentTarget.submit(); // Manually submit the form if the condition is met + formRef.current && formRef.current.submit(); // Manually submit the form if the condition is met } else { setBannerMessage(true); } }} onError={() => setIsSubmitting(false)} > - + {nextPath && }
    @@ -194,6 +208,7 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => { 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)} + autoComplete="on" autoFocus /> {showPassword?.password ? ( diff --git a/web/core/components/account/auth-forms/unique-code.tsx b/web/core/components/account/auth-forms/unique-code.tsx index 8c7b8a60fdd..530874eb9c8 100644 --- a/web/core/components/account/auth-forms/unique-code.tsx +++ b/web/core/components/account/auth-forms/unique-code.tsx @@ -107,6 +107,7 @@ export const AuthUniqueCodeForm: React.FC = (props) => { onChange={(e) => handleFormChange("email", e.target.value)} placeholder="name@company.com" className={`disable-autofill-style h-[46px] w-full placeholder:text-onboarding-text-400 border-0`} + autoComplete="on" disabled /> {uniqueCodeFormData.email.length > 0 && ( diff --git a/web/core/components/analytics/custom-analytics/main-content.tsx b/web/core/components/analytics/custom-analytics/main-content.tsx index 1193688aa6f..8c1f8451f3e 100644 --- a/web/core/components/analytics/custom-analytics/main-content.tsx +++ b/web/core/components/analytics/custom-analytics/main-content.tsx @@ -51,7 +51,7 @@ export const CustomAnalyticsMainContent: React.FC = (props) => {
    ) ) : ( - + diff --git a/web/core/components/analytics/custom-analytics/select-bar.tsx b/web/core/components/analytics/custom-analytics/select-bar.tsx index de4dc97edeb..8df1e71e110 100644 --- a/web/core/components/analytics/custom-analytics/select-bar.tsx +++ b/web/core/components/analytics/custom-analytics/select-bar.tsx @@ -1,12 +1,14 @@ import { observer } from "mobx-react"; import { Control, Controller, UseFormSetValue } from "react-hook-form"; +// types import { IAnalyticsParams } from "@plane/types"; -// hooks +// ui +import { Row } from "@plane/ui"; +// components import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "@/components/analytics"; import { ANALYTICS_X_AXIS_VALUES } from "@/constants/analytics"; +// hooks import { useProject } from "@/hooks/store"; -// components -// types type Props = { control: Control; @@ -30,14 +32,14 @@ export const CustomAnalyticsSelectBar: React.FC = observer((props) => { : ANALYTICS_X_AXIS_VALUES; return ( -
    {!isProjectLevel && (
    -
    Project
    +
    Project
    = observer((props) => {
    )}
    -
    Measure (y-axis)
    +
    Measure (y-axis)
    = observer((props) => { />
    -
    Dimension (x-axis)
    +
    Dimension (x-axis)
    = observer((props) => { />
    -
    Group
    +
    Group
    = observer((props) => { )} />
    -
    + ); }); diff --git a/web/core/components/analytics/custom-analytics/select/project.tsx b/web/core/components/analytics/custom-analytics/select/project.tsx index 5eb209d0e1e..e9d226a7e63 100644 --- a/web/core/components/analytics/custom-analytics/select/project.tsx +++ b/web/core/components/analytics/custom-analytics/select/project.tsx @@ -23,7 +23,7 @@ export const SelectProject: React.FC = observer((props) => { value: projectDetails?.id, query: `${projectDetails?.name} ${projectDetails?.identifier}`, content: ( -
    +
    {projectDetails?.identifier} {projectDetails?.name}
    @@ -37,12 +37,14 @@ export const SelectProject: React.FC = observer((props) => { onChange={(val: string[]) => onChange(val)} options={options} label={ - value && value.length > 0 - ? projectIds - ?.filter((p) => value.includes(p)) - .map((p) => getProjectById(p)?.name) - .join(", ") - : "All projects" +
    + {value && value.length > 0 + ? projectIds + ?.filter((p) => value.includes(p)) + .map((p) => getProjectById(p)?.name) + .join(", ") + : "All projects"} +
    } multiple /> diff --git a/web/core/components/analytics/custom-analytics/table.tsx b/web/core/components/analytics/custom-analytics/table.tsx index 616fcba3dfe..49039ed02b6 100644 --- a/web/core/components/analytics/custom-analytics/table.tsx +++ b/web/core/components/analytics/custom-analytics/table.tsx @@ -23,7 +23,7 @@ export const AnalyticsTable: React.FC = ({ analytics, barGraphData, param - {params.segment ? ( @@ -31,7 +31,7 @@ export const AnalyticsTable: React.FC = ({ analytics, barGraphData, param )) ) : ( - )} @@ -60,11 +60,11 @@ export const AnalyticsTable: React.FC = ({ analytics, barGraphData, param {barGraphData.data.map((item, index) => ( - {params.segment ? ( barGraphData.xAxisKeys.map((key, index) => ( - )) ) : ( - + )} ))} diff --git a/web/core/components/analytics/project-modal/header.tsx b/web/core/components/analytics/project-modal/header.tsx index 526dc975ee2..79d63ce3c0f 100644 --- a/web/core/components/analytics/project-modal/header.tsx +++ b/web/core/components/analytics/project-modal/header.tsx @@ -19,7 +19,7 @@ export const ProjectAnalyticsModalHeader: React.FC = observer((props) =>
    + ) : ( diff --git a/web/core/components/analytics/scope-and-demand/scope.tsx b/web/core/components/analytics/scope-and-demand/scope.tsx index 4b420af610c..4bff95cc699 100644 --- a/web/core/components/analytics/scope-and-demand/scope.tsx +++ b/web/core/components/analytics/scope-and-demand/scope.tsx @@ -1,5 +1,6 @@ // ui import { IDefaultAnalyticsUser } from "@plane/types"; +import { Card } from "@plane/ui"; import { BarGraph, ProfileEmptyState } from "@/components/ui"; // image import emptyBarGraph from "@/public/empty-state/empty_bar_graph.svg"; @@ -11,7 +12,7 @@ type Props = { }; export const AnalyticsScope: React.FC = ({ pendingUnAssignedIssuesUser, pendingAssignedIssues }) => ( -
    +
    @@ -87,5 +88,5 @@ export const AnalyticsScope: React.FC = ({ pendingUnAssignedIssuesUser, p )}
    -
    +
    ); diff --git a/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx b/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx index d6c412aba61..b2c5805dd7a 100644 --- a/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx +++ b/web/core/components/analytics/scope-and-demand/year-wise-issues.tsx @@ -1,5 +1,6 @@ // ui import { IDefaultAnalyticsResponse } from "@plane/types"; +import { Card } from "@plane/ui"; import { LineGraph, ProfileEmptyState } from "@/components/ui"; // image import { MONTHS_LIST } from "@/constants/calendar"; @@ -12,8 +13,8 @@ type Props = { }; export const AnalyticsYearWiseIssues: React.FC = ({ defaultAnalytics }) => ( -
    -

    Issues closed in a year

    + +

    Issues closed in a year

    {defaultAnalytics.issue_completed_month_wise.length > 0 ? ( = ({ defaultAnalytics }) = />
    )} -
    + ); diff --git a/web/core/components/api-token/token-list-item.tsx b/web/core/components/api-token/token-list-item.tsx index 7bbd6a6dba8..958fc81d09b 100644 --- a/web/core/components/api-token/token-list-item.tsx +++ b/web/core/components/api-token/token-list-item.tsx @@ -26,7 +26,7 @@ export const ApiTokenListItem: React.FC = (props) => { return ( <> setDeleteModalOpen(false)} tokenId={token.id} /> -
    +
    {fileRejections.length > 0 && ( @@ -349,9 +356,7 @@ export const ImagePickerPopover: React.FC = observer((props) => {

    )} -

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

    +

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

    - -
    - - - - - - - - ); -}; diff --git a/web/core/components/core/modals/user-image-upload-modal.tsx b/web/core/components/core/modals/user-image-upload-modal.tsx index 28188b7ee50..7e033053f78 100644 --- a/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/web/core/components/core/modals/user-image-upload-modal.tsx @@ -39,7 +39,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, accept: { - "image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"], + "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, multiple: false, @@ -144,7 +144,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { )} - + {fileRejections.length > 0 && ( @@ -156,9 +156,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { )} -

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

    +

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

    {handleDelete && (
    )} - + {fileRejections.length > 0 && ( @@ -162,9 +162,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { )} -

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

    +

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

    {handleRemove && ( - - - - -
    - )} - -
    -

    - Added {calculateTimeAgo(link.created_at)} -
    - {createdByDetails && ( - <> - by{" "} - {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name} - - )} -

    -
    - - ); - })} - - ); -}); diff --git a/web/core/components/core/sidebar/progress-chart.tsx b/web/core/components/core/sidebar/progress-chart.tsx index f6273600223..25dd3fee5b3 100644 --- a/web/core/components/core/sidebar/progress-chart.tsx +++ b/web/core/components/core/sidebar/progress-chart.tsx @@ -136,7 +136,7 @@ const ProgressChart: React.FC = ({ enableSlices="x" sliceTooltip={(datum) => (
    - {datum.slice.points[0].data.yFormatted} + {datum.slice.points?.[1]?.data?.yFormatted ?? datum.slice.points[0].data.yFormatted} {plotTitle} pending on {datum.slice.points[0].data.xFormatted}
    diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index d8232375034..1ee86a620b7 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -1,14 +1,13 @@ "use client"; import { FC, Fragment, useCallback, useRef, useState } from "react"; +import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; -import Link from "next/link"; -import useSWR from "swr"; import { CalendarCheck } from "lucide-react"; // headless ui import { Tab } from "@headlessui/react"; // types -import { ICycle } from "@plane/types"; +import { ICycle, IIssueFilterOptions } from "@plane/types"; // ui import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui"; // components @@ -17,24 +16,29 @@ import { StateDropdown } from "@/components/dropdowns"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; import { EIssuesStoreType } from "@/constants/issue"; // helper import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; // hooks -import { useIssueDetail, useIssues, useProject } from "@/hooks/store"; +import { useIssueDetail, useIssues } from "@/hooks/store"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import useLocalStorage from "@/hooks/use-local-storage"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues"; +import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; export type ActiveCycleStatsProps = { workspaceSlug: string; projectId: string; - cycle: ICycle; + cycle: ICycle | null; + cycleId?: string | null; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void; + cycleIssueDetails: ActiveCycleIssueDetails; }; export const ActiveCycleStats: FC = observer((props) => { - const { workspaceSlug, projectId, cycle } = props; + const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate, cycleIssueDetails } = props; const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); @@ -54,31 +58,29 @@ export const ActiveCycleStats: FC = observer((props) => { } }; const { - issues: { getActiveCycleById, fetchActiveCycleIssues, fetchNextActiveCycleIssues }, + issues: { fetchNextActiveCycleIssues }, } = useIssues(EIssuesStoreType.CYCLE); const { issue: { getIssueById }, + setPeekIssue, } = useIssueDetail(); - - const { currentProjectDetails } = useProject(); - - useSWR( - workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null, - workspaceSlug && projectId && cycle.id - ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle.id) - : null, - { revalidateIfStale: false, revalidateOnFocus: false } - ); - - const cycleIssueDetails = getActiveCycleById(cycle.id); - const loadMoreIssues = useCallback(() => { - fetchNextActiveCycleIssues(workspaceSlug, projectId, cycle.id); - }, [workspaceSlug, projectId, cycle.id, issuesLoaderElement, cycleIssueDetails?.nextPageResults]); + if (!cycleId) return; + fetchNextActiveCycleIssues(workspaceSlug, projectId, cycleId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceSlug, projectId, cycleId, issuesLoaderElement, cycleIssueDetails?.nextPageResults]); useIntersectionObserver(issuesContainerRef, issuesLoaderElement, loadMoreIssues, `0% 0% 100% 0%`); - return ( + const loaders = ( + + + + + + ); + + return cycleId ? (
    = observer((props) => { ref={issuesContainerRef} className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm" > - {cycleIssueDetails && cycleIssueDetails.issueIds ? ( + {cycleIssueDetails && "issueIds" in cycleIssueDetails ? ( cycleIssueDetails.issueCount > 0 ? ( <> {cycleIssueDetails.issueIds.map((issueId: string) => { @@ -163,26 +165,27 @@ export const ActiveCycleStats: FC = observer((props) => { if (!issue) return null; return ( - { + if (issue.id) { + setPeekIssue({ workspaceSlug, projectId, issueId: issue.id }); + handleFiltersUpdate("priority", ["urgent", "high"], true); + } + }} >
    - - - - - {currentProjectDetails?.identifier}-{issue.sequence_id} - - + {issue.name}
    +
    = observer((props) => { )}
    - +
    ); })} {(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && ( @@ -229,11 +232,7 @@ export const ActiveCycleStats: FC = observer((props) => { ) ) : ( - - - - - + loaders )} @@ -242,44 +241,57 @@ export const ActiveCycleStats: FC = observer((props) => { as="div" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" > - {cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? ( - cycle.distribution?.assignees?.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - + {cycle && !isEmpty(cycle.distribution) ? ( + cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? ( + cycle.distribution?.assignees?.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + - {assignee.display_name} - - } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - else - return ( - -
    - User + {assignee.display_name}
    - No assignee - - } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - }) + } + completed={assignee.completed_issues} + total={assignee.total_issues} + onClick={() => { + if (assignee.assignee_id) { + handleFiltersUpdate("assignees", [assignee.assignee_id], true); + } + }} + /> + ); + else + return ( + +
    + User +
    + No assignee + + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + }) + ) : ( +
    + +
    + ) ) : ( -
    - -
    + loaders )} @@ -287,33 +299,46 @@ export const ActiveCycleStats: FC = observer((props) => { as="div" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" > - {cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? ( - cycle.distribution.labels?.map((label, index) => ( - - - {label.label_name ?? "No labels"} - - } - completed={label.completed_issues} - total={label.total_issues} - /> - )) + {cycle && !isEmpty(cycle.distribution) ? ( + cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? ( + cycle.distribution.labels?.map((label, index) => ( + + + {label.label_name ?? "No labels"} + + } + completed={label.completed_issues} + total={label.total_issues} + onClick={() => { + if (label.label_id) { + handleFiltersUpdate("labels", [label.label_id], true); + } + }} + /> + )) + ) : ( +
    + +
    + ) ) : ( -
    - -
    + loaders )} + ) : ( + + + ); }); diff --git a/web/core/components/cycles/active-cycle/index.ts b/web/core/components/cycles/active-cycle/index.ts index d88ccc3e8b6..c2197825207 100644 --- a/web/core/components/cycles/active-cycle/index.ts +++ b/web/core/components/cycles/active-cycle/index.ts @@ -1,4 +1,3 @@ -export * from "./root"; export * from "./header"; export * from "./stats"; export * from "./upcoming-cycles-list-item"; diff --git a/web/core/components/cycles/active-cycle/productivity.tsx b/web/core/components/cycles/active-cycle/productivity.tsx index cc10fb808ac..1e70f326f40 100644 --- a/web/core/components/cycles/active-cycle/productivity.tsx +++ b/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,8 +1,8 @@ -import { FC, Fragment, useState } from "react"; +import { FC, Fragment } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { ICycle, TCyclePlotType } from "@plane/types"; -import { CustomSelect, Spinner } from "@plane/ui"; +import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types"; +import { CustomSelect, Loader } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; import { EmptyState } from "@/components/empty-state"; @@ -15,35 +15,26 @@ import { EEstimateSystem } from "@/plane-web/constants/estimates"; export type ActiveCycleProductivityProps = { workspaceSlug: string; projectId: string; - cycle: ICycle; + cycle: ICycle | null; }; const cycleBurnDownChartOptions = [ - { value: "burndown", label: "Issues" }, + { value: "issues", label: "Issues" }, { value: "points", label: "Points" }, ]; export const ActiveCycleProductivity: FC = observer((props) => { const { workspaceSlug, projectId, cycle } = props; // hooks - const { getPlotTypeByCycleId, setPlotType, fetchCycleDetails } = useCycle(); + const { getEstimateTypeByCycleId, setEstimateType } = useCycle(); const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates(); - // state - const [loader, setLoader] = useState(false); + // derived values - const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown"; + const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues"; - const onChange = async (value: TCyclePlotType) => { + const onChange = async (value: TCycleEstimateType) => { if (!workspaceSlug || !projectId || !cycle || !cycle.id) return; - setPlotType(cycle.id, value); - try { - setLoader(true); - await fetchCycleDetails(workspaceSlug, projectId, cycle.id); - setLoader(false); - } catch (error) { - setLoader(false); - setPlotType(cycle.id, plotType); - } + setEstimateType(cycle.id, value); }; const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; @@ -51,20 +42,21 @@ export const ActiveCycleProductivity: FC = observe isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS; - const chartDistributionData = plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined; + const chartDistributionData = + cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined; const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; - return ( -
    -
    + return cycle && completionChartDistributionData ? ( +
    +

    Issue burndown

    {isCurrentEstimateTypeIsPoints && (
    {cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}} + value={estimateType} + label={{cycleBurnDownChartOptions.find((v) => v.value === estimateType)?.label ?? "None"}} onChange={onChange} maxHeight="lg" > @@ -74,7 +66,6 @@ export const ActiveCycleProductivity: FC = observe ))} - {loader && }
    )}
    @@ -94,7 +85,7 @@ export const ActiveCycleProductivity: FC = observe Current
    - {plotType === "points" ? ( + {estimateType === "points" ? ( {`Pending points - ${cycle.backlog_estimate_points + cycle.unstarted_estimate_points + cycle.started_estimate_points}`} ) : ( {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} @@ -104,7 +95,7 @@ export const ActiveCycleProductivity: FC = observe
    {completionChartDistributionData && ( - {plotType === "points" ? ( + {estimateType === "points" ? ( = observe )}
    + ) : ( + + + ); }); diff --git a/web/core/components/cycles/active-cycle/progress.tsx b/web/core/components/cycles/active-cycle/progress.tsx index ca03e2c0bc9..f75c51526c4 100644 --- a/web/core/components/cycles/active-cycle/progress.tsx +++ b/web/core/components/cycles/active-cycle/progress.tsx @@ -1,45 +1,49 @@ "use client"; import { FC } from "react"; -import Link from "next/link"; +import { observer } from "mobx-react"; // types -import { ICycle } from "@plane/types"; +import { ICycle, IIssueFilterOptions } from "@plane/types"; // ui -import { LinearProgressIndicator } from "@plane/ui"; +import { LinearProgressIndicator, Loader } from "@plane/ui"; // components import { EmptyState } from "@/components/empty-state"; // constants -import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; +import { PROGRESS_STATE_GROUPS_DETAILS } from "@/constants/common"; import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useProjectState } from "@/hooks/store"; export type ActiveCycleProgressProps = { + cycle: ICycle | null; workspaceSlug: string; projectId: string; - cycle: ICycle; + handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void; }; -export const ActiveCycleProgress: FC = (props) => { - const { workspaceSlug, projectId, cycle } = props; +export const ActiveCycleProgress: FC = observer((props) => { + const { handleFiltersUpdate, cycle } = props; + // store hooks + const { groupedProjectStates } = useProjectState(); - const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({ + // derived values + const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, name: group.title, - value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0, + value: cycle && cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0, color: group.color, })); + const groupedIssues: any = cycle + ? { + completed: cycle?.completed_issues, + started: cycle?.started_issues, + unstarted: cycle?.unstarted_issues, + backlog: cycle?.backlog_issues, + } + : {}; - const groupedIssues: any = { - completed: cycle.completed_issues, - started: cycle.started_issues, - unstarted: cycle.unstarted_issues, - backlog: cycle.backlog_issues, - }; - - return ( - + return cycle && cycle.hasOwnProperty("started_issues") ? ( +

    Progress

    @@ -60,12 +64,20 @@ export const ActiveCycleProgress: FC = (props) => { <> {groupedIssues[group] > 0 && (
    -
    +
    { + if (groupedProjectStates) { + const states = groupedProjectStates[group].map((state) => state.id); + handleFiltersUpdate("state", states, true); + } + }} + >
    {group} @@ -93,6 +105,10 @@ export const ActiveCycleProgress: FC = (props) => {
    )} - +
    + ) : ( + + + ); -}; +}); diff --git a/web/core/components/cycles/active-cycle/use-cycles-details.ts b/web/core/components/cycles/active-cycle/use-cycles-details.ts new file mode 100644 index 00000000000..cd148705b48 --- /dev/null +++ b/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -0,0 +1,94 @@ +import { useCallback } from "react"; +import isEqual from "lodash/isEqual"; +import { useRouter } from "next/navigation"; +import useSWR from "swr"; +import { IIssueFilterOptions } from "@plane/types"; +import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { useCycle, useIssues } from "@/hooks/store"; + +interface IActiveCycleDetails { + workspaceSlug: string; + projectId: string; + cycleId: string | null | undefined; +} + +const useCyclesDetails = (props: IActiveCycleDetails) => { + // props + const { workspaceSlug, projectId, cycleId } = props; + // router + const router = useRouter(); + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues }, + } = useIssues(EIssuesStoreType.CYCLE); + + const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle(); + // derived values + const cycle = cycleId ? getCycleById(cycleId) : null; + + // fetch cycle details + useSWR( + workspaceSlug && projectId && cycle ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null, + workspaceSlug && projectId && cycle ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + useSWR( + workspaceSlug && projectId && cycle && !cycle?.distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` : null, + workspaceSlug && projectId && cycle && !cycle?.distribution + ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues") + : null + ); + useSWR( + workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + ? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION` + : null, + workspaceSlug && projectId && cycle && !cycle?.estimate_distribution + ? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points") + : null + ); + useSWR( + workspaceSlug && projectId && cycle?.id ? CYCLE_ISSUES_WITH_PARAMS(cycle?.id, { priority: "urgent,high" }) : null, + workspaceSlug && projectId && cycle?.id + ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle?.id) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + + const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false }; + + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => { + if (!workspaceSlug || !projectId || !cycleId) return; + + const newFilters: IIssueFilterOptions = {}; + Object.keys(issueFilters?.filters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = []; + }); + + let newValues: string[] = []; + + if (isEqual(newValues, value)) newValues = []; + else newValues = value; + + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { ...newFilters, [key]: newValues }, + cycleId.toString() + ); + if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`); + }, + [workspaceSlug, projectId, cycleId, issueFilters, updateFilters, router] + ); + return { + cycle, + cycleId, + router, + handleFiltersUpdate, + cycleIssueDetails, + }; +}; +export default useCyclesDetails; diff --git a/web/core/components/cycles/analytics-sidebar/index.ts b/web/core/components/cycles/analytics-sidebar/index.ts index c509152a2bf..035a5858543 100644 --- a/web/core/components/cycles/analytics-sidebar/index.ts +++ b/web/core/components/cycles/analytics-sidebar/index.ts @@ -1,3 +1,5 @@ export * from "./root"; export * from "./issue-progress"; export * from "./progress-stats"; +export * from "./sidebar-header"; +export * from "./sidebar-details"; diff --git a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 1ca239af7e7..2f9b4b79e9d 100644 --- a/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -5,12 +5,11 @@ import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; -import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react"; +import { ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; -import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; -import { CustomSelect, Spinner } from "@plane/ui"; +import { ICycle, IIssueFilterOptions, TCycleEstimateType, TCyclePlotType, TProgressSnapshot } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; // components -import ProgressChart from "@/components/core/sidebar/progress-chart"; import { CycleProgressStats } from "@/components/cycles"; // constants import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; @@ -19,6 +18,7 @@ import { getDate } from "@/helpers/date-time.helper"; // hooks import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store"; // plane web constants +import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar"; import { EEstimateSystem } from "@/plane-web/constants/estimates"; type TCycleAnalyticsProgress = { @@ -27,11 +27,6 @@ type TCycleAnalyticsProgress = { cycleId: string; }; -const cycleBurnDownChartOptions = [ - { value: "burndown", label: "Issues" }, - { value: "points", label: "Points" }, -]; - const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { if (!cycleDetails || cycleDetails === null) return cycleDetails; @@ -47,6 +42,18 @@ const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => { return updatedCycleDetails; }; +type options = { + value: string; + label: string; +}; +export const cycleChartOptions: options[] = [ + { value: "burndown", label: "Burn-down" }, + { value: "burnup", label: "Burn-up" }, +]; +export const cycleEstimateOptions: options[] = [ + { value: "issues", label: "issues" }, + { value: "points", label: "points" }, +]; export const CycleAnalyticsProgress: FC = observer((props) => { // props const { workspaceSlug, projectId, cycleId } = props; @@ -55,7 +62,15 @@ export const CycleAnalyticsProgress: FC = observer((pro const peekCycle = searchParams.get("peekCycle") || undefined; // hooks const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates(); - const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails } = useCycle(); + const { + getPlotTypeByCycleId, + getEstimateTypeByCycleId, + setPlotType, + getCycleById, + fetchCycleDetails, + fetchArchivedCycleDetails, + setEstimateType, + } = useCycle(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.CYCLE); @@ -65,6 +80,7 @@ export const CycleAnalyticsProgress: FC = observer((pro // derived values const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId); + const estimateType = getEstimateTypeByCycleId(cycleId); const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false; const estimateDetails = isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId); @@ -76,7 +92,7 @@ export const CycleAnalyticsProgress: FC = observer((pro const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; const progressHeaderPercentage = cycleDetails - ? plotType === "points" + ? estimateType === "points" ? completedEstimatePoints != 0 && totalEstimatePoints != 0 ? Math.round((completedEstimatePoints / totalEstimatePoints) * 100) : 0 @@ -86,21 +102,22 @@ export const CycleAnalyticsProgress: FC = observer((pro : 0; const chartDistributionData = - plotType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; - const completionChartDistributionData = chartDistributionData?.completion_chart || undefined; + estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; const groupedIssues = useMemo( () => ({ - backlog: plotType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0, + backlog: + estimateType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0, unstarted: - plotType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0, - started: plotType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0, + estimateType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0, + started: + estimateType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0, completed: - plotType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0, + estimateType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0, cancelled: - plotType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0, + estimateType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0, }), - [plotType, cycleDetails] + [estimateType, cycleDetails] ); const cycleStartDate = getDate(cycleDetails?.start_date); @@ -108,18 +125,23 @@ export const CycleAnalyticsProgress: FC = observer((pro const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date(); const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate; const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid; + const isArchived = !!cycleDetails?.archived_at; // handlers - const onChange = async (value: TCyclePlotType) => { - setPlotType(cycleId, value); + const onChange = async (value: TCycleEstimateType) => { + setEstimateType(cycleId, value); if (!workspaceSlug || !projectId || !cycleId) return; try { setLoader(true); - await fetchCycleDetails(workspaceSlug, projectId, cycleId); + if (isArchived) { + await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId); + } else { + await fetchCycleDetails(workspaceSlug, projectId, cycleId); + } setLoader(false); } catch (error) { setLoader(false); - setPlotType(cycleId, plotType); + setEstimateType(cycleId, estimateType); } }; @@ -156,40 +178,16 @@ export const CycleAnalyticsProgress: FC = observer((pro if (!cycleDetails) return <>; return ( -
    +
    {({ open }) => ( -
    +
    {/* progress bar header */} {isCycleDateValid ? (
    Progress
    - {progressHeaderPercentage > 0 && ( -
    {`${progressHeaderPercentage}%`}
    - )}
    - {isCurrentEstimateTypeIsPoints && ( - <> -
    - {cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"} - } - onChange={onChange} - maxHeight="lg" - > - {cycleBurnDownChartOptions.map((item) => ( - - {item.label} - - ))} - -
    - {loader && } - - )} {open ? (
    + {ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label} @@ -51,7 +51,7 @@ export const AnalyticsTable: React.FC = ({ analytics, barGraphData, param + {ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
    +
    {params.x_axis === "priority" ? ( - + ) : (
    = ({ analytics, barGraphData, param
    + {item[key] ?? 0} {item[yAxisKey]}{item[yAxisKey]}